mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
51 Commits
add-resolv
...
rb/dev-int
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bec240015 | ||
|
|
a68ac2f5af | ||
|
|
61036b5bd1 | ||
|
|
798f280f5f | ||
|
|
a847a11e6e | ||
|
|
23cd526f09 | ||
|
|
0b3b23df58 | ||
|
|
c480507332 | ||
|
|
c422f3670b | ||
|
|
c86078654c | ||
|
|
f7b2f20e85 | ||
|
|
0481dc0b41 | ||
|
|
c231b9c348 | ||
|
|
0bb9cdc0a9 | ||
|
|
0851ad87f6 | ||
|
|
7914d6ae76 | ||
|
|
40afe4bd9c | ||
|
|
607952f2b4 | ||
|
|
6867043ff2 | ||
|
|
a44b1a6408 | ||
|
|
eab6580dc7 | ||
|
|
555c8b5135 | ||
|
|
3ba0d157fa | ||
|
|
a96c61ed55 | ||
|
|
afe8254456 | ||
|
|
fb330c9b59 | ||
|
|
c001eb70ab | ||
|
|
a9d7479d47 | ||
|
|
e5eaec9682 | ||
|
|
53061b7d8d | ||
|
|
71df9c6f13 | ||
|
|
8d93bf81f3 | ||
|
|
b3911fd44f | ||
|
|
4c0e5e7820 | ||
|
|
e02237716f | ||
|
|
70feb228e8 | ||
|
|
5248c835ab | ||
|
|
27c1c9d310 | ||
|
|
d91f915f89 | ||
|
|
ce5a5fdfc2 | ||
|
|
b9df421ce5 | ||
|
|
6e7f3b0499 | ||
|
|
527945cb96 | ||
|
|
693ea45092 | ||
|
|
d8bdfa99e2 | ||
|
|
a4342023ba | ||
|
|
c3c59bad9c | ||
|
|
ebfba98f1b | ||
|
|
c1e215c343 | ||
|
|
110c1ad5dc | ||
|
|
f03fcbfc59 |
61
.github/workflows/lint-fix.yml
vendored
61
.github/workflows/lint-fix.yml
vendored
@@ -1,61 +0,0 @@
|
||||
name: Lint Fix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
lint-fix:
|
||||
if: github.event.label.name == 'lint-fix'
|
||||
name: Fix linting issues
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Frontend lint fixes
|
||||
- name: Install Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm install --frozen-lockfile
|
||||
- name: Fix frontend lint issues
|
||||
run: |
|
||||
cd frontend
|
||||
npm run lint:fix
|
||||
|
||||
# Python lint fixes
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip'
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Fix python lint issues
|
||||
run: |
|
||||
pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
# Commit and push changes if any
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git diff --quiet || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
- name: Commit and push if there are changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
git config --local user.email "openhands@all-hands.dev"
|
||||
git config --local user.name "OpenHands Bot"
|
||||
git add -A
|
||||
git commit -m "🤖 Auto-fix linting issues"
|
||||
git push
|
||||
2
.github/workflows/openhands-resolver.yml
vendored
2
.github/workflows/openhands-resolver.yml
vendored
@@ -181,7 +181,6 @@ jobs:
|
||||
retention-days: 30 # Keep the artifact for 30 days
|
||||
|
||||
- name: Create draft PR or push branch
|
||||
if: always() # Create PR or branch even if the previous steps fail
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME }}
|
||||
@@ -205,7 +204,6 @@ jobs:
|
||||
|
||||
- name: Comment on issue
|
||||
uses: actions/github-script@v7
|
||||
if: always() # Comment on issue even if the previous steps fail
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
|
||||
@@ -92,32 +92,3 @@ You may also check out previous PRs in the [PR list](https://github.com/All-Hand
|
||||
|
||||
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
|
||||
please include a short message that we can add to our changelog.
|
||||
|
||||
## How to Make Effective Contributions
|
||||
|
||||
### Opening Issues
|
||||
|
||||
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/All-Hands-AI/OpenHands/issues). We will triage based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that the community has interest/effort for.
|
||||
|
||||
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
|
||||
|
||||
### Making Pull Requests
|
||||
|
||||
We're generally happy to consider all PRs, with the evaluation process varying based on the type of change:
|
||||
|
||||
#### For Small Improvements
|
||||
|
||||
Small improvements with few downsides are typically reviewed and approved quickly.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check before getting a review.
|
||||
|
||||
#### For Core Agent Changes
|
||||
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are evaluated based on three key metrics:
|
||||
|
||||
1. **Accuracy**
|
||||
2. **Efficiency**
|
||||
3. **Code Complexity**
|
||||
|
||||
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
|
||||
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
|
||||
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
|
||||
|
||||
@@ -38,9 +38,7 @@ make build
|
||||
```
|
||||
|
||||
### 3. Configuring the Language Model
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
By default, we've chosen Claude Sonnet 3.5 as our go-to model, but the world is your oyster! You can unleash the
|
||||
potential of any other LM that piques your interest.
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library. By default, we've chosen the mighty GPT-4 from OpenAI as our go-to model, but the world is your oyster! You can unleash the potential of Anthropic's suave Claude, the enigmatic Llama, or any other LM that piques your interest.
|
||||
|
||||
To configure the LM of your choice, run:
|
||||
|
||||
@@ -54,7 +52,10 @@ To configure the LM of your choice, run:
|
||||
Environment variables > config.toml variables > default variables
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.all-hands.dev/modules/usage/llms) for recommended models.
|
||||
Some alternative models may prove more challenging to tame than others. Fear not, brave adventurer! We shall soon unveil LLM-specific documentation to guide you on your quest.
|
||||
And if you've already mastered the art of wielding a model other than OpenAI's GPT, we encourage you to share your setup instructions with us by creating instructions and adding it [to our documentation](https://github.com/All-Hands-AI/OpenHands/tree/main/docs/modules/usage/llms).
|
||||
|
||||
For a full list of the LM providers and models available, please consult the [litellm documentation](https://docs.litellm.ai/docs/providers).
|
||||
|
||||
### 4. Running the application
|
||||
#### Option A: Run the Full Application
|
||||
@@ -97,10 +98,9 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`
|
||||
|
||||
### 9. Use existing Docker image
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
|
||||
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.14-nikolaik`
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
|
||||
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.14-nikolaik
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ These are the procedures and guidelines on how issues are triaged in this repo b
|
||||
* Issues may be tagged with what it relates to (**backend**, **frontend**, **agent quality**, etc.)
|
||||
|
||||
## Severity
|
||||
* **Low**: Minor issues or affecting single user.
|
||||
* **Medium**: Affecting multiple users.
|
||||
* **Critical**: Affecting all users or potential security issues.
|
||||
* **Low**: Minor issues, single user report
|
||||
* **Medium**: Affecting multiple users
|
||||
* **Critical**: Affecting all users or potential security issues
|
||||
|
||||
## Effort
|
||||
* Issues may be estimated with effort required (**small effort**, **medium effort**, **large effort**)
|
||||
@@ -17,9 +17,9 @@ These are the procedures and guidelines on how issues are triaged in this repo b
|
||||
* Issues with low implementation difficulty may be tagged with **good first issue**
|
||||
|
||||
## Not Enough Information
|
||||
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
|
||||
* If an issue is unclear and the author does not provide more information or respond to a request, the issue may be closed as **not planned** (Usually after a week).
|
||||
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear
|
||||
* If an issue is unclear and the author does not provide more information or respond to a request, the issue may be closed as **not planned** (Usually after a week)
|
||||
|
||||
## Multiple Requests/Fixes in One Issue
|
||||
* These issues will be narrowed down to one request/fix so the issue is more easily tracked and fixed.
|
||||
* Issues may be broken down into multiple issues if required.
|
||||
* These issues will be narrowed down to one request/fix so the issue is more easily tracked and fixed
|
||||
* Issues may be broken down into multiple issues if required
|
||||
|
||||
@@ -42,9 +42,9 @@ docker pull docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik
|
||||
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.14
|
||||
@@ -61,7 +61,7 @@ works best, but you have [many options](https://docs.all-hands.dev/modules/usage
|
||||
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes),
|
||||
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
|
||||
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
|
||||
or run it on tagged issues with [a github action](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md).
|
||||
or run it on tagged issues with [a github action](https://github.com/All-Hands-AI/OpenHands-resolver).
|
||||
|
||||
Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# About OpenHands
|
||||
# 📚 Misc
|
||||
|
||||
## Research Strategy
|
||||
## ⭐️ Research Strategy
|
||||
|
||||
Achieving full replication of production-grade applications with LLMs is a complex endeavor. Our strategy involves:
|
||||
|
||||
@@ -9,11 +9,34 @@ Achieving full replication of production-grade applications with LLMs is a compl
|
||||
3. **Task Planning:** Developing capabilities for bug detection, codebase management, and optimization
|
||||
4. **Evaluation:** Establishing comprehensive evaluation metrics to better understand and improve our models
|
||||
|
||||
## Default Agent
|
||||
## 🚧 Default Agent
|
||||
|
||||
Our default Agent is currently the [CodeActAgent](agents), which is capable of generating code and handling files.
|
||||
|
||||
## Built With
|
||||
## 🤝 How to Contribute
|
||||
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. Whether you're a developer, a researcher, or simply enthusiastic about advancing the field of software engineering with AI, there are many ways to get involved:
|
||||
|
||||
- **Code Contributions:** Help us develop the core functionalities, frontend interface, or sandboxing solutions
|
||||
- **Research and Evaluation:** Contribute to our understanding of LLMs in software engineering, participate in evaluating the models, or suggest improvements
|
||||
- **Feedback and Testing:** Use the OpenHands toolset, report bugs, suggest features, or provide feedback on usability
|
||||
|
||||
For details, please check [this document](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## 🤖 Join Our Community
|
||||
|
||||
We have both Slack workspace for the collaboration on building OpenHands and Discord server for discussion about anything related, e.g., this project, LLM, agent, etc.
|
||||
|
||||
- [Slack workspace](https://join.slack.com/t/opendevin/shared_invite/zt-2oikve2hu-UDxHeo8nsE69y6T7yFX_BA)
|
||||
- [Discord server](https://discord.gg/ESHStjSjD4)
|
||||
|
||||
If you would love to contribute, feel free to join our community. Let's simplify software engineering together!
|
||||
|
||||
🐚 **Code less, make more with OpenHands.**
|
||||
|
||||
[](https://star-history.com/#All-Hands-AI/OpenHands&Date)
|
||||
|
||||
## 🛠️ Built With
|
||||
|
||||
OpenHands is built using a combination of powerful frameworks and libraries, providing a robust foundation for its development. Here are the key technologies used in the project:
|
||||
|
||||
@@ -21,9 +44,6 @@ OpenHands is built using a combination of powerful frameworks and libraries, pro
|
||||
|
||||
Please note that the selection of these technologies is in progress, and additional technologies may be added or existing ones may be removed as the project evolves. We strive to adopt the most suitable and efficient tools to enhance the capabilities of OpenHands.
|
||||
|
||||
## Licensing, Contributing, Community Servers
|
||||
## 📜 License
|
||||
|
||||
Distributed under MIT [License](https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE).
|
||||
|
||||
For guides on how to contribute to OpenHands, joining our Discord and Slack servers
|
||||
[check out the OpenHands README.](https://github.com/All-Hands-AI/OpenHands?tab=readme-ov-file#-how-to-contribute)
|
||||
Distributed under the MIT License. See [our license](https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE) for more information.
|
||||
|
||||
@@ -62,3 +62,25 @@ Run OpenHands by running ```make run``` in the top level directory.
|
||||
## Technical Explanation
|
||||
|
||||
Please refer to [custom docker image section of the runtime documentation](https://docs.all-hands.dev/modules/usage/architecture/runtime#advanced-how-openhands-builds-and-maintains-od-runtime-images) for more details.
|
||||
|
||||
## Troubleshooting / Errors
|
||||
|
||||
### Error: ```useradd: UID 1000 is not unique```
|
||||
|
||||
If you see this error in the console output it is because OpenHands is trying to create the openhands user in the sandbox with a UID of 1000, however this UID is already being used in the image (for some reason). To fix this change the sandbox_user_id field in the config.toml file to a different value:
|
||||
|
||||
```toml
|
||||
[core]
|
||||
workspace_base="./workspace"
|
||||
run_as_openhands=true
|
||||
sandbox_base_container_image="custom_image"
|
||||
sandbox_user_id="1001"
|
||||
```
|
||||
|
||||
### Port use errors
|
||||
|
||||
If you see an error about a port being in use or unavailable, try deleting all running Docker Containers (run `docker ps` and `docker rm` relevant containers) and then re-running ```make run``` .
|
||||
|
||||
## Discuss
|
||||
|
||||
For other issues or questions join the [Slack](https://join.slack.com/t/opendevin/shared_invite/zt-2oikve2hu-UDxHeo8nsE69y6T7yFX_BA) or [Discord](https://discord.gg/ESHStjSjD4) and ask!
|
||||
|
||||
@@ -12,5 +12,4 @@ To use the OpenHands GitHub Action in the OpenHands repository, an OpenHands mai
|
||||
|
||||
## Installing the Action in a New Repository
|
||||
|
||||
To install the OpenHands GitHub Action in your own repository, follow
|
||||
the [README for the OpenHands Resolver](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md).
|
||||
To install the OpenHands GitHub Action in your own repository, follow the [directions in the OpenHands Resolver repo](https://github.com/All-Hands-AI/OpenHands-resolver?tab=readme-ov-file#using-the-github-actions-workflow).
|
||||
|
||||
@@ -15,9 +15,9 @@ docker pull docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.14-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.14
|
||||
|
||||
@@ -49,7 +49,7 @@ but seems to work well on most systems.
|
||||
|
||||
## All Hands Runtime
|
||||
The All Hands Runtime is currently in beta. You can request access by joining
|
||||
the #remote-runtime-limited-beta channel on Slack ([see the README](https://github.com/All-Hands-AI/OpenHands?tab=readme-ov-file#-join-our-community) for an invite).
|
||||
the #remote-runtime-limited-beta channel on Slack (see the README for an invite).
|
||||
|
||||
To use the All Hands Runtime, set the following environment variables when
|
||||
starting OpenHands:
|
||||
@@ -66,7 +66,7 @@ docker run # ...
|
||||
## Modal Runtime
|
||||
Our partners at [Modal](https://modal.com/) have also provided a runtime for OpenHands.
|
||||
|
||||
To use the Modal Runtime, create an account, and then [create an API key.](https://modal.com/settings)
|
||||
To use the Modal Runtime, create an account, and then [create an API key](https://modal.com/settings)
|
||||
|
||||
You'll then need to set the following environment variables when starting OpenHands:
|
||||
```bash
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
"APP_MODE": "oss",
|
||||
"GITHUB_CLIENT_ID": "",
|
||||
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,6 @@ export default defineConfig(({ mode }) => {
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["vitest.setup.ts"],
|
||||
reporters: "basic",
|
||||
exclude: [...configDefaults.exclude, "tests"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html", "lcov", "text-summary"],
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
// Mock requests during tests
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" }));
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
// Cleanup the document body after each test
|
||||
|
||||
@@ -20,6 +20,7 @@ from openhands.events.action import (
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.event import EventSource
|
||||
from openhands.events.observation import (
|
||||
AgentDelegateObservation,
|
||||
BrowserOutputObservation,
|
||||
@@ -257,6 +258,8 @@ class CodeActAgent(Agent):
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif isinstance(obs, FileEditObservation):
|
||||
text = truncate_content(str(obs), max_message_chars)
|
||||
if obs.source == EventSource.USER:
|
||||
text = '[User has edited a file]\n' + text
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif isinstance(obs, BrowserOutputObservation):
|
||||
text = obs.get_agent_obs_text()
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
|
||||
You also observe user actions, like "User has edited a file", and
|
||||
infer the user's long-term intentons based on these edits.
|
||||
If you think you can help the user finish the task at hand,
|
||||
you should offer a suggestion as to how you can
|
||||
help, and wait for the user to confirm.
|
||||
<IMPORTANT>
|
||||
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
|
||||
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
|
||||
|
||||
176
openhands/agenthub/codeact_agent/system_prompt.j2
Normal file
176
openhands/agenthub/codeact_agent/system_prompt.j2
Normal file
@@ -0,0 +1,176 @@
|
||||
{% set MINIMAL_SYSTEM_PREFIX %}
|
||||
A chat between a curious user and an artificial intelligence assistant.
|
||||
The assistant gives helpful, detailed answers to the user's questions.
|
||||
It also observes user actions, like "User has edited a file", and
|
||||
infers the user's long-term intentons based on these edits. If the agent thinks
|
||||
it can help the user finish the task at hand, it offers a suggestion as to how it can
|
||||
help, and waits for the user to confirm.
|
||||
|
||||
[1] The assistant can use a Python environment with <execute_ipython>, e.g.:
|
||||
<execute_ipython>
|
||||
print("Hello World!")
|
||||
</execute_ipython>
|
||||
|
||||
[2] The assistant can execute bash commands wrapped with <execute_bash>, e.g. <execute_bash> ls </execute_bash>.
|
||||
If a bash command returns exit code `-1`, this means the process is not yet finished.
|
||||
The assistant must then send a second <execute_bash>. The second <execute_bash> can be empty
|
||||
(which will retrieve any additional logs), or it can contain text to be sent to STDIN of the running process,
|
||||
or it can contain the text `ctrl+c` to interrupt the process.
|
||||
|
||||
For commands that may run indefinitely, the output should be redirected to a file and the command run
|
||||
in the background, e.g. <execute_bash> python3 app.py > server.log 2>&1 & </execute_bash>
|
||||
If a command execution result says "Command timed out. Sending SIGINT to the process",
|
||||
the assistant should retry running the command in the background.
|
||||
|
||||
[3] The assistant can edit files using <file_edit> by setting the file path and providing a draft of the new file content. The draft file content does not need to be exactly the same as the existing file content; the assistant may skip some lines and only include the parts that need to be changed.
|
||||
|
||||
IMPORTANT: When editing large file (e.g., > 300 lines), the assistant MUST SPECIFY the range of lines to be edited by setting `start` and `end` (1-indexed, both inclusive). For example, `<file_edit path="/path/to/file.txt" start=1 end=-1>` means the assistant will edit the whole file (from line 1 to the end of the file). `start=1` and `end=-1` are the default values, so the assistant can omit them if they are the same as the default values.
|
||||
BEFORE you start editing, you MUST view the ENTIRE body of the part you want to edit and get the correct begin and end line numbers.
|
||||
|
||||
When editing files, the assistant should include comments indicating where the code will not change. For example, use comments like `# no changes before` or `# no changes here` to clearly mark sections of the code that remain unchanged. This helps to provide context and ensure clarity in the edits being made.
|
||||
|
||||
Possible cases:
|
||||
- File too long: When the file to be edited is too long, the assistant should set `start` and `end` (1-indexed, both inclusive) to specify the range of lines to be edited. For example, `<file_edit path="/path/to/file.txt" start=100 end=200>` means the assistant will only edit lines 100 to 200 of `/path/to/file.txt`.
|
||||
- Append to file: If the assistant wants to append to a file, it should set both `start` and `end` to `-1`.
|
||||
- File does not exist: If `<file_edit>` is pointing to a file that does not exist, a new file with the exact content will be created.
|
||||
|
||||
Important: because line numbers are useful, the assistant should always use the provided functions to search (e.g., `search_dir`) or view the file content (e.g., `open_file`) along with the line numbers. DO NOT use other methods (e.g., `cat`) to view the file content.
|
||||
|
||||
**Example 1 (general edit for short files)**
|
||||
For example, given an existing file `/path/to/file.py` that looks like this:
|
||||
|
||||
(this is the end of the file)
|
||||
1|class MyClass:
|
||||
2| def __init__(self):
|
||||
3| self.x = 1
|
||||
4| self.y = 2
|
||||
5| self.z = 3
|
||||
6|
|
||||
7|print(MyClass().z)
|
||||
8|print(MyClass().x)
|
||||
(this is the end of the file)
|
||||
|
||||
|
||||
The assistant wants to edit the file to look like this:
|
||||
|
||||
(this is the end of the file)
|
||||
1|class MyClass:
|
||||
2| def __init__(self):
|
||||
3| self.x = 1
|
||||
4| self.y = 2
|
||||
5|
|
||||
6|print(MyClass().y)
|
||||
(this is the end of the file)
|
||||
|
||||
|
||||
The assistant may produce an edit action like this:
|
||||
<file_edit path="/path/to/file.txt" start=1 end=-1>
|
||||
class MyClass:
|
||||
def __init__(self):
|
||||
# no changes before
|
||||
self.y = 2
|
||||
# self.z is removed
|
||||
|
||||
# MyClass().z is removed
|
||||
print(MyClass().y)
|
||||
</file_edit>
|
||||
|
||||
**Example 2 (append to file for short files)**
|
||||
|
||||
For example, given an existing file `/path/to/file.py` that looks like this:
|
||||
|
||||
(this is the end of the file)
|
||||
1|class MyClass:
|
||||
2| def __init__(self):
|
||||
3| self.x = 1
|
||||
4| self.y = 2
|
||||
5| self.z = 3
|
||||
6|
|
||||
7|print(MyClass().z)
|
||||
8|print(MyClass().x)
|
||||
(this is the end of the file)
|
||||
|
||||
To append the following lines to the file:
|
||||
```python
|
||||
print(MyClass().y)
|
||||
```
|
||||
|
||||
The assistant may produce an edit action like this:
|
||||
<file_edit path="/path/to/file.txt" start=-1 end=-1>
|
||||
print(MyClass().y)
|
||||
</file_edit>
|
||||
|
||||
**Example 3 (edit for long files)**
|
||||
|
||||
Given an existing file `/path/to/file.py` that looks like this:
|
||||
|
||||
(1000 more lines above)
|
||||
1001|class MyClass:
|
||||
1002| def __init__(self):
|
||||
1003| self.x = 1
|
||||
1004| self.y = 2
|
||||
1005| self.z = 3
|
||||
1006|
|
||||
1007|print(MyClass().z)
|
||||
1008|print(MyClass().x)
|
||||
(2000 more lines below)
|
||||
|
||||
|
||||
The assistant wants to edit the file to look like this:
|
||||
|
||||
(1000 more lines above)
|
||||
1001|class MyClass:
|
||||
1002| def __init__(self):
|
||||
1003| self.x = 1
|
||||
1004| self.y = 2
|
||||
1005|
|
||||
1006|print(MyClass().y)
|
||||
(2000 more lines below)
|
||||
|
||||
The assistant may produce an edit action like this:
|
||||
|
||||
<file_edit path="/path/to/file.txt" start=1001 end=1008>
|
||||
class MyClass:
|
||||
def __init__(self):
|
||||
# no changes before
|
||||
self.y = 2
|
||||
# self.z is removed
|
||||
|
||||
# MyClass().z is removed
|
||||
print(MyClass().y)
|
||||
</file_edit>
|
||||
|
||||
|
||||
{% endset %}
|
||||
{% set BROWSING_PREFIX %}
|
||||
The assistant can browse the Internet with <execute_browse> and </execute_browse>.
|
||||
For example, <execute_browse> Tell me the usa's president using google search </execute_browse>.
|
||||
Or <execute_browse> Tell me what is in http://example.com </execute_browse>.
|
||||
{% endset %}
|
||||
{% set PIP_INSTALL_PREFIX %}
|
||||
The assistant can install Python packages using the %pip magic command in an IPython environment by using the following syntax: <execute_ipython> %pip install [package needed] </execute_ipython> and should always import packages and define variables before starting to use them.
|
||||
{% endset %}
|
||||
{% set SYSTEM_PREFIX = MINIMAL_SYSTEM_PREFIX + BROWSING_PREFIX + PIP_INSTALL_PREFIX %}
|
||||
{% set COMMAND_DOCS %}
|
||||
Apart from the standard Python library, the assistant can also use the following functions (already imported) in <execute_ipython> environment:
|
||||
{{ agent_skills_docs }}
|
||||
IMPORTANT:
|
||||
- `open_file` only returns the first 100 lines of the file by default! The assistant MUST use `scroll_down` repeatedly to read the full file BEFORE making edits!
|
||||
- Indentation is important and code that is not indented correctly will fail and require fixing before it can be run.
|
||||
- Any code issued should be less than 50 lines to avoid context being cut off!
|
||||
{% endset %}
|
||||
{% set SYSTEM_SUFFIX %}
|
||||
Responses should be concise.
|
||||
The assistant should attempt fewer things at a time instead of putting too many commands OR too much code in one "execute" block.
|
||||
Include ONLY ONE <execute_ipython>, <execute_bash>, or <execute_browse> per response, unless the assistant is finished with the task or needs more input or action from the user in order to proceed.
|
||||
If the assistant is finished with the task you MUST include <finish></finish> in your response.
|
||||
IMPORTANT: Execute code using <execute_ipython>, <execute_bash>, or <execute_browse> whenever possible.
|
||||
The assistant should utilize full file paths and the `pwd` command to prevent path-related errors.
|
||||
The assistant MUST NOT apologize to the user or thank the user after running commands or editing files. It should only address the user in response to an explicit message from the user, or to ask for more information.
|
||||
The assistant MUST NOT push any changes to GitHub unless explicitly requested to do so.
|
||||
|
||||
{% endset %}
|
||||
{# Combine all parts without newlines between them #}
|
||||
{{ SYSTEM_PREFIX -}}
|
||||
{{- COMMAND_DOCS -}}
|
||||
{{- SYSTEM_SUFFIX }}
|
||||
278
openhands/agenthub/codeact_agent/user_prompt.j2
Normal file
278
openhands/agenthub/codeact_agent/user_prompt.j2
Normal file
@@ -0,0 +1,278 @@
|
||||
{% set DEFAULT_EXAMPLE %}
|
||||
--- START OF EXAMPLE ---
|
||||
|
||||
USER: Create a list of numbers from 1 to 10, and display them in a web page at port 5000.
|
||||
|
||||
ASSISTANT:
|
||||
Sure! Let me first check the current directory:
|
||||
<execute_bash>
|
||||
ls
|
||||
</execute_bash>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
openhands@runtime:~/workspace$
|
||||
|
||||
ASSISTANT:
|
||||
There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
|
||||
<file_edit path="/workspace/app.py" start=-1 end=-1>
|
||||
from flask import Flask
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
numbers = list(range(1, 11))
|
||||
return str(numbers)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(port=5000)
|
||||
</file_edit>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
[New file /workspace/app.py is created.]
|
||||
(begin of changes)
|
||||
--- /workspace/app.py
|
||||
+++ /workspace/app.py
|
||||
@@ -1 +1,10 @@
|
||||
+from flask import Flask
|
||||
+app = Flask(__name__)
|
||||
|
||||
+@app.route('/')
|
||||
+def index():
|
||||
+ numbers = list(range(1, 11))
|
||||
+ return str(numbers)
|
||||
+
|
||||
+if __name__ == '__main__':
|
||||
+ app.run(port=5000)
|
||||
(end of changes)
|
||||
|
||||
ASSISTANT:
|
||||
I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. Let me run the Python file for you:
|
||||
<execute_bash>
|
||||
python3 app.py > server.log 2>&1 &
|
||||
</execute_bash>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
[1] 121[1]+ Exit 1 python3 app.py > server.log 2>&1
|
||||
|
||||
ASSISTANT: Looks like the server is running with PID 121 then crashed. Let me check the server log:
|
||||
<execute_bash>
|
||||
cat server.log
|
||||
</execute_bash>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
Traceback (most recent call last):
|
||||
File "/workspace/app.py", line 2, in <module>
|
||||
from flask import Flask
|
||||
ModuleNotFoundError: No module named 'flask'
|
||||
|
||||
ASSISTANT:
|
||||
It seems that Flask is not installed. Let me install Flask for you:
|
||||
<execute_bash>
|
||||
pip install flask
|
||||
</execute_bash>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
Defaulting to user installation because normal site-packages is not writeable
|
||||
Collecting flask
|
||||
Using cached flask-3.0.3-py3-none-any.whl (101 kB)
|
||||
Collecting blinker>=1.6.2
|
||||
Using cached blinker-1.7.0-py3-none-any.whl (13 kB)
|
||||
Collecting Werkzeug>=3.0.0
|
||||
Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB)
|
||||
Collecting click>=8.1.3
|
||||
Using cached click-8.1.7-py3-none-any.whl (97 kB)
|
||||
Collecting itsdangerous>=2.1.2
|
||||
Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB)
|
||||
Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3)
|
||||
Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5)
|
||||
Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask
|
||||
Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0
|
||||
|
||||
ASSISTANT:
|
||||
Now that Flask is installed, let me run the Python file again:
|
||||
<execute_bash>
|
||||
python3 app.py > server.log 2>&1 &
|
||||
</execute_bash>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
[1] 124
|
||||
|
||||
ASSISTANT:
|
||||
Let me check the server log again:
|
||||
<execute_bash>
|
||||
cat server.log
|
||||
</execute_bash>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
* Serving Flask app 'app'
|
||||
* Debug mode: off
|
||||
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
|
||||
* Running on http://127.0.0.1:5000
|
||||
Press CTRL+C to quit
|
||||
|
||||
ASSISTANT:
|
||||
The server is running on port 5000 with PID 124. You can access the list of numbers by visiting http://127.0.0.1:5000. If you have any further questions, feel free to ask!
|
||||
|
||||
USER: Now browse the newly started server's homepage and show me the content.
|
||||
|
||||
ASSISTANT:
|
||||
Sure! Let me browse the server's homepage at http://127.0.0.1:5000:
|
||||
<execute_browse>
|
||||
Get the content on "http://127.0.0.1:5000"
|
||||
</execute_browse>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
ASSISTANT:
|
||||
The content of the server's homepage is "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]". If you have any further questions, feel free to ask!
|
||||
|
||||
USER: Now kill the server, make it display the numbers in a table format.
|
||||
|
||||
ASSISTANT:
|
||||
Sure! Let me stop the server first:
|
||||
<execute_bash>
|
||||
kill 124
|
||||
</execute_bash>
|
||||
|
||||
USER:
|
||||
OBSERVATION:
|
||||
[1]+ Terminated python3 app.py > server.log 2>&1
|
||||
|
||||
ASSISTANT:
|
||||
The server has been stopped. Let me open the Python file and modify it.
|
||||
<execute_ipython>
|
||||
open_file('app.py')
|
||||
</execute_ipython>
|
||||
|
||||
USER:
|
||||
[File: /workspace/app.py (10 lines total)]
|
||||
(this is the beginning of the file)
|
||||
1|from flask import Flask
|
||||
2|app = Flask(__name__)
|
||||
3|
|
||||
4|@app.route('/')
|
||||
5|def index():
|
||||
6| numbers = list(range(1, 11))
|
||||
7| return str(numbers)
|
||||
8|
|
||||
9|if __name__ == '__main__':
|
||||
10| app.run(port=5000)
|
||||
(this is the end of the file)
|
||||
|
||||
ASSISTANT:
|
||||
I should edit the file to display the numbers in a table format. I should include correct indentation. Let me update the file:
|
||||
<file_edit path="/workspace/app.py" start=4 end=7>
|
||||
@app.route('/')
|
||||
def index():
|
||||
numbers = list(range(1, 11))
|
||||
ret = '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
|
||||
return ret
|
||||
</file_edit>
|
||||
|
||||
USER:
|
||||
Observation:
|
||||
[Edited existing file /workspace/app.py]
|
||||
[File: /workspace/app.py (10 lines total after edit)]
|
||||
(this is the beginning of the file)
|
||||
1|from flask import Flask
|
||||
2|app = Flask(__name__)
|
||||
3|
|
||||
4|@app.route('/')
|
||||
5|def index():
|
||||
6| numbers = list(range(1, 11))
|
||||
7| ret = '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
|
||||
8| return ret
|
||||
9|
|
||||
10|if __name__ == '__main__':
|
||||
11| app.run(port=5000)
|
||||
(this is the end of the file)
|
||||
[File updated (edited at line 7). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]
|
||||
|
||||
USER:
|
||||
[User has edited a file]
|
||||
OBSERVATION:
|
||||
[Existing file app.py is edited with 1 changes.]
|
||||
[begin of edit 1 / 1]
|
||||
(content before edit)
|
||||
1|from flask import Flask
|
||||
2|app = Flask(__name__)
|
||||
3|
|
||||
4|@app.route('/')
|
||||
5|def index():
|
||||
6| numbers = list(range(1, 11))
|
||||
7| ret = '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
|
||||
8| return ret
|
||||
9|
|
||||
10|if __name__ == '__main__':
|
||||
11| app.run(port=5000)
|
||||
(content after edit)
|
||||
1|from flask import Flask
|
||||
2|app = Flask(__name__)
|
||||
3|
|
||||
4|@app.route('/')
|
||||
5|def index():
|
||||
6| numbers = list(range(1, 11))
|
||||
7| ret = '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
|
||||
8| return ret
|
||||
9|
|
||||
+10|@app.route('/reversed')
|
||||
+11|
|
||||
12|if __name__ == '__main__':
|
||||
13| app.run(port=5000)
|
||||
|
||||
[end of edit 1 / 1]
|
||||
|
||||
ASSISTANT:
|
||||
I see you've started to add a new route '/reversed'. Would you like me to
|
||||
implement the logic for this route by reversing the numbers?
|
||||
|
||||
USER:
|
||||
continue
|
||||
|
||||
ASSISTANT:
|
||||
I should edit the file to display the reversed numbers in a table format. I should include correct indentation. Let me update the file:
|
||||
<file_edit path="/workspace/app.py" start=10 end=12>
|
||||
@app.route('/reversed')
|
||||
def index():
|
||||
numbers = reversed(list(range(1, 11)))
|
||||
ret = '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
|
||||
return ret
|
||||
</file_edit>
|
||||
|
||||
|
||||
ASSISTANT:
|
||||
Running the updated file:
|
||||
<execute_bash>
|
||||
python3 app.py > server.log 2>&1 &
|
||||
</execute_bash>
|
||||
|
||||
USER:
|
||||
Observation:
|
||||
[1] 126
|
||||
|
||||
ASSISTANT:
|
||||
The server is running on port 5000 with PID 126. You can access the list of numbers in a table format by visiting http://127.0.0.1:5000. Let me know if you have any further requests!
|
||||
<finish></finish>
|
||||
|
||||
--- END OF EXAMPLE ---
|
||||
{% endset %}
|
||||
Here is an example of how you can interact with the environment for task solving:
|
||||
{{ DEFAULT_EXAMPLE }}
|
||||
{% if micro_agent %}
|
||||
--- BEGIN OF GUIDELINE ---
|
||||
The following information may assist you in completing your task:
|
||||
|
||||
{{ micro_agent }}
|
||||
--- END OF GUIDELINE ---
|
||||
{% endif %}
|
||||
|
||||
NOW, LET'S START!
|
||||
@@ -282,6 +282,11 @@ class AgentController:
|
||||
if self.state.agent_state == AgentState.USER_REJECTED:
|
||||
await self.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
|
||||
return
|
||||
|
||||
if observation.source == EventSource.USER:
|
||||
if self.state.agent_state == AgentState.AWAITING_USER_INPUT:
|
||||
await self.set_agent_state_to(AgentState.RUNNING)
|
||||
|
||||
elif isinstance(observation, ErrorObservation):
|
||||
if self.state.agent_state == AgentState.ERROR:
|
||||
self.state.metrics.merge(self.state.local_metrics)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Type
|
||||
from uuid import uuid4
|
||||
@@ -38,6 +39,8 @@ from openhands.storage import get_file_store
|
||||
|
||||
|
||||
def display_message(message: str):
|
||||
if not message:
|
||||
return
|
||||
print(colored('🤖 ' + message + '\n', 'yellow'))
|
||||
|
||||
|
||||
@@ -56,7 +59,8 @@ def display_command_output(output: str):
|
||||
|
||||
|
||||
def display_file_edit(event: FileEditAction | FileEditObservation):
|
||||
print(colored(str(event), 'green'))
|
||||
# print(colored(str(event), 'green'))
|
||||
pass
|
||||
|
||||
|
||||
def display_event(event: Event):
|
||||
@@ -66,14 +70,24 @@ def display_event(event: Event):
|
||||
if isinstance(event, MessageAction):
|
||||
if event.source == EventSource.AGENT:
|
||||
display_message(event.content)
|
||||
if isinstance(event, CmdRunAction):
|
||||
elif isinstance(event, CmdRunAction):
|
||||
display_command(event.command)
|
||||
if isinstance(event, CmdOutputObservation):
|
||||
elif isinstance(event, CmdOutputObservation):
|
||||
display_command_output(event.content)
|
||||
if isinstance(event, FileEditAction):
|
||||
display_file_edit(event)
|
||||
if isinstance(event, FileEditObservation):
|
||||
elif isinstance(event, FileEditAction):
|
||||
display_file_edit(event)
|
||||
elif isinstance(event, FileEditObservation):
|
||||
if event.source == EventSource.ENVIRONMENT:
|
||||
# For file watcher events, use a different color and format
|
||||
if not event.prev_exist:
|
||||
print(colored(f'📝 File created: {event.path}', 'cyan'))
|
||||
elif event.new_content == '':
|
||||
print(colored(f'🗑️ File deleted: {event.path}', 'red'))
|
||||
else:
|
||||
print(colored(f'✏️ File modified: {event.path}', 'yellow'))
|
||||
else:
|
||||
# For regular file edits, use the standard display
|
||||
display_file_edit(event)
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -89,6 +103,15 @@ async def main():
|
||||
help='Show the version number and exit',
|
||||
default=None,
|
||||
)
|
||||
# Add the watch directory argument
|
||||
parser.add_argument(
|
||||
'-w',
|
||||
'--watch',
|
||||
type=str,
|
||||
help='Directory to watch for changes',
|
||||
metavar='DIR',
|
||||
default=None,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
@@ -110,6 +133,19 @@ async def main():
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
event_stream = EventStream(sid, file_store)
|
||||
|
||||
if args.watch:
|
||||
from openhands.intent.watch import FileWatcher
|
||||
|
||||
watch_dir = os.path.abspath(args.watch)
|
||||
if not os.path.isdir(watch_dir):
|
||||
print(
|
||||
f"Error: Watch directory '{args.watch}' does not exist or is not a directory"
|
||||
)
|
||||
return
|
||||
print(f'Starting file watcher for directory: {watch_dir}')
|
||||
file_watcher = FileWatcher(directory=watch_dir, event_stream=event_stream)
|
||||
file_watcher.start()
|
||||
|
||||
runtime_cls = get_runtime_cls(config.runtime)
|
||||
runtime: Runtime = runtime_cls( # noqa: F841
|
||||
config=config,
|
||||
@@ -124,11 +160,12 @@ async def main():
|
||||
max_iterations=config.max_iterations,
|
||||
max_budget_per_task=config.max_budget_per_task,
|
||||
agent_to_llm_config=config.get_agent_to_llm_config_map(),
|
||||
agent_configs=config.get_agent_configs(),
|
||||
event_stream=event_stream,
|
||||
)
|
||||
|
||||
async def prompt_for_next_task():
|
||||
# Run input() in a thread pool to avoid blocking the event loop
|
||||
await controller.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
|
||||
loop = asyncio.get_event_loop()
|
||||
next_message = await loop.run_in_executor(
|
||||
None, lambda: input('How can I help? >> ')
|
||||
@@ -162,6 +199,11 @@ async def main():
|
||||
controller, runtime, [AgentState.STOPPED, AgentState.ERROR]
|
||||
)
|
||||
|
||||
# Stop file watcher if it was started
|
||||
if args.watch and 'file_watcher' in locals():
|
||||
print('Stopping file watcher...')
|
||||
file_watcher.stop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
@@ -24,7 +24,6 @@ class MessageAction(Action):
|
||||
@images_urls.setter
|
||||
def images_urls(self, value):
|
||||
self.image_urls = value
|
||||
|
||||
def __str__(self) -> str:
|
||||
ret = f'**MessageAction** (source={self.source})\n'
|
||||
ret += f'CONTENT: {self.content}'
|
||||
|
||||
@@ -69,7 +69,7 @@ def action_from_dict(action: dict) -> Action:
|
||||
# images_urls has been renamed to image_urls
|
||||
if 'images_urls' in args:
|
||||
args['image_urls'] = args.pop('images_urls')
|
||||
|
||||
|
||||
try:
|
||||
decoded_action = action_class(**args)
|
||||
if 'timeout' in action:
|
||||
|
||||
1
openhands/intent/__init__.py
Normal file
1
openhands/intent/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Intent detection and processing for OpenHands."""
|
||||
464
openhands/intent/watch.py
Normal file
464
openhands/intent/watch.py
Normal file
@@ -0,0 +1,464 @@
|
||||
import os
|
||||
import time
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
from threading import Timer
|
||||
from typing import Dict, Optional, Set
|
||||
|
||||
import pathspec
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from openhands.events import EventSource, EventStream
|
||||
from openhands.events.observation import FileEditObservation
|
||||
|
||||
|
||||
class FileWatcher(FileSystemEventHandler):
|
||||
"""Watches a directory for filesystem changes and emits events to the EventStream.
|
||||
|
||||
Args:
|
||||
directory (str): The directory path to watch for changes
|
||||
event_stream (EventStream): The event stream to emit events to
|
||||
recursive (bool, optional): Whether to watch subdirectories recursively. Defaults to True.
|
||||
patterns (list[str], optional): List of glob patterns to match files against. Defaults to None.
|
||||
ignore_patterns (list[str], optional): List of glob patterns to ignore. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: str,
|
||||
event_stream: EventStream,
|
||||
recursive: bool = True,
|
||||
patterns: Optional[list[str]] = None,
|
||||
ignore_patterns: Optional[list[str]] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.directory = os.path.abspath(directory)
|
||||
self.event_stream = event_stream
|
||||
self.recursive = recursive
|
||||
self.patterns = patterns
|
||||
# Always ignore .git directory and its contents
|
||||
self.ignore_patterns = {'.git', '.git/*'}
|
||||
# Add any explicitly provided ignore patterns
|
||||
if ignore_patterns:
|
||||
self.ignore_patterns.update(ignore_patterns)
|
||||
|
||||
# Load .gitignore patterns
|
||||
self.gitignore_spec = self._load_gitignore()
|
||||
|
||||
self.observer = Observer()
|
||||
# Keep track of file contents
|
||||
self.file_contents: Dict[str, str] = {}
|
||||
# Track files with pending changes
|
||||
self.pending_changes: Set[str] = set()
|
||||
# Debounce timer for each file
|
||||
self.debounce_timers: Dict[str, Timer] = {}
|
||||
# Debounce delay in seconds
|
||||
self.debounce_delay = 0.1
|
||||
# Whether to use debouncing (disabled for testing)
|
||||
self.use_debouncing = True
|
||||
# Track recently deleted files for handling atomic renames
|
||||
self.recent_deletes: Dict[str, tuple[str, float]] = {}
|
||||
# Time window to consider a delete+create as a rename (in seconds)
|
||||
self.rename_window = 0.1
|
||||
# Initialize file contents for existing files
|
||||
self._initialize_file_contents()
|
||||
|
||||
def _load_gitignore(self) -> pathspec.PathSpec:
|
||||
"""Load .gitignore patterns from the watched directory."""
|
||||
gitignore_patterns = []
|
||||
|
||||
# Only look for .gitignore in the watched directory
|
||||
gitignore_path = os.path.join(self.directory, '.gitignore')
|
||||
try:
|
||||
if os.path.isfile(gitignore_path):
|
||||
with open(gitignore_path, 'r') as f:
|
||||
patterns = f.read().splitlines()
|
||||
# Filter out empty lines and comments
|
||||
patterns = [p for p in patterns if p and not p.startswith('#')]
|
||||
gitignore_patterns.extend(patterns)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
return pathspec.PathSpec.from_lines(
|
||||
pathspec.patterns.GitWildMatchPattern, gitignore_patterns
|
||||
)
|
||||
|
||||
def _initialize_file_contents(self):
|
||||
"""Initialize the content cache for existing files in the watched directory."""
|
||||
for root, dirs, files in os.walk(self.directory, topdown=True):
|
||||
# Filter out ignored directories to prevent walking into them
|
||||
dirs[:] = [
|
||||
d for d in dirs if not self._should_ignore(os.path.join(root, d))
|
||||
]
|
||||
|
||||
# Process files in non-ignored directories
|
||||
for file in files:
|
||||
abs_path = os.path.join(root, file)
|
||||
if not self._should_ignore(abs_path) and self._should_watch(abs_path):
|
||||
try:
|
||||
with open(abs_path, 'r', encoding='utf-8') as f:
|
||||
self.file_contents[abs_path] = f.read()
|
||||
except (IOError, UnicodeDecodeError):
|
||||
# Skip files that can't be read or aren't text files
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
"""Start watching the directory for changes."""
|
||||
self.observer.schedule(self, self.directory, recursive=self.recursive)
|
||||
self.observer.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop watching the directory."""
|
||||
# Cancel any pending timers
|
||||
for timer in self.debounce_timers.values():
|
||||
timer.cancel()
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
|
||||
def _handle_debounced_change(self, path: str):
|
||||
"""Handle a debounced file change event."""
|
||||
if path not in self.pending_changes:
|
||||
return
|
||||
|
||||
self.pending_changes.remove(path)
|
||||
self.debounce_timers.pop(path, None)
|
||||
|
||||
# Skip if file should be ignored
|
||||
if self._should_ignore(path) or not self._should_watch(path):
|
||||
return
|
||||
|
||||
# Skip if this is a neovim swap file or backup file
|
||||
if (
|
||||
path.endswith('.swp')
|
||||
or path.endswith('.swo')
|
||||
or path.endswith('~')
|
||||
or os.path.basename(path).startswith('4913')
|
||||
):
|
||||
return
|
||||
|
||||
rel_path = os.path.relpath(path, self.directory)
|
||||
old_content = self.file_contents.get(path, '')
|
||||
new_content = self._read_file_content(path)
|
||||
|
||||
# Only emit event if content actually changed
|
||||
if old_content != new_content:
|
||||
diff = self._generate_diff(old_content, new_content, rel_path)
|
||||
self.file_contents[path] = new_content
|
||||
|
||||
observation = FileEditObservation(
|
||||
path=rel_path,
|
||||
prev_exist=True,
|
||||
old_content=old_content,
|
||||
new_content=new_content,
|
||||
content=diff,
|
||||
)
|
||||
self.event_stream.add_event(observation, EventSource.USER)
|
||||
|
||||
def _schedule_debounced_change(self, path: str):
|
||||
"""Schedule a debounced change event for a file."""
|
||||
# Cancel existing timer if any
|
||||
if path in self.debounce_timers:
|
||||
self.debounce_timers[path].cancel()
|
||||
|
||||
# Create new timer
|
||||
timer = Timer(self.debounce_delay, self._handle_debounced_change, args=[path])
|
||||
timer.start()
|
||||
self.debounce_timers[path] = timer
|
||||
self.pending_changes.add(path)
|
||||
|
||||
def _should_ignore(self, path: str) -> bool:
|
||||
"""Check if the path should be ignored based on ignore patterns and .gitignore."""
|
||||
# Get path relative to watched directory
|
||||
rel_path = os.path.relpath(path, self.directory)
|
||||
|
||||
# Convert Windows paths to Unix style for consistency
|
||||
rel_path = rel_path.replace(os.sep, '/')
|
||||
|
||||
# First check if any part of the path contains .git
|
||||
path_parts = rel_path.split('/')
|
||||
for i in range(len(path_parts)):
|
||||
if path_parts[i] == '.git':
|
||||
return True
|
||||
|
||||
# Then check explicit ignore patterns
|
||||
if any(Path(rel_path).match(pattern) for pattern in self.ignore_patterns):
|
||||
return True
|
||||
|
||||
# For directories, we need to check both the directory path and path with trailing slash
|
||||
is_dir = os.path.isdir(path)
|
||||
if is_dir:
|
||||
# Check directory path both with and without trailing slash
|
||||
return self.gitignore_spec.match_file(
|
||||
rel_path
|
||||
) or self.gitignore_spec.match_file(rel_path + '/')
|
||||
|
||||
# For files, just check the path directly
|
||||
return self.gitignore_spec.match_file(rel_path)
|
||||
|
||||
def _should_watch(self, path: str) -> bool:
|
||||
"""Check if the path should be watched based on patterns."""
|
||||
if self.patterns is None:
|
||||
return True
|
||||
rel_path = os.path.relpath(path, self.directory)
|
||||
return any(Path(rel_path).match(pattern) for pattern in self.patterns)
|
||||
|
||||
def _read_file_content(self, path: str) -> str:
|
||||
"""Read the content of a file, returning empty string if it fails."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except (IOError, UnicodeDecodeError):
|
||||
return ''
|
||||
|
||||
def _generate_diff(self, old_content: str, new_content: str, path: str) -> str:
|
||||
"""Generate a unified diff between old and new content without context lines."""
|
||||
old_lines = old_content.splitlines(keepends=True)
|
||||
new_lines = new_content.splitlines(keepends=True)
|
||||
|
||||
# Generate diff with no context lines (n=0)
|
||||
diff_lines = list(
|
||||
unified_diff(
|
||||
old_lines, new_lines, fromfile=path, tofile=path, n=0, lineterm=''
|
||||
)
|
||||
)
|
||||
|
||||
# Remove the file name headers and timestamp lines (first 2 lines)
|
||||
if len(diff_lines) > 2:
|
||||
diff_lines = diff_lines[2:]
|
||||
|
||||
# Also remove the @@ lines that show line numbers
|
||||
diff_lines = [line for line in diff_lines if not line.startswith('@@')]
|
||||
|
||||
return ''.join(diff_lines)
|
||||
|
||||
def on_created(self, event: FileSystemEvent):
|
||||
"""Handle file creation event."""
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
# If this is a neovim swap file or backup file, ignore it
|
||||
if (
|
||||
event.src_path.endswith('.swp')
|
||||
or event.src_path.endswith('.swo')
|
||||
or event.src_path.endswith('~')
|
||||
or os.path.basename(event.src_path).startswith('4913')
|
||||
):
|
||||
return
|
||||
|
||||
if self._should_ignore(event.src_path) or not self._should_watch(
|
||||
event.src_path
|
||||
):
|
||||
return
|
||||
|
||||
# Check if this is part of an atomic rename operation
|
||||
rel_path = os.path.relpath(event.src_path, self.directory)
|
||||
now = time.time()
|
||||
for old_path, (old_content, timestamp) in list(self.recent_deletes.items()):
|
||||
if now - timestamp <= self.rename_window:
|
||||
# This is likely a rename operation
|
||||
new_content = self._read_file_content(event.src_path)
|
||||
if new_content == old_content:
|
||||
# This is definitely a rename, don't emit any events
|
||||
self.file_contents[event.src_path] = new_content
|
||||
self.recent_deletes.pop(old_path)
|
||||
return
|
||||
|
||||
if self.use_debouncing:
|
||||
self._schedule_debounced_change(event.src_path)
|
||||
else:
|
||||
new_content = self._read_file_content(event.src_path)
|
||||
self.file_contents[event.src_path] = new_content
|
||||
|
||||
# For new files, the diff will be all additions
|
||||
diff = self._generate_diff('', new_content, rel_path)
|
||||
|
||||
observation = FileEditObservation(
|
||||
path=rel_path,
|
||||
prev_exist=False,
|
||||
old_content='',
|
||||
new_content=new_content,
|
||||
content=diff,
|
||||
)
|
||||
self.event_stream.add_event(observation, EventSource.USER)
|
||||
|
||||
def on_modified(self, event: FileSystemEvent):
|
||||
"""Handle file modification event."""
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
# If this is a neovim swap file or backup file, ignore it
|
||||
if (
|
||||
event.src_path.endswith('.swp')
|
||||
or event.src_path.endswith('.swo')
|
||||
or event.src_path.endswith('~')
|
||||
or os.path.basename(event.src_path).startswith('4913')
|
||||
):
|
||||
return
|
||||
|
||||
if self._should_ignore(event.src_path) or not self._should_watch(
|
||||
event.src_path
|
||||
):
|
||||
return
|
||||
|
||||
if self.use_debouncing:
|
||||
self._schedule_debounced_change(event.src_path)
|
||||
else:
|
||||
rel_path = os.path.relpath(event.src_path, self.directory)
|
||||
old_content = self.file_contents.get(event.src_path, '')
|
||||
new_content = self._read_file_content(event.src_path)
|
||||
|
||||
# Only emit event if content actually changed
|
||||
if old_content != new_content:
|
||||
diff = self._generate_diff(old_content, new_content, rel_path)
|
||||
self.file_contents[event.src_path] = new_content
|
||||
|
||||
observation = FileEditObservation(
|
||||
path=rel_path,
|
||||
prev_exist=True,
|
||||
old_content=old_content,
|
||||
new_content=new_content,
|
||||
content=diff,
|
||||
)
|
||||
self.event_stream.add_event(observation, EventSource.USER)
|
||||
|
||||
def on_deleted(self, event: FileSystemEvent):
|
||||
"""Handle file deletion event."""
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
# If this is a neovim swap file or backup file, ignore it
|
||||
if (
|
||||
event.src_path.endswith('.swp')
|
||||
or event.src_path.endswith('.swo')
|
||||
or event.src_path.endswith('~')
|
||||
or os.path.basename(event.src_path).startswith('4913')
|
||||
):
|
||||
return
|
||||
|
||||
# Cancel any pending changes for this file
|
||||
if event.src_path in self.debounce_timers:
|
||||
self.debounce_timers[event.src_path].cancel()
|
||||
self.debounce_timers.pop(event.src_path)
|
||||
self.pending_changes.discard(event.src_path)
|
||||
|
||||
if self._should_ignore(event.src_path) or not self._should_watch(
|
||||
event.src_path
|
||||
):
|
||||
return
|
||||
|
||||
# Store the deleted file's content
|
||||
old_content = self.file_contents.get(event.src_path, '')
|
||||
self.file_contents.pop(event.src_path, None)
|
||||
|
||||
if self.use_debouncing:
|
||||
# Only schedule a delete timer if we haven't already scheduled one
|
||||
if event.src_path not in self.recent_deletes:
|
||||
# Store the content temporarily in case this is a rename
|
||||
self.recent_deletes[event.src_path] = (old_content, time.time())
|
||||
# Schedule cleanup of recent_deletes after the rename window
|
||||
timer = Timer(
|
||||
self.rename_window,
|
||||
self._handle_delayed_delete,
|
||||
args=[event.src_path, old_content],
|
||||
)
|
||||
timer.start()
|
||||
else:
|
||||
# Emit deletion event immediately
|
||||
rel_path = os.path.relpath(event.src_path, self.directory)
|
||||
diff = self._generate_diff(old_content, '', rel_path)
|
||||
|
||||
observation = FileEditObservation(
|
||||
path=rel_path,
|
||||
prev_exist=True,
|
||||
old_content=old_content,
|
||||
new_content='',
|
||||
content=diff,
|
||||
)
|
||||
self.event_stream.add_event(observation, EventSource.USER)
|
||||
|
||||
def _handle_delayed_delete(self, path: str, old_content: str):
|
||||
"""Handle a deletion after waiting to see if it's part of a rename."""
|
||||
# Use dict.get() to safely check if the path is still in recent_deletes
|
||||
# and its content matches what we expect
|
||||
stored = self.recent_deletes.get(path)
|
||||
if stored is not None and stored[0] == old_content:
|
||||
# This was a real deletion, not part of a rename
|
||||
rel_path = os.path.relpath(path, self.directory)
|
||||
diff = self._generate_diff(old_content, '', rel_path)
|
||||
|
||||
observation = FileEditObservation(
|
||||
path=rel_path,
|
||||
prev_exist=True,
|
||||
old_content=old_content,
|
||||
new_content='',
|
||||
content=diff,
|
||||
)
|
||||
self.event_stream.add_event(observation, EventSource.USER)
|
||||
# Use pop with a default value to avoid KeyError
|
||||
self.recent_deletes.pop(path, None)
|
||||
|
||||
def on_moved(self, event: FileSystemEvent):
|
||||
"""Handle file move/rename event."""
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
# Cancel any pending changes for the source file
|
||||
if event.src_path in self.debounce_timers:
|
||||
self.debounce_timers[event.src_path].cancel()
|
||||
self.debounce_timers.pop(event.src_path)
|
||||
self.pending_changes.discard(event.src_path)
|
||||
|
||||
# If this is a neovim swap file or backup file, ignore it
|
||||
if (
|
||||
event.src_path.endswith('.swp')
|
||||
or event.src_path.endswith('.swo')
|
||||
or event.src_path.endswith('~')
|
||||
or os.path.basename(event.src_path).startswith('4913')
|
||||
or event.dest_path.endswith('.swp')
|
||||
or event.dest_path.endswith('.swo')
|
||||
or event.dest_path.endswith('~')
|
||||
or os.path.basename(event.dest_path).startswith('4913')
|
||||
):
|
||||
return
|
||||
|
||||
if self._should_ignore(event.src_path) or not self._should_watch(
|
||||
event.src_path
|
||||
):
|
||||
return
|
||||
|
||||
# Handle source file deletion
|
||||
src_rel_path = os.path.relpath(event.src_path, self.directory)
|
||||
old_content = self.file_contents.get(event.src_path, '')
|
||||
|
||||
# For the source file, generate a deletion diff
|
||||
src_diff = self._generate_diff(old_content, '', src_rel_path)
|
||||
|
||||
observation = FileEditObservation(
|
||||
path=src_rel_path,
|
||||
prev_exist=True,
|
||||
old_content=old_content,
|
||||
new_content='',
|
||||
content=src_diff,
|
||||
)
|
||||
self.event_stream.add_event(observation, EventSource.USER)
|
||||
self.file_contents.pop(event.src_path, None)
|
||||
|
||||
# Handle destination file creation
|
||||
if not self._should_ignore(event.dest_path) and self._should_watch(
|
||||
event.dest_path
|
||||
):
|
||||
dest_rel_path = os.path.relpath(event.dest_path, self.directory)
|
||||
self.file_contents[event.dest_path] = old_content
|
||||
|
||||
# For the destination file, generate an addition diff
|
||||
dest_diff = self._generate_diff('', old_content, dest_rel_path)
|
||||
|
||||
observation = FileEditObservation(
|
||||
path=dest_rel_path,
|
||||
prev_exist=False,
|
||||
old_content='',
|
||||
new_content=old_content,
|
||||
content=dest_diff,
|
||||
)
|
||||
self.event_stream.add_event(observation, EventSource.USER)
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Linter module for OpenHands.
|
||||
|
||||
Part of this Linter module is adapted from Aider (Apache 2.0 License, [original
|
||||
code](https://github.com/paul-gauthier/aider/blob/main/aider/linter.py)).
|
||||
- Please see the [original repository](https://github.com/paul-gauthier/aider) for more information.
|
||||
- The detailed implementation of the linter can be found at: https://github.com/All-Hands-AI/openhands-aci.
|
||||
Part of this Linter module is adapted from Aider (Apache 2.0 License, [original code](https://github.com/paul-gauthier/aider/blob/main/aider/linter.py)). Please see the [original repository](https://github.com/paul-gauthier/aider) for more information.
|
||||
"""
|
||||
|
||||
from openhands_aci.linter import DefaultLinter, LintResult
|
||||
from openhands.linter.base import LintResult
|
||||
from openhands.linter.linter import DefaultLinter
|
||||
|
||||
__all__ = ['DefaultLinter', 'LintResult']
|
||||
|
||||
79
openhands/linter/base.py
Normal file
79
openhands/linter/base.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LintResult(BaseModel):
|
||||
file: str
|
||||
line: int # 1-indexed
|
||||
column: int # 1-indexed
|
||||
message: str
|
||||
|
||||
def visualize(self, half_window: int = 3) -> str:
|
||||
"""Visualize the lint result by print out all the lines where the lint result is found.
|
||||
|
||||
Args:
|
||||
half_window: The number of context lines to display around the error on each side.
|
||||
"""
|
||||
with open(self.file, 'r') as f:
|
||||
file_lines = f.readlines()
|
||||
|
||||
# Add line numbers
|
||||
_span_size = len(str(len(file_lines)))
|
||||
file_lines = [
|
||||
f'{i + 1:>{_span_size}}|{line.rstrip()}'
|
||||
for i, line in enumerate(file_lines)
|
||||
]
|
||||
|
||||
# Get the window of lines to display
|
||||
assert self.line <= len(file_lines) and self.line > 0
|
||||
line_idx = self.line - 1
|
||||
begin_window = max(0, line_idx - half_window)
|
||||
end_window = min(len(file_lines), line_idx + half_window + 1)
|
||||
|
||||
selected_lines = file_lines[begin_window:end_window]
|
||||
line_idx_in_window = line_idx - begin_window
|
||||
|
||||
# Add character hint
|
||||
_character_hint = (
|
||||
_span_size * ' '
|
||||
+ ' ' * (self.column)
|
||||
+ '^'
|
||||
+ ' ERROR HERE: '
|
||||
+ self.message
|
||||
)
|
||||
selected_lines[line_idx_in_window] = (
|
||||
f'\033[91m{selected_lines[line_idx_in_window]}\033[0m'
|
||||
+ '\n'
|
||||
+ _character_hint
|
||||
)
|
||||
return '\n'.join(selected_lines)
|
||||
|
||||
|
||||
class LinterException(Exception):
|
||||
"""Base class for all linter exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BaseLinter(ABC):
|
||||
"""Base class for all linters.
|
||||
|
||||
Each linter should be able to lint files of a specific type and return a list of (parsed) lint results.
|
||||
"""
|
||||
|
||||
encoding: str = 'utf-8'
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_extensions(self) -> list[str]:
|
||||
"""The file extensions that this linter supports, such as .py or .tsx."""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
"""Lint the given file.
|
||||
|
||||
file_path: The path to the file to lint. Required to be absolute.
|
||||
"""
|
||||
pass
|
||||
98
openhands/linter/languages/python.py
Normal file
98
openhands/linter/languages/python.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from typing import List
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.linter.base import BaseLinter, LintResult
|
||||
from openhands.linter.utils import run_cmd
|
||||
|
||||
|
||||
def python_compile_lint(fname: str) -> list[LintResult]:
|
||||
try:
|
||||
with open(fname, 'r') as f:
|
||||
code = f.read()
|
||||
compile(code, fname, 'exec') # USE TRACEBACK BELOW HERE
|
||||
return []
|
||||
except SyntaxError as err:
|
||||
err_lineno = getattr(err, 'end_lineno', err.lineno)
|
||||
err_offset = getattr(err, 'end_offset', err.offset)
|
||||
if err_offset and err_offset < 0:
|
||||
err_offset = err.offset
|
||||
return [
|
||||
LintResult(
|
||||
file=fname, line=err_lineno, column=err_offset or 1, message=err.msg
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def flake_lint(filepath: str) -> list[LintResult]:
|
||||
fatal = 'F821,F822,F831,E112,E113,E999,E902'
|
||||
flake8_cmd = f'flake8 --select={fatal} --isolated {filepath}'
|
||||
|
||||
try:
|
||||
cmd_outputs = run_cmd(flake8_cmd)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
results: list[LintResult] = []
|
||||
if not cmd_outputs:
|
||||
return results
|
||||
for line in cmd_outputs.splitlines():
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 4:
|
||||
_msg = parts[3].strip()
|
||||
if len(parts) > 4:
|
||||
_msg += ': ' + parts[4].strip()
|
||||
|
||||
try:
|
||||
line_num = int(parts[1])
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
f'Error parsing flake8 output for line: {e}. Parsed parts: {parts}. Skipping...'
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
column_num = int(parts[2])
|
||||
except ValueError as e:
|
||||
column_num = 1
|
||||
_msg = (
|
||||
parts[2].strip() + ' ' + _msg
|
||||
) # add the unparsed message to the original message
|
||||
logger.warning(
|
||||
f'Error parsing flake8 output for column: {e}. Parsed parts: {parts}. Using default column 1.'
|
||||
)
|
||||
|
||||
results.append(
|
||||
LintResult(
|
||||
file=filepath,
|
||||
line=line_num,
|
||||
column=column_num,
|
||||
message=_msg,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class PythonLinter(BaseLinter):
|
||||
@property
|
||||
def supported_extensions(self) -> List[str]:
|
||||
return ['.py']
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
error = flake_lint(file_path)
|
||||
if not error:
|
||||
error = python_compile_lint(file_path)
|
||||
return error
|
||||
|
||||
def compile_lint(self, file_path: str, code: str) -> List[LintResult]:
|
||||
try:
|
||||
compile(code, file_path, 'exec')
|
||||
return []
|
||||
except SyntaxError as e:
|
||||
return [
|
||||
LintResult(
|
||||
file=file_path,
|
||||
line=e.lineno,
|
||||
column=e.offset,
|
||||
message=str(e),
|
||||
rule='SyntaxError',
|
||||
)
|
||||
]
|
||||
74
openhands/linter/languages/treesitter.py
Normal file
74
openhands/linter/languages/treesitter.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import warnings
|
||||
|
||||
from grep_ast import TreeContext, filename_to_lang
|
||||
from grep_ast.parsers import PARSERS
|
||||
from tree_sitter_languages import get_parser
|
||||
|
||||
from openhands.linter.base import BaseLinter, LintResult
|
||||
|
||||
# tree_sitter is throwing a FutureWarning
|
||||
warnings.simplefilter('ignore', category=FutureWarning)
|
||||
|
||||
|
||||
def tree_context(fname, code, line_nums):
|
||||
context = TreeContext(
|
||||
fname,
|
||||
code,
|
||||
color=False,
|
||||
line_number=True,
|
||||
child_context=False,
|
||||
last_line=False,
|
||||
margin=0,
|
||||
mark_lois=True,
|
||||
loi_pad=3,
|
||||
# header_max=30,
|
||||
show_top_of_file_parent_scope=False,
|
||||
)
|
||||
line_nums = set(line_nums)
|
||||
context.add_lines_of_interest(line_nums)
|
||||
context.add_context()
|
||||
output = context.format()
|
||||
return output
|
||||
|
||||
|
||||
def traverse_tree(node):
|
||||
"""Traverses the tree to find errors."""
|
||||
errors = []
|
||||
if node.type == 'ERROR' or node.is_missing:
|
||||
line_no = node.start_point[0] + 1
|
||||
col_no = node.start_point[1] + 1
|
||||
error_type = 'Missing node' if node.is_missing else 'Syntax error'
|
||||
errors.append((line_no, col_no, error_type))
|
||||
|
||||
for child in node.children:
|
||||
errors += traverse_tree(child)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
class TreesitterBasicLinter(BaseLinter):
|
||||
@property
|
||||
def supported_extensions(self) -> list[str]:
|
||||
return list(PARSERS.keys())
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
"""Use tree-sitter to look for syntax errors, display them with tree context."""
|
||||
lang = filename_to_lang(file_path)
|
||||
if not lang:
|
||||
return []
|
||||
parser = get_parser(lang)
|
||||
with open(file_path, 'r') as f:
|
||||
code = f.read()
|
||||
tree = parser.parse(bytes(code, 'utf-8'))
|
||||
errors = traverse_tree(tree.root_node)
|
||||
if not errors:
|
||||
return []
|
||||
return [
|
||||
LintResult(
|
||||
file=file_path,
|
||||
line=int(line),
|
||||
column=int(col),
|
||||
message=error_details,
|
||||
)
|
||||
for line, col, error_details in errors
|
||||
]
|
||||
122
openhands/linter/linter.py
Normal file
122
openhands/linter/linter.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from openhands.linter.base import BaseLinter, LinterException, LintResult
|
||||
from openhands.linter.languages.python import PythonLinter
|
||||
from openhands.linter.languages.treesitter import TreesitterBasicLinter
|
||||
|
||||
|
||||
class DefaultLinter(BaseLinter):
|
||||
def __init__(self):
|
||||
self.linters: dict[str, list[BaseLinter]] = defaultdict(list)
|
||||
self.linters['.py'] = [PythonLinter()]
|
||||
|
||||
# Add treesitter linter as a fallback for all linters
|
||||
self.basic_linter = TreesitterBasicLinter()
|
||||
for extension in self.basic_linter.supported_extensions:
|
||||
self.linters[extension].append(self.basic_linter)
|
||||
self._supported_extensions = list(self.linters.keys())
|
||||
|
||||
@property
|
||||
def supported_extensions(self) -> list[str]:
|
||||
return self._supported_extensions
|
||||
|
||||
def lint(self, file_path: str) -> list[LintResult]:
|
||||
if not os.path.isabs(file_path):
|
||||
raise LinterException(f'File path {file_path} is not an absolute path')
|
||||
file_extension = os.path.splitext(file_path)[1]
|
||||
|
||||
linters: list[BaseLinter] = self.linters.get(file_extension, [])
|
||||
for linter in linters:
|
||||
res = linter.lint(file_path)
|
||||
# We always return the first linter's result (higher priority)
|
||||
if res:
|
||||
return res
|
||||
return []
|
||||
|
||||
def lint_file_diff(
|
||||
self, original_file_path: str, updated_file_path: str
|
||||
) -> list[LintResult]:
|
||||
"""Only return lint errors that are introduced by the diff.
|
||||
|
||||
Args:
|
||||
original_file_path: The original file path.
|
||||
updated_file_path: The updated file path.
|
||||
|
||||
Returns:
|
||||
A list of lint errors that are introduced by the diff.
|
||||
"""
|
||||
# 1. Lint the original and updated file
|
||||
original_lint_errors: list[LintResult] = self.lint(original_file_path)
|
||||
updated_lint_errors: list[LintResult] = self.lint(updated_file_path)
|
||||
|
||||
# 2. Load the original and updated file content
|
||||
with open(original_file_path, 'r') as f:
|
||||
old_lines = f.readlines()
|
||||
with open(updated_file_path, 'r') as f:
|
||||
new_lines = f.readlines()
|
||||
|
||||
# 3. Get line numbers that are changed & unchanged
|
||||
# Map the line number of the original file to the updated file
|
||||
# NOTE: this only works for lines that are not changed (i.e., equal)
|
||||
old_to_new_line_no_mapping: dict[int, int] = {}
|
||||
replace_or_inserted_lines: list[int] = []
|
||||
for (
|
||||
tag,
|
||||
old_idx_start,
|
||||
old_idx_end,
|
||||
new_idx_start,
|
||||
new_idx_end,
|
||||
) in SequenceMatcher(
|
||||
isjunk=None,
|
||||
a=old_lines,
|
||||
b=new_lines,
|
||||
).get_opcodes():
|
||||
if tag == 'equal':
|
||||
for idx, _ in enumerate(old_lines[old_idx_start:old_idx_end]):
|
||||
old_to_new_line_no_mapping[old_idx_start + idx + 1] = (
|
||||
new_idx_start + idx + 1
|
||||
)
|
||||
elif tag == 'replace' or tag == 'insert':
|
||||
for idx, _ in enumerate(old_lines[old_idx_start:old_idx_end]):
|
||||
replace_or_inserted_lines.append(new_idx_start + idx + 1)
|
||||
else:
|
||||
# omit the case of delete
|
||||
pass
|
||||
|
||||
# 4. Get pre-existing errors in unchanged lines
|
||||
# increased error elsewhere introduced by the newlines
|
||||
# i.e., we omit errors that are already in original files and report new one
|
||||
new_line_no_to_original_errors: dict[int, list[LintResult]] = defaultdict(list)
|
||||
for error in original_lint_errors:
|
||||
if error.line in old_to_new_line_no_mapping:
|
||||
new_line_no_to_original_errors[
|
||||
old_to_new_line_no_mapping[error.line]
|
||||
].append(error)
|
||||
|
||||
# 5. Select errors from lint results in new file to report
|
||||
selected_errors = []
|
||||
for error in updated_lint_errors:
|
||||
# 5.1. Error introduced by replace/insert
|
||||
if error.line in replace_or_inserted_lines:
|
||||
selected_errors.append(error)
|
||||
# 5.2. Error introduced by modified lines that impacted
|
||||
# the unchanged lines that HAVE pre-existing errors
|
||||
elif error.line in new_line_no_to_original_errors:
|
||||
# skip if the error is already reported
|
||||
# or add if the error is new
|
||||
if not any(
|
||||
original_error.message == error.message
|
||||
and original_error.column == error.column
|
||||
for original_error in new_line_no_to_original_errors[error.line]
|
||||
):
|
||||
selected_errors.append(error)
|
||||
# 5.3. Error introduced by modified lines that impacted
|
||||
# the unchanged lines that have NO pre-existing errors
|
||||
else:
|
||||
selected_errors.append(error)
|
||||
|
||||
# 6. Sort errors by line and column
|
||||
selected_errors.sort(key=lambda x: (x.line, x.column))
|
||||
return selected_errors
|
||||
3
openhands/linter/utils/__init__.py
Normal file
3
openhands/linter/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cmd import check_tool_installed, run_cmd
|
||||
|
||||
__all__ = ['run_cmd', 'check_tool_installed']
|
||||
37
openhands/linter/utils/cmd.py
Normal file
37
openhands/linter/utils/cmd.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_cmd(cmd: str, cwd: str | None = None) -> str | None:
|
||||
"""Run a command and return the output.
|
||||
|
||||
If the command succeeds, return None. If the command fails, return the stdout.
|
||||
"""
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd.split(),
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
)
|
||||
stdout, _ = process.communicate()
|
||||
if process.returncode == 0:
|
||||
return None
|
||||
return stdout
|
||||
|
||||
|
||||
def check_tool_installed(tool_name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[tool_name, '--version'],
|
||||
check=True,
|
||||
cwd=os.getcwd(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
@@ -83,21 +83,7 @@ class IssueHandler(IssueHandlerInterface):
|
||||
return re.findall(image_pattern, issue_body)
|
||||
|
||||
def _extract_issue_references(self, body: str) -> list[int]:
|
||||
# First, remove code blocks as they may contain false positives
|
||||
body = re.sub(r'```.*?```', '', body, flags=re.DOTALL)
|
||||
|
||||
# Remove inline code
|
||||
body = re.sub(r'`[^`]*`', '', body)
|
||||
|
||||
# Remove URLs that contain hash symbols
|
||||
body = re.sub(r'https?://[^\s)]*#\d+[^\s)]*', '', body)
|
||||
|
||||
# Now extract issue numbers, making sure they're not part of other text
|
||||
# The pattern matches #number that:
|
||||
# 1. Is at the start of text or after whitespace/punctuation
|
||||
# 2. Is followed by whitespace, punctuation, or end of text
|
||||
# 3. Is not part of a URL
|
||||
pattern = r'(?:^|[\s\[({]|[^\w#])#(\d+)(?=[\s,.\])}]|$)'
|
||||
pattern = r'#(\d+)'
|
||||
return [int(match) for match in re.findall(pattern, body)]
|
||||
|
||||
def _get_issue_comments(
|
||||
@@ -469,20 +455,17 @@ class PRHandler(IssueHandler):
|
||||
)
|
||||
|
||||
for issue_number in unique_issue_references:
|
||||
try:
|
||||
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}'
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
issue_data = response.json()
|
||||
issue_body = issue_data.get('body', '')
|
||||
if issue_body:
|
||||
closing_issues.append(issue_body)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}')
|
||||
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}'
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
issue_data = response.json()
|
||||
issue_body = issue_data.get('body', '')
|
||||
if issue_body:
|
||||
closing_issues.append(issue_body)
|
||||
|
||||
return closing_issues
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .apply import apply_diff
|
||||
from .patch import parse_patch
|
||||
from .apply import apply_diff
|
||||
|
||||
__all__ = ['parse_patch', 'apply_diff']
|
||||
__all__ = ["parse_patch", "apply_diff"]
|
||||
|
||||
@@ -10,33 +10,33 @@ from .snippets import remove, which
|
||||
|
||||
def _apply_diff_with_subprocess(diff, lines, reverse=False):
|
||||
# call out to patch program
|
||||
patchexec = which('patch')
|
||||
patchexec = which("patch")
|
||||
if not patchexec:
|
||||
raise SubprocessException('cannot find patch program', code=-1)
|
||||
raise SubprocessException("cannot find patch program", code=-1)
|
||||
|
||||
tempdir = tempfile.gettempdir()
|
||||
|
||||
filepath = os.path.join(tempdir, 'wtp-' + str(hash(diff.header)))
|
||||
oldfilepath = filepath + '.old'
|
||||
newfilepath = filepath + '.new'
|
||||
rejfilepath = filepath + '.rej'
|
||||
patchfilepath = filepath + '.patch'
|
||||
with open(oldfilepath, 'w') as f:
|
||||
f.write('\n'.join(lines) + '\n')
|
||||
filepath = os.path.join(tempdir, "wtp-" + str(hash(diff.header)))
|
||||
oldfilepath = filepath + ".old"
|
||||
newfilepath = filepath + ".new"
|
||||
rejfilepath = filepath + ".rej"
|
||||
patchfilepath = filepath + ".patch"
|
||||
with open(oldfilepath, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
with open(patchfilepath, 'w') as f:
|
||||
with open(patchfilepath, "w") as f:
|
||||
f.write(diff.text)
|
||||
|
||||
args = [
|
||||
patchexec,
|
||||
'--reverse' if reverse else '--forward',
|
||||
'--quiet',
|
||||
'--no-backup-if-mismatch',
|
||||
'-o',
|
||||
"--reverse" if reverse else "--forward",
|
||||
"--quiet",
|
||||
"--no-backup-if-mismatch",
|
||||
"-o",
|
||||
newfilepath,
|
||||
'-i',
|
||||
"-i",
|
||||
patchfilepath,
|
||||
'-r',
|
||||
"-r",
|
||||
rejfilepath,
|
||||
oldfilepath,
|
||||
]
|
||||
@@ -58,7 +58,7 @@ def _apply_diff_with_subprocess(diff, lines, reverse=False):
|
||||
|
||||
# do this last to ensure files get cleaned up
|
||||
if ret != 0:
|
||||
raise SubprocessException('patch program failed', code=ret)
|
||||
raise SubprocessException("patch program failed", code=ret)
|
||||
|
||||
return lines, rejlines
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ class HunkException(PatchingException):
|
||||
self.hunk = hunk
|
||||
if hunk is not None:
|
||||
super(HunkException, self).__init__(
|
||||
'{msg}, in hunk #{n}'.format(msg=msg, n=hunk)
|
||||
"{msg}, in hunk #{n}".format(msg=msg, n=hunk)
|
||||
)
|
||||
else:
|
||||
super(HunkException, self).__init__(msg)
|
||||
|
||||
@@ -8,67 +8,67 @@ from . import exceptions
|
||||
from .snippets import findall_regex, split_by_regex
|
||||
|
||||
header = namedtuple(
|
||||
'header',
|
||||
'index_path old_path old_version new_path new_version',
|
||||
"header",
|
||||
"index_path old_path old_version new_path new_version",
|
||||
)
|
||||
|
||||
diffobj = namedtuple('diffobj', 'header changes text')
|
||||
Change = namedtuple('Change', 'old new line hunk')
|
||||
diffobj = namedtuple("diffobj", "header changes text")
|
||||
Change = namedtuple("Change", "old new line hunk")
|
||||
|
||||
file_timestamp_str = '(.+?)(?:\t|:| +)(.*)'
|
||||
file_timestamp_str = "(.+?)(?:\t|:| +)(.*)"
|
||||
# .+? was previously [^:\t\n\r\f\v]+
|
||||
|
||||
# general diff regex
|
||||
diffcmd_header = re.compile('^diff.* (.+) (.+)$')
|
||||
unified_header_index = re.compile('^Index: (.+)$')
|
||||
unified_header_old_line = re.compile(r'^--- ' + file_timestamp_str + '$')
|
||||
unified_header_new_line = re.compile(r'^\+\+\+ ' + file_timestamp_str + '$')
|
||||
unified_hunk_start = re.compile(r'^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)$')
|
||||
unified_change = re.compile('^([-+ ])(.*)$')
|
||||
diffcmd_header = re.compile("^diff.* (.+) (.+)$")
|
||||
unified_header_index = re.compile("^Index: (.+)$")
|
||||
unified_header_old_line = re.compile(r"^--- " + file_timestamp_str + "$")
|
||||
unified_header_new_line = re.compile(r"^\+\+\+ " + file_timestamp_str + "$")
|
||||
unified_hunk_start = re.compile(r"^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)$")
|
||||
unified_change = re.compile("^([-+ ])(.*)$")
|
||||
|
||||
context_header_old_line = re.compile(r'^\*\*\* ' + file_timestamp_str + '$')
|
||||
context_header_new_line = re.compile('^--- ' + file_timestamp_str + '$')
|
||||
context_hunk_start = re.compile(r'^\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*$')
|
||||
context_hunk_old = re.compile(r'^\*\*\* (\d+),?(\d*) \*\*\*\*$')
|
||||
context_hunk_new = re.compile(r'^--- (\d+),?(\d*) ----$')
|
||||
context_change = re.compile('^([-+ !]) (.*)$')
|
||||
context_header_old_line = re.compile(r"^\*\*\* " + file_timestamp_str + "$")
|
||||
context_header_new_line = re.compile("^--- " + file_timestamp_str + "$")
|
||||
context_hunk_start = re.compile(r"^\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*$")
|
||||
context_hunk_old = re.compile(r"^\*\*\* (\d+),?(\d*) \*\*\*\*$")
|
||||
context_hunk_new = re.compile(r"^--- (\d+),?(\d*) ----$")
|
||||
context_change = re.compile("^([-+ !]) (.*)$")
|
||||
|
||||
ed_hunk_start = re.compile(r'^(\d+),?(\d*)([acd])$')
|
||||
ed_hunk_end = re.compile('^.$')
|
||||
ed_hunk_start = re.compile(r"^(\d+),?(\d*)([acd])$")
|
||||
ed_hunk_end = re.compile("^.$")
|
||||
# much like forward ed, but no 'c' type
|
||||
rcs_ed_hunk_start = re.compile(r'^([ad])(\d+) ?(\d*)$')
|
||||
rcs_ed_hunk_start = re.compile(r"^([ad])(\d+) ?(\d*)$")
|
||||
|
||||
default_hunk_start = re.compile(r'^(\d+),?(\d*)([acd])(\d+),?(\d*)$')
|
||||
default_hunk_mid = re.compile('^---$')
|
||||
default_change = re.compile('^([><]) (.*)$')
|
||||
default_hunk_start = re.compile(r"^(\d+),?(\d*)([acd])(\d+),?(\d*)$")
|
||||
default_hunk_mid = re.compile("^---$")
|
||||
default_change = re.compile("^([><]) (.*)$")
|
||||
|
||||
# Headers
|
||||
|
||||
# git has a special index header and no end part
|
||||
git_diffcmd_header = re.compile('^diff --git a/(.+) b/(.+)$')
|
||||
git_header_index = re.compile(r'^index ([a-f0-9]+)..([a-f0-9]+) ?(\d*)$')
|
||||
git_header_old_line = re.compile('^--- (.+)$')
|
||||
git_header_new_line = re.compile(r'^\+\+\+ (.+)$')
|
||||
git_header_file_mode = re.compile(r'^(new|deleted) file mode \d{6}$')
|
||||
git_header_binary_file = re.compile('^Binary files (.+) and (.+) differ')
|
||||
git_binary_patch_start = re.compile(r'^GIT binary patch$')
|
||||
git_binary_literal_start = re.compile(r'^literal (\d+)$')
|
||||
git_binary_delta_start = re.compile(r'^delta (\d+)$')
|
||||
base85string = re.compile(r'^[0-9A-Za-z!#$%&()*+;<=>?@^_`{|}~-]+$')
|
||||
git_diffcmd_header = re.compile("^diff --git a/(.+) b/(.+)$")
|
||||
git_header_index = re.compile(r"^index ([a-f0-9]+)..([a-f0-9]+) ?(\d*)$")
|
||||
git_header_old_line = re.compile("^--- (.+)$")
|
||||
git_header_new_line = re.compile(r"^\+\+\+ (.+)$")
|
||||
git_header_file_mode = re.compile(r"^(new|deleted) file mode \d{6}$")
|
||||
git_header_binary_file = re.compile("^Binary files (.+) and (.+) differ")
|
||||
git_binary_patch_start = re.compile(r"^GIT binary patch$")
|
||||
git_binary_literal_start = re.compile(r"^literal (\d+)$")
|
||||
git_binary_delta_start = re.compile(r"^delta (\d+)$")
|
||||
base85string = re.compile(r"^[0-9A-Za-z!#$%&()*+;<=>?@^_`{|}~-]+$")
|
||||
|
||||
bzr_header_index = re.compile('=== (.+)')
|
||||
bzr_header_index = re.compile("=== (.+)")
|
||||
bzr_header_old_line = unified_header_old_line
|
||||
bzr_header_new_line = unified_header_new_line
|
||||
|
||||
svn_header_index = unified_header_index
|
||||
svn_header_timestamp_version = re.compile(r'\((?:working copy|revision (\d+))\)')
|
||||
svn_header_timestamp = re.compile(r'.*(\(.*\))$')
|
||||
svn_header_timestamp_version = re.compile(r"\((?:working copy|revision (\d+))\)")
|
||||
svn_header_timestamp = re.compile(r".*(\(.*\))$")
|
||||
|
||||
cvs_header_index = unified_header_index
|
||||
cvs_header_rcs = re.compile(r'^RCS file: (.+)(?:,\w{1}$|$)')
|
||||
cvs_header_timestamp = re.compile(r'(.+)\t([\d.]+)')
|
||||
cvs_header_timestamp_colon = re.compile(r':([\d.]+)\t(.+)')
|
||||
old_cvs_diffcmd_header = re.compile('^diff.* (.+):(.*) (.+):(.*)$')
|
||||
cvs_header_rcs = re.compile(r"^RCS file: (.+)(?:,\w{1}$|$)")
|
||||
cvs_header_timestamp = re.compile(r"(.+)\t([\d.]+)")
|
||||
cvs_header_timestamp_colon = re.compile(r":([\d.]+)\t(.+)")
|
||||
old_cvs_diffcmd_header = re.compile("^diff.* (.+):(.*) (.+):(.*)$")
|
||||
|
||||
|
||||
def parse_patch(text):
|
||||
@@ -97,7 +97,7 @@ def parse_patch(text):
|
||||
break
|
||||
|
||||
for diff in diffs:
|
||||
difftext = '\n'.join(diff) + '\n'
|
||||
difftext = "\n".join(diff) + "\n"
|
||||
h = parse_header(diff)
|
||||
d = parse_diff(diff)
|
||||
if h or d:
|
||||
@@ -133,10 +133,10 @@ def parse_scm_header(text):
|
||||
if res:
|
||||
old_path = res.old_path
|
||||
new_path = res.new_path
|
||||
if old_path.startswith('a/'):
|
||||
if old_path.startswith("a/"):
|
||||
old_path = old_path[2:]
|
||||
|
||||
if new_path.startswith('b/'):
|
||||
if new_path.startswith("b/"):
|
||||
new_path = new_path[2:]
|
||||
|
||||
return header(
|
||||
@@ -240,10 +240,10 @@ def parse_git_header(text):
|
||||
new_path = binary.group(2)
|
||||
|
||||
if old_path and new_path:
|
||||
if old_path.startswith('a/'):
|
||||
if old_path.startswith("a/"):
|
||||
old_path = old_path[2:]
|
||||
|
||||
if new_path.startswith('b/'):
|
||||
if new_path.startswith("b/"):
|
||||
new_path = new_path[2:]
|
||||
return header(
|
||||
index_path=None,
|
||||
@@ -256,19 +256,19 @@ def parse_git_header(text):
|
||||
# if we go through all of the text without finding our normal info,
|
||||
# use the cmd if available
|
||||
if cmd_old_path and cmd_new_path and old_version and new_version:
|
||||
if cmd_old_path.startswith('a/'):
|
||||
if cmd_old_path.startswith("a/"):
|
||||
cmd_old_path = cmd_old_path[2:]
|
||||
|
||||
if cmd_new_path.startswith('b/'):
|
||||
if cmd_new_path.startswith("b/"):
|
||||
cmd_new_path = cmd_new_path[2:]
|
||||
|
||||
return header(
|
||||
index_path=None,
|
||||
# wow, I kind of hate this:
|
||||
# assume /dev/null if the versions are zeroed out
|
||||
old_path='/dev/null' if old_version == '0000000' else cmd_old_path,
|
||||
old_path="/dev/null" if old_version == "0000000" else cmd_old_path,
|
||||
old_version=old_version,
|
||||
new_path='/dev/null' if new_version == '0000000' else cmd_new_path,
|
||||
new_path="/dev/null" if new_version == "0000000" else cmd_new_path,
|
||||
new_version=new_version,
|
||||
)
|
||||
|
||||
@@ -569,10 +569,10 @@ def parse_default_diff(text):
|
||||
kind = c.group(1)
|
||||
line = c.group(2)
|
||||
|
||||
if kind == '<' and (r != old_len or r == 0):
|
||||
if kind == "<" and (r != old_len or r == 0):
|
||||
changes.append(Change(old + r, None, line, hunk_n))
|
||||
r += 1
|
||||
elif kind == '>' and (i != new_len or i == 0):
|
||||
elif kind == ">" and (i != new_len or i == 0):
|
||||
changes.append(Change(None, new + i, line, hunk_n))
|
||||
i += 1
|
||||
|
||||
@@ -627,13 +627,13 @@ def parse_unified_diff(text):
|
||||
kind = c.group(1)
|
||||
line = c.group(2)
|
||||
|
||||
if kind == '-' and (r != old_len or r == 0):
|
||||
if kind == "-" and (r != old_len or r == 0):
|
||||
changes.append(Change(old + r, None, line, hunk_n))
|
||||
r += 1
|
||||
elif kind == '+' and (i != new_len or i == 0):
|
||||
elif kind == "+" and (i != new_len or i == 0):
|
||||
changes.append(Change(None, new + i, line, hunk_n))
|
||||
i += 1
|
||||
elif kind == ' ':
|
||||
elif kind == " ":
|
||||
if r != old_len and i != new_len:
|
||||
changes.append(Change(old + r, new + i, line, hunk_n))
|
||||
r += 1
|
||||
@@ -667,7 +667,7 @@ def parse_context_diff(text):
|
||||
k = 0
|
||||
parts = split_by_regex(hunk, context_hunk_new)
|
||||
if len(parts) != 2:
|
||||
raise exceptions.ParseException('Context diff invalid', hunk_n)
|
||||
raise exceptions.ParseException("Context diff invalid", hunk_n)
|
||||
|
||||
old_hunk = parts[0]
|
||||
new_hunk = parts[1]
|
||||
@@ -695,7 +695,7 @@ def parse_context_diff(text):
|
||||
|
||||
# now have old and new set, can start processing?
|
||||
if len(old_hunk) > 0 and len(new_hunk) == 0:
|
||||
msg = 'Got unexpected change in removal hunk: '
|
||||
msg = "Got unexpected change in removal hunk: "
|
||||
# only removes left?
|
||||
while len(old_hunk) > 0:
|
||||
c = context_change.match(old_hunk[0])
|
||||
@@ -707,22 +707,22 @@ def parse_context_diff(text):
|
||||
kind = c.group(1)
|
||||
line = c.group(2)
|
||||
|
||||
if kind == '-' and (j != old_len or j == 0):
|
||||
if kind == "-" and (j != old_len or j == 0):
|
||||
changes.append(Change(old + j, None, line, hunk_n))
|
||||
j += 1
|
||||
elif kind == ' ' and (
|
||||
elif kind == " " and (
|
||||
(j != old_len and k != new_len) or (j == 0 or k == 0)
|
||||
):
|
||||
changes.append(Change(old + j, new + k, line, hunk_n))
|
||||
j += 1
|
||||
k += 1
|
||||
elif kind == '+' or kind == '!':
|
||||
elif kind == "+" or kind == "!":
|
||||
raise exceptions.ParseException(msg + kind, hunk_n)
|
||||
|
||||
continue
|
||||
|
||||
if len(old_hunk) == 0 and len(new_hunk) > 0:
|
||||
msg = 'Got unexpected change in removal hunk: '
|
||||
msg = "Got unexpected change in removal hunk: "
|
||||
# only insertions left?
|
||||
while len(new_hunk) > 0:
|
||||
c = context_change.match(new_hunk[0])
|
||||
@@ -734,16 +734,16 @@ def parse_context_diff(text):
|
||||
kind = c.group(1)
|
||||
line = c.group(2)
|
||||
|
||||
if kind == '+' and (k != new_len or k == 0):
|
||||
if kind == "+" and (k != new_len or k == 0):
|
||||
changes.append(Change(None, new + k, line, hunk_n))
|
||||
k += 1
|
||||
elif kind == ' ' and (
|
||||
elif kind == " " and (
|
||||
(j != old_len and k != new_len) or (j == 0 or k == 0)
|
||||
):
|
||||
changes.append(Change(old + j, new + k, line, hunk_n))
|
||||
j += 1
|
||||
k += 1
|
||||
elif kind == '-' or kind == '!':
|
||||
elif kind == "-" or kind == "!":
|
||||
raise exceptions.ParseException(msg + kind, hunk_n)
|
||||
continue
|
||||
|
||||
@@ -765,17 +765,17 @@ def parse_context_diff(text):
|
||||
if not (oc or nc):
|
||||
del old_hunk[0]
|
||||
del new_hunk[0]
|
||||
elif okind == ' ' and nkind == ' ' and oline == nline:
|
||||
elif okind == " " and nkind == " " and oline == nline:
|
||||
changes.append(Change(old + j, new + k, oline, hunk_n))
|
||||
j += 1
|
||||
k += 1
|
||||
del old_hunk[0]
|
||||
del new_hunk[0]
|
||||
elif okind == '-' or okind == '!' and (j != old_len or j == 0):
|
||||
elif okind == "-" or okind == "!" and (j != old_len or j == 0):
|
||||
changes.append(Change(old + j, None, oline, hunk_n))
|
||||
j += 1
|
||||
del old_hunk[0]
|
||||
elif nkind == '+' or nkind == '!' and (k != new_len or k == 0):
|
||||
elif nkind == "+" or nkind == "!" and (k != new_len or k == 0):
|
||||
changes.append(Change(None, new + k, nline, hunk_n))
|
||||
k += 1
|
||||
del new_hunk[0]
|
||||
@@ -821,7 +821,7 @@ def parse_ed_diff(text):
|
||||
old_end = int(o.group(2)) if len(o.group(2)) else old
|
||||
|
||||
hunk_kind = o.group(3)
|
||||
if hunk_kind == 'd':
|
||||
if hunk_kind == "d":
|
||||
k = 0
|
||||
while old_end >= old:
|
||||
changes.append(Change(old + k, None, None, hunk_n))
|
||||
@@ -832,7 +832,7 @@ def parse_ed_diff(text):
|
||||
|
||||
while len(hunk) > 0:
|
||||
e = ed_hunk_end.match(hunk[0])
|
||||
if not e and hunk_kind == 'c':
|
||||
if not e and hunk_kind == "c":
|
||||
k = 0
|
||||
while old_end >= old:
|
||||
changes.append(Change(old + k, None, None, hunk_n))
|
||||
@@ -852,7 +852,7 @@ def parse_ed_diff(text):
|
||||
)
|
||||
i += 1
|
||||
j += 1
|
||||
if not e and hunk_kind == 'a':
|
||||
if not e and hunk_kind == "a":
|
||||
changes.append(
|
||||
Change(
|
||||
None,
|
||||
@@ -900,7 +900,7 @@ def parse_rcs_ed_diff(text):
|
||||
old = int(o.group(2))
|
||||
size = int(o.group(3))
|
||||
|
||||
if hunk_kind == 'a':
|
||||
if hunk_kind == "a":
|
||||
old += total_change_size + 1
|
||||
total_change_size += size
|
||||
while size > 0 and len(hunk) > 0:
|
||||
@@ -910,7 +910,7 @@ def parse_rcs_ed_diff(text):
|
||||
|
||||
del hunk[0]
|
||||
|
||||
elif hunk_kind == 'd':
|
||||
elif hunk_kind == "d":
|
||||
total_change_size -= size
|
||||
while size > 0:
|
||||
changes.append(Change(old + j, None, None, hunk_n))
|
||||
@@ -938,8 +938,8 @@ def parse_git_binary_diff(text):
|
||||
# the sizes are used as latch-up
|
||||
new_size = 0
|
||||
old_size = 0
|
||||
old_encoded = ''
|
||||
new_encoded = ''
|
||||
old_encoded = ""
|
||||
new_encoded = ""
|
||||
for line in lines:
|
||||
if cmd_old_path is None and cmd_new_path is None:
|
||||
hm = git_diffcmd_header.match(line)
|
||||
@@ -978,11 +978,11 @@ def parse_git_binary_diff(text):
|
||||
change = Change(None, 0, added_data, None)
|
||||
changes.append(change)
|
||||
new_size = 0
|
||||
new_encoded = ''
|
||||
new_encoded = ""
|
||||
else:
|
||||
# Invalid line format
|
||||
new_size = 0
|
||||
new_encoded = ''
|
||||
new_encoded = ""
|
||||
|
||||
# the second is removed file
|
||||
if old_size == 0:
|
||||
@@ -1006,10 +1006,10 @@ def parse_git_binary_diff(text):
|
||||
change = Change(0, None, None, removed_data)
|
||||
changes.append(change)
|
||||
old_size = 0
|
||||
old_encoded = ''
|
||||
old_encoded = ""
|
||||
else:
|
||||
# Invalid line format
|
||||
old_size = 0
|
||||
old_encoded = ''
|
||||
old_encoded = ""
|
||||
|
||||
return changes
|
||||
|
||||
@@ -54,7 +54,7 @@ def which(program):
|
||||
if is_exe(program):
|
||||
return program
|
||||
else:
|
||||
for path in os.environ['PATH'].split(os.pathsep):
|
||||
for path in os.environ["PATH"].split(os.pathsep):
|
||||
path = path.strip('"')
|
||||
exe_file = os.path.join(path, program)
|
||||
if is_exe(exe_file):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
This is a Python repo for openhands-resolver, a library that attempts to resolve github issues with the AI agent OpenHands.
|
||||
|
||||
- Setup: `poetry install --with test --with dev`
|
||||
- Testing: `poetry run pytest tests/test_*.py`
|
||||
- Testing: `poetry run pytest tests/test_*.py`
|
||||
@@ -1,4 +1,4 @@
|
||||
This is a node repo for an RSS parser.
|
||||
- Setup: `yes | npm install`
|
||||
- Testing: `SKIP_BROWSER_TESTS=1 npm test`
|
||||
- Writing Tests: Add to the `test` directory.
|
||||
- Writing Tests: Add to the `test` directory.
|
||||
@@ -3,7 +3,7 @@ The feedback may be addressed to specific code files. In this case the file loca
|
||||
Please update the code based on the feedback for the repository in /workspace.
|
||||
An environment has been set up for you to start working. You may assume all necessary tools are installed.
|
||||
|
||||
# Issues addressed
|
||||
# Issues addressed
|
||||
{{ issues }}
|
||||
|
||||
# Review comments
|
||||
@@ -21,4 +21,4 @@ You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instructi
|
||||
Some basic information about this repository:
|
||||
{{ repo_instruction }}{% endif %}
|
||||
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
@@ -14,4 +14,4 @@ For all changes to actual application code (e.g. in Python or Javascript), add a
|
||||
Run the tests, and if they pass you are done!
|
||||
You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
When you think you have fixed the issue through code changes, please call the finish action to end the interaction.
|
||||
When you think you have fixed the issue through code changes, please call the finish action to end the interaction.
|
||||
@@ -10,4 +10,4 @@ You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instructi
|
||||
Some basic information about this repository:
|
||||
{{ repo_instruction }}{% endif %}
|
||||
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
@@ -316,7 +316,7 @@ async def resolve_issue(
|
||||
issue_number: int,
|
||||
comment_id: int | None,
|
||||
reset_logger: bool = False,
|
||||
) -> ResolverOutput:
|
||||
) -> None:
|
||||
"""Resolve a single github issue.
|
||||
|
||||
Args:
|
||||
@@ -412,9 +412,9 @@ async def resolve_issue(
|
||||
data = ResolverOutput.model_validate_json(line)
|
||||
if data.issue.number == issue_number:
|
||||
logger.warning(
|
||||
f'Issue {issue_number} was already processed. Returning existing output.'
|
||||
f'Issue {issue_number} was already processed. Skipping.'
|
||||
)
|
||||
return data
|
||||
return
|
||||
|
||||
output_fp = open(output_file, 'a')
|
||||
|
||||
@@ -458,7 +458,6 @@ async def resolve_issue(
|
||||
finally:
|
||||
output_fp.close()
|
||||
logger.info('Finished.')
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -7,7 +7,6 @@ import subprocess
|
||||
import jinja2
|
||||
import litellm
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -427,36 +426,21 @@ def update_existing_pull_request(
|
||||
return pr_url
|
||||
|
||||
|
||||
class ProcessIssueResult(BaseModel):
|
||||
success: bool
|
||||
url: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def create_pull_request_from_resolver_output(
|
||||
def process_single_issue(
|
||||
output_dir: str,
|
||||
resolver_output: ResolverOutput,
|
||||
github_token: str,
|
||||
github_username: str | None,
|
||||
github_username: str,
|
||||
pr_type: str,
|
||||
llm_config: LLMConfig,
|
||||
fork_owner: str | None,
|
||||
send_on_failure: bool,
|
||||
) -> ProcessIssueResult:
|
||||
if github_username is None:
|
||||
return ProcessIssueResult(
|
||||
success=False,
|
||||
error='GITHUB_USERNAME environment variable not set',
|
||||
)
|
||||
|
||||
) -> None:
|
||||
if not resolver_output.success and not send_on_failure:
|
||||
print(
|
||||
f'Issue {resolver_output.issue.number} was not successfully resolved. Skipping PR creation.'
|
||||
)
|
||||
return ProcessIssueResult(
|
||||
success=False,
|
||||
error='Issue was not successfully resolved',
|
||||
)
|
||||
return
|
||||
|
||||
issue_type = resolver_output.issue_type
|
||||
|
||||
@@ -481,30 +465,26 @@ def create_pull_request_from_resolver_output(
|
||||
|
||||
make_commit(patched_repo_dir, resolver_output.issue, issue_type)
|
||||
|
||||
try:
|
||||
if issue_type == 'pr':
|
||||
url = update_existing_pull_request(
|
||||
github_issue=resolver_output.issue,
|
||||
github_token=github_token,
|
||||
github_username=github_username,
|
||||
patch_dir=patched_repo_dir,
|
||||
additional_message=resolver_output.success_explanation,
|
||||
llm_config=llm_config,
|
||||
)
|
||||
else:
|
||||
url = send_pull_request(
|
||||
github_issue=resolver_output.issue,
|
||||
github_token=github_token,
|
||||
github_username=github_username,
|
||||
patch_dir=patched_repo_dir,
|
||||
pr_type=pr_type,
|
||||
llm_config=llm_config,
|
||||
fork_owner=fork_owner,
|
||||
additional_message=resolver_output.success_explanation,
|
||||
)
|
||||
return ProcessIssueResult(success=True, url=url)
|
||||
except Exception as e:
|
||||
return ProcessIssueResult(success=False, error=str(e))
|
||||
if issue_type == 'pr':
|
||||
update_existing_pull_request(
|
||||
github_issue=resolver_output.issue,
|
||||
github_token=github_token,
|
||||
github_username=github_username,
|
||||
patch_dir=patched_repo_dir,
|
||||
additional_message=resolver_output.success_explanation,
|
||||
llm_config=llm_config,
|
||||
)
|
||||
else:
|
||||
send_pull_request(
|
||||
github_issue=resolver_output.issue,
|
||||
github_token=github_token,
|
||||
github_username=github_username,
|
||||
patch_dir=patched_repo_dir,
|
||||
pr_type=pr_type,
|
||||
llm_config=llm_config,
|
||||
fork_owner=fork_owner,
|
||||
additional_message=resolver_output.success_explanation,
|
||||
)
|
||||
|
||||
|
||||
def process_all_successful_issues(
|
||||
@@ -519,7 +499,7 @@ def process_all_successful_issues(
|
||||
for resolver_output in load_all_resolver_outputs(output_path):
|
||||
if resolver_output.success:
|
||||
print(f'Processing issue {resolver_output.issue.number}')
|
||||
create_pull_request_from_resolver_output(
|
||||
process_single_issue(
|
||||
output_dir,
|
||||
resolver_output,
|
||||
github_token,
|
||||
@@ -636,7 +616,7 @@ def main():
|
||||
resolver_output = load_single_resolver_output(output_path, issue_number)
|
||||
if not github_username:
|
||||
raise ValueError('Github username is required.')
|
||||
create_pull_request_from_resolver_output(
|
||||
process_single_issue(
|
||||
my_args.output_dir,
|
||||
resolver_output,
|
||||
github_token,
|
||||
|
||||
@@ -4,11 +4,13 @@ import re
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from openhands_aci.utils.diff import get_diff
|
||||
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import FileEditAction, FileReadAction, FileWriteAction
|
||||
from openhands.events.action import (
|
||||
FileEditAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
FileEditObservation,
|
||||
@@ -20,6 +22,7 @@ from openhands.linter import DefaultLinter
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.utils.chunk_localizer import Chunk, get_top_k_chunk_matches
|
||||
from openhands.utils.diff import get_diff
|
||||
|
||||
SYS_MSG = """Your job is to produce a new version of the file based on the old version and the
|
||||
provided draft of the new version. The provided draft may be incomplete (it may skip lines) and/or incorrectly indented. You should try to apply the changes present in the draft to the old version, and output a new version of the file.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ResolveIssueDataModel(BaseModel):
|
||||
owner: str = Field(..., description='Github owner of the repo')
|
||||
repo: str = Field(..., description='Github repository name')
|
||||
token: str = Field(..., description='Github token to access the repository')
|
||||
username: str = Field(..., description='Github username to access the repository')
|
||||
max_iterations: int = Field(50, description='Maximum number of iterations to run')
|
||||
issue_type: Literal['issue', 'pr'] = Field(
|
||||
..., description='Type of issue to resolve (issue or pr)'
|
||||
)
|
||||
issue_number: int = Field(..., description='Issue number to resolve')
|
||||
comment_id: int | None = Field(
|
||||
None, description='Optional ID of a specific comment to focus on'
|
||||
)
|
||||
# PR-related fields
|
||||
pr_type: Literal['branch', 'draft', 'ready'] = Field(
|
||||
'draft', description='Type of PR to create (branch, draft, ready)'
|
||||
)
|
||||
fork_owner: str | None = Field(None, description='Optional owner to fork to')
|
||||
send_on_failure: bool = Field(
|
||||
False, description='Whether to send PR even if resolution failed'
|
||||
)
|
||||
|
||||
|
||||
@@ -5,10 +5,28 @@ import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from pathspec import PathSpec
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.security.options import SecurityAnalyzers
|
||||
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
|
||||
from openhands.server.github import (
|
||||
GITHUB_CLIENT_ID,
|
||||
GITHUB_CLIENT_SECRET,
|
||||
UserVerifier,
|
||||
authenticate_github_user,
|
||||
)
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
import litellm
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
@@ -22,8 +40,6 @@ from fastapi import (
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathspec import PathSpec
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
from pydantic import BaseModel
|
||||
|
||||
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
|
||||
@@ -47,33 +63,9 @@ from openhands.events.serialization import event_to_dict
|
||||
from openhands.events.stream import AsyncEventStreamWrapper
|
||||
from openhands.llm import bedrock
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.security.options import SecurityAnalyzers
|
||||
from openhands.server.auth.auth import get_sid_from_token, sign_token
|
||||
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
|
||||
from openhands.server.github import (
|
||||
GITHUB_CLIENT_ID,
|
||||
GITHUB_CLIENT_SECRET,
|
||||
UserVerifier,
|
||||
authenticate_github_user,
|
||||
)
|
||||
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
|
||||
from openhands.server.session import SessionManager
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
import litellm
|
||||
|
||||
|
||||
from openhands.resolver.resolve_issue import resolve_issue as resolve_github_issue
|
||||
from openhands.resolver.send_pull_request import (
|
||||
create_pull_request_from_resolver_output,
|
||||
)
|
||||
from openhands.server.data_models.issue_models import (
|
||||
ResolveIssueDataModel,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -460,108 +452,6 @@ async def get_security_analyzers():
|
||||
return sorted(SecurityAnalyzers.keys())
|
||||
|
||||
|
||||
@app.post('/api/resolver/resolve-issue')
|
||||
async def resolve_issue(request: Request) -> dict[str, str | dict[str, Any]]:
|
||||
"""Resolve a GitHub issue using OpenHands and create a pull request.
|
||||
|
||||
This endpoint:
|
||||
1. Analyzes the issue content and comments
|
||||
2. Makes necessary code changes
|
||||
3. Creates a pull request or branch with the changes
|
||||
|
||||
Args:
|
||||
request: The incoming request object
|
||||
|
||||
Returns:
|
||||
A dictionary containing the resolution results with keys:
|
||||
- status: 'success' or 'error'
|
||||
- output: The resolver output (on success)
|
||||
- result: PR creation result (on success)
|
||||
- message: Error message (on error)
|
||||
"""
|
||||
try:
|
||||
# Get LLM config from current session
|
||||
llm_config = config.get_llm_config()
|
||||
|
||||
# Get runtime container image from config
|
||||
runtime_container_image = config.sandbox.runtime_container_image
|
||||
if not runtime_container_image:
|
||||
raise ValueError('Runtime container image not configured')
|
||||
|
||||
# Get GitHub token from environment
|
||||
github_token = os.environ.get('GITHUB_TOKEN')
|
||||
if not github_token:
|
||||
raise ValueError('GITHUB_TOKEN environment variable not set')
|
||||
|
||||
# Get GitHub username from environment
|
||||
github_username = os.environ.get('GITHUB_USERNAME')
|
||||
|
||||
# Parse request data
|
||||
body = await request.json()
|
||||
data = ResolveIssueDataModel(**body)
|
||||
|
||||
# Create temporary output directory for any intermediate files
|
||||
output_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
# Process the issue resolution
|
||||
resolver_output = await resolve_github_issue(
|
||||
owner=data.owner,
|
||||
repo=data.repo,
|
||||
token=data.token,
|
||||
username=data.username,
|
||||
max_iterations=data.max_iterations,
|
||||
output_dir=output_dir,
|
||||
llm_config=llm_config,
|
||||
runtime_container_image=runtime_container_image,
|
||||
prompt_template='', # Using default for now
|
||||
issue_type=data.issue_type,
|
||||
repo_instruction=None,
|
||||
issue_number=data.issue_number,
|
||||
comment_id=data.comment_id,
|
||||
reset_logger=True,
|
||||
)
|
||||
finally:
|
||||
# Cleanup temp directory
|
||||
if os.path.exists(output_dir):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
if not resolver_output:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'No resolver output generated for issue {data.issue_number}',
|
||||
}
|
||||
|
||||
# Create pull request
|
||||
result = create_pull_request_from_resolver_output(
|
||||
output_dir=output_dir,
|
||||
resolver_output=resolver_output,
|
||||
github_token=github_token,
|
||||
github_username=github_username,
|
||||
pr_type=data.pr_type,
|
||||
llm_config=llm_config,
|
||||
fork_owner=data.fork_owner,
|
||||
send_on_failure=data.send_on_failure,
|
||||
)
|
||||
|
||||
if result.success:
|
||||
return {
|
||||
'status': 'success',
|
||||
'output': resolver_output.model_dump(),
|
||||
'result': {'url': result.url},
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'status': 'error',
|
||||
'output': resolver_output.model_dump(),
|
||||
'message': result.error or 'Unknown error',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
|
||||
FILES_TO_IGNORE = [
|
||||
'.git/',
|
||||
'.DS_Store',
|
||||
|
||||
41
openhands/utils/diff.py
Normal file
41
openhands/utils/diff.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import difflib
|
||||
|
||||
import whatthepatch
|
||||
|
||||
|
||||
def get_diff(old_contents: str, new_contents: str, filepath: str = 'file') -> str:
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
old_contents.split('\n'),
|
||||
new_contents.split('\n'),
|
||||
fromfile=filepath,
|
||||
tofile=filepath,
|
||||
# do not output unchange lines
|
||||
# because they can cause `parse_diff` to fail
|
||||
n=0,
|
||||
)
|
||||
)
|
||||
return '\n'.join(map(lambda x: x.rstrip(), diff))
|
||||
|
||||
|
||||
def parse_diff(diff_patch: str) -> list[whatthepatch.patch.Change]:
|
||||
# handle empty patch
|
||||
if diff_patch.strip() == '':
|
||||
return []
|
||||
|
||||
patch = whatthepatch.parse_patch(diff_patch)
|
||||
patch_list = list(patch)
|
||||
assert len(patch_list) == 1, (
|
||||
'parse_diff only supports single file diff. But got:\nPATCH:\n'
|
||||
+ diff_patch
|
||||
+ '\nPATCH LIST:\n'
|
||||
+ str(patch_list)
|
||||
)
|
||||
changes = patch_list[0].changes
|
||||
|
||||
# ignore changes that are the same (i.e., old_lineno == new_lineno)
|
||||
output_changes = []
|
||||
for change in changes:
|
||||
if change.old != change.new:
|
||||
output_changes.append(change)
|
||||
return output_changes
|
||||
13
poetry.lock
generated
13
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aenum"
|
||||
@@ -5629,6 +5629,7 @@ optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"},
|
||||
{file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"},
|
||||
@@ -5641,18 +5642,17 @@ numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""}
|
||||
|
||||
[[package]]
|
||||
name = "openhands-aci"
|
||||
version = "0.1.1"
|
||||
version = "0.1.0"
|
||||
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.12"
|
||||
files = [
|
||||
{file = "openhands_aci-0.1.1-py3-none-any.whl", hash = "sha256:8831f97b887571005dca0d70a9f6f0a4f9feb35d3d41f499e70d72b5fb68a599"},
|
||||
{file = "openhands_aci-0.1.1.tar.gz", hash = "sha256:705b74a12a8f428e64295b5de125f553500f62ef5ab3a5a6284d8fcf638025e6"},
|
||||
{file = "openhands_aci-0.1.0-py3-none-any.whl", hash = "sha256:f28e5a32e394d1e643f79bf8af27fe44d039cb71729d590f9f3ee0c23c075f00"},
|
||||
{file = "openhands_aci-0.1.0.tar.gz", hash = "sha256:babc55f516efbb27eb7e528662e14b75c902965c48a110408fda824b83ea4461"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
diskcache = ">=5.6.3,<6.0.0"
|
||||
flake8 = "*"
|
||||
gitpython = "*"
|
||||
grep-ast = "0.3.3"
|
||||
litellm = "*"
|
||||
@@ -5661,7 +5661,6 @@ numpy = "*"
|
||||
pandas = "*"
|
||||
scipy = "*"
|
||||
tree-sitter = "0.21.3"
|
||||
whatthepatch = ">=1.0.6,<2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
@@ -10212,4 +10211,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "b710448cff0788b563f4d7614fca438ab0b9fe19903a061750012c56da95ff37"
|
||||
content-hash = "8718ffe2ed836fca6c646c37bdad2c9c8e63ebd7ec881f420148fef5095d19e4"
|
||||
|
||||
@@ -41,6 +41,7 @@ pyarrow = "17.0.0" # transitive dependency, pinned here to avoid conflicts
|
||||
tenacity = "^8.5.0"
|
||||
zope-interface = "7.1.1"
|
||||
pathspec = "^0.12.1"
|
||||
watchdog = "^3.0.0"
|
||||
google-cloud-aiplatform = "*"
|
||||
anthropic = {extras = ["vertex"], version = "*"}
|
||||
grep-ast = "0.3.3"
|
||||
@@ -63,7 +64,7 @@ opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
|
||||
modal = "^0.64.145"
|
||||
runloop-api-client = "0.7.0"
|
||||
pygithub = "^2.5.0"
|
||||
openhands-aci = "^0.1.1"
|
||||
openhands-aci = "^0.1.0"
|
||||
|
||||
[tool.poetry.group.llama-index.dependencies]
|
||||
llama-index = "*"
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from conftest import TEST_IN_CI, _close_test_runtime, _load_runtime
|
||||
from openhands_aci.utils.diff import get_diff
|
||||
from conftest import (
|
||||
TEST_IN_CI,
|
||||
_close_test_runtime,
|
||||
_load_runtime,
|
||||
)
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import FileEditAction, FileReadAction
|
||||
from openhands.events.observation import FileEditObservation
|
||||
from openhands.utils.diff import get_diff
|
||||
|
||||
ORGINAL = """from flask import Flask
|
||||
app = Flask(__name__)
|
||||
|
||||
503
tests/test_file_watcher.py
Normal file
503
tests/test_file_watcher.py
Normal file
@@ -0,0 +1,503 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from watchdog.events import FileCreatedEvent, FileDeletedEvent, FileModifiedEvent, FileMovedEvent
|
||||
|
||||
from openhands.events import EventSource
|
||||
from openhands.events.observation import FileEditObservation
|
||||
from openhands.intent.watch import FileWatcher
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_stream():
|
||||
"""Create a mock event stream."""
|
||||
stream = MagicMock()
|
||||
stream.add_event = MagicMock()
|
||||
return stream
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def watcher(mock_event_stream, temp_dir):
|
||||
"""Create a FileWatcher instance with mocked components and debouncing disabled."""
|
||||
with patch('watchdog.observers.Observer'):
|
||||
watcher = FileWatcher(temp_dir, mock_event_stream)
|
||||
watcher.use_debouncing = False # Disable debouncing for basic tests
|
||||
yield watcher
|
||||
|
||||
|
||||
def create_test_file(path: str, content: str = ""):
|
||||
"""Create a test file with given content."""
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def test_file_creation(watcher, temp_dir):
|
||||
"""Test that file creation events are handled correctly."""
|
||||
file_path = os.path.join(temp_dir, "test.txt")
|
||||
content = "Hello, World!"
|
||||
|
||||
# Create the file
|
||||
create_test_file(file_path, content)
|
||||
|
||||
# Simulate watchdog event
|
||||
event = FileCreatedEvent(file_path)
|
||||
watcher.on_created(event)
|
||||
|
||||
# Verify the event was emitted correctly
|
||||
watcher.event_stream.add_event.assert_called_once()
|
||||
args = watcher.event_stream.add_event.call_args[0]
|
||||
observation, source = args
|
||||
|
||||
assert isinstance(observation, FileEditObservation)
|
||||
assert observation.path == "test.txt" # Should be relative path
|
||||
assert observation.prev_exist is False
|
||||
assert observation.old_content == ""
|
||||
assert observation.new_content == content
|
||||
assert observation.content.startswith("+Hello, World!")
|
||||
assert source == EventSource.USER
|
||||
|
||||
|
||||
def test_file_modification(watcher, temp_dir):
|
||||
"""Test that file modification events are handled correctly."""
|
||||
file_path = os.path.join(temp_dir, "test.txt")
|
||||
old_content = "Old content"
|
||||
new_content = "New content"
|
||||
|
||||
# Create initial file
|
||||
create_test_file(file_path, old_content)
|
||||
watcher.file_contents[file_path] = old_content
|
||||
|
||||
# Update the file
|
||||
create_test_file(file_path, new_content)
|
||||
|
||||
# Simulate watchdog event
|
||||
event = FileModifiedEvent(file_path)
|
||||
watcher.on_modified(event)
|
||||
|
||||
# Verify the event was emitted correctly
|
||||
watcher.event_stream.add_event.assert_called_once()
|
||||
observation, source = watcher.event_stream.add_event.call_args[0]
|
||||
|
||||
assert isinstance(observation, FileEditObservation)
|
||||
assert observation.path == "test.txt"
|
||||
assert observation.prev_exist is True
|
||||
assert observation.old_content == old_content
|
||||
assert observation.new_content == new_content
|
||||
assert "-Old content" in observation.content
|
||||
assert "+New content" in observation.content
|
||||
assert source == EventSource.USER
|
||||
|
||||
|
||||
def test_file_deletion(watcher, temp_dir):
|
||||
"""Test that file deletion events are handled correctly."""
|
||||
file_path = os.path.join(temp_dir, "test.txt")
|
||||
content = "Content to delete"
|
||||
|
||||
# Create initial file
|
||||
create_test_file(file_path, content)
|
||||
watcher.file_contents[file_path] = content
|
||||
|
||||
# Delete the file
|
||||
os.unlink(file_path)
|
||||
|
||||
# Simulate watchdog event
|
||||
event = FileDeletedEvent(file_path)
|
||||
watcher.on_deleted(event)
|
||||
|
||||
# Verify the event was emitted correctly
|
||||
watcher.event_stream.add_event.assert_called_once()
|
||||
observation, source = watcher.event_stream.add_event.call_args[0]
|
||||
|
||||
assert isinstance(observation, FileEditObservation)
|
||||
assert observation.path == "test.txt"
|
||||
assert observation.prev_exist is True
|
||||
assert observation.old_content == content
|
||||
assert observation.new_content == ""
|
||||
assert "-Content to delete" in observation.content
|
||||
assert source == EventSource.USER
|
||||
|
||||
|
||||
def test_file_move(watcher, temp_dir):
|
||||
"""Test that file move/rename events are handled correctly."""
|
||||
src_path = os.path.join(temp_dir, "old.txt")
|
||||
dst_path = os.path.join(temp_dir, "new.txt")
|
||||
content = "Content to move"
|
||||
|
||||
# Create source file
|
||||
create_test_file(src_path, content)
|
||||
watcher.file_contents[src_path] = content
|
||||
|
||||
# Move the file
|
||||
os.rename(src_path, dst_path)
|
||||
|
||||
# Simulate watchdog event
|
||||
event = FileMovedEvent(src_path, dst_path)
|
||||
watcher.on_moved(event)
|
||||
|
||||
# Should have two events: deletion and creation
|
||||
assert watcher.event_stream.add_event.call_count == 2
|
||||
|
||||
# Check deletion event
|
||||
del_observation, del_source = watcher.event_stream.add_event.call_args_list[0][0]
|
||||
assert isinstance(del_observation, FileEditObservation)
|
||||
assert del_observation.path == "old.txt"
|
||||
assert del_observation.prev_exist is True
|
||||
assert del_observation.old_content == content
|
||||
assert del_observation.new_content == ""
|
||||
assert "-Content to move" in del_observation.content
|
||||
assert del_source == EventSource.USER
|
||||
|
||||
# Check creation event
|
||||
create_observation, create_source = watcher.event_stream.add_event.call_args_list[1][0]
|
||||
assert isinstance(create_observation, FileEditObservation)
|
||||
assert create_observation.path == "new.txt"
|
||||
assert create_observation.prev_exist is False
|
||||
assert create_observation.old_content == ""
|
||||
assert create_observation.new_content == content
|
||||
assert "+Content to move" in create_observation.content
|
||||
assert create_source == EventSource.USER
|
||||
|
||||
|
||||
def test_gitignore_handling(watcher, temp_dir):
|
||||
"""Test that .gitignore patterns are respected."""
|
||||
# Create a .gitignore file
|
||||
gitignore_content = """
|
||||
# Node modules
|
||||
**/node_modules/
|
||||
# Python
|
||||
*.pyc
|
||||
__pycache__/
|
||||
# Custom
|
||||
/ignored/
|
||||
*.log
|
||||
"""
|
||||
create_test_file(os.path.join(temp_dir, ".gitignore"), gitignore_content)
|
||||
|
||||
# Reload gitignore patterns
|
||||
watcher.gitignore_spec = watcher._load_gitignore()
|
||||
|
||||
# Test various paths
|
||||
test_cases = [
|
||||
("node_modules/file.txt", True),
|
||||
("frontend/node_modules/package.json", True),
|
||||
("deep/path/node_modules/file.js", True),
|
||||
("file.pyc", True),
|
||||
("dir/__pycache__/module.pyc", True),
|
||||
("ignored/file.txt", True),
|
||||
("debug.log", True),
|
||||
("src/app.js", False),
|
||||
("frontend/src/components/Button.tsx", False),
|
||||
("README.md", False),
|
||||
]
|
||||
|
||||
for rel_path, should_ignore in test_cases:
|
||||
abs_path = os.path.join(temp_dir, rel_path)
|
||||
assert watcher._should_ignore(abs_path) == should_ignore, f"Failed for {rel_path}"
|
||||
|
||||
|
||||
def test_git_directory_ignored(watcher, temp_dir):
|
||||
"""Test that .git directory is always ignored regardless of gitignore."""
|
||||
# Create some files in a .git directory
|
||||
git_files = [
|
||||
".git/HEAD",
|
||||
".git/config",
|
||||
".git/refs/heads/main",
|
||||
".git/objects/ab/cdef1234567890",
|
||||
"subdir/.git/HEAD", # Test nested .git directories
|
||||
"subdir/.git/config",
|
||||
]
|
||||
|
||||
# Create the files
|
||||
for rel_path in git_files:
|
||||
abs_path = os.path.join(temp_dir, rel_path)
|
||||
create_test_file(abs_path, "test content")
|
||||
|
||||
# Create some non-.git files for comparison
|
||||
normal_files = [
|
||||
"src/file.txt",
|
||||
"subdir/file.txt",
|
||||
]
|
||||
for rel_path in normal_files:
|
||||
abs_path = os.path.join(temp_dir, rel_path)
|
||||
create_test_file(abs_path, "test content")
|
||||
|
||||
# Test that all .git paths are ignored
|
||||
for rel_path in git_files:
|
||||
abs_path = os.path.join(temp_dir, rel_path)
|
||||
assert watcher._should_ignore(abs_path), f".git file not ignored: {rel_path}"
|
||||
|
||||
# Also test the directory itself
|
||||
dir_path = os.path.dirname(abs_path)
|
||||
if '.git' in os.path.basename(dir_path):
|
||||
assert watcher._should_ignore(dir_path), f".git directory not ignored: {os.path.dirname(rel_path)}"
|
||||
|
||||
# Test that normal files are not ignored
|
||||
for rel_path in normal_files:
|
||||
abs_path = os.path.join(temp_dir, rel_path)
|
||||
assert not watcher._should_ignore(abs_path), f"Non-.git file incorrectly ignored: {rel_path}"
|
||||
|
||||
|
||||
def test_explicit_ignore_patterns(watcher, temp_dir):
|
||||
"""Test that explicitly provided ignore patterns work."""
|
||||
# Create watcher with custom ignore patterns
|
||||
custom_patterns = ["*.txt", "temp/*"]
|
||||
with patch('watchdog.observers.Observer'):
|
||||
watcher = FileWatcher(
|
||||
temp_dir,
|
||||
watcher.event_stream,
|
||||
ignore_patterns=custom_patterns
|
||||
)
|
||||
|
||||
test_cases = [
|
||||
("file.txt", True),
|
||||
("path/to/doc.txt", True),
|
||||
("temp/any.js", True),
|
||||
("temp/file.py", True),
|
||||
("file.js", False),
|
||||
("docs/file.md", False),
|
||||
]
|
||||
|
||||
for rel_path, should_ignore in test_cases:
|
||||
abs_path = os.path.join(temp_dir, rel_path)
|
||||
assert watcher._should_ignore(abs_path) == should_ignore, f"Failed for {rel_path}"
|
||||
|
||||
|
||||
def test_watch_patterns(watcher, temp_dir):
|
||||
"""Test that watch patterns work correctly."""
|
||||
# Create watcher with watch patterns
|
||||
watch_patterns = ["*.py", "src/*.ts"]
|
||||
with patch('watchdog.observers.Observer'):
|
||||
watcher = FileWatcher(
|
||||
temp_dir,
|
||||
watcher.event_stream,
|
||||
patterns=watch_patterns
|
||||
)
|
||||
|
||||
test_cases = [
|
||||
("file.py", True),
|
||||
("src/app.ts", True),
|
||||
("src/deep/file.ts", False), # Not directly in src/
|
||||
("file.js", False),
|
||||
("src/file.js", False),
|
||||
]
|
||||
|
||||
for rel_path, should_watch in test_cases:
|
||||
abs_path = os.path.join(temp_dir, rel_path)
|
||||
assert watcher._should_watch(abs_path) == should_watch, f"Failed for {rel_path}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def watcher_with_short_delay(mock_event_stream, temp_dir):
|
||||
"""Create a FileWatcher instance with a very short debounce delay for testing."""
|
||||
with patch('watchdog.observers.Observer'):
|
||||
watcher = FileWatcher(temp_dir, mock_event_stream)
|
||||
# Set a very short delay for testing
|
||||
watcher.debounce_delay = 0.01
|
||||
yield watcher
|
||||
|
||||
|
||||
def test_debounce_rapid_changes(watcher_with_short_delay, temp_dir):
|
||||
"""Test that rapid changes to a file result in a single event."""
|
||||
import time
|
||||
|
||||
file_path = os.path.join(temp_dir, "test.txt")
|
||||
initial_content = "Initial content"
|
||||
final_content = "Final content"
|
||||
|
||||
# Create initial file
|
||||
create_test_file(file_path, initial_content)
|
||||
watcher_with_short_delay.file_contents[file_path] = initial_content
|
||||
|
||||
# Simulate rapid changes
|
||||
for i in range(5):
|
||||
create_test_file(file_path, f"Content version {i}")
|
||||
event = FileModifiedEvent(file_path)
|
||||
watcher_with_short_delay.on_modified(event)
|
||||
|
||||
# Final change
|
||||
create_test_file(file_path, final_content)
|
||||
event = FileModifiedEvent(file_path)
|
||||
watcher_with_short_delay.on_modified(event)
|
||||
|
||||
# Wait for debounce timer
|
||||
time.sleep(0.02) # Slightly longer than debounce_delay
|
||||
|
||||
# Should only have one event with the final content
|
||||
watcher_with_short_delay.event_stream.add_event.assert_called_once()
|
||||
observation, source = watcher_with_short_delay.event_stream.add_event.call_args[0]
|
||||
|
||||
assert isinstance(observation, FileEditObservation)
|
||||
assert observation.path == "test.txt"
|
||||
assert observation.old_content == initial_content
|
||||
assert observation.new_content == final_content
|
||||
|
||||
|
||||
def test_neovim_sequence(watcher_with_short_delay, temp_dir):
|
||||
"""Test handling of neovim's sequence of file operations."""
|
||||
import time
|
||||
|
||||
file_path = os.path.join(temp_dir, "test.txt")
|
||||
initial_content = "Initial content"
|
||||
final_content = "Final content"
|
||||
|
||||
# Create initial file
|
||||
create_test_file(file_path, initial_content)
|
||||
watcher_with_short_delay.file_contents[file_path] = initial_content
|
||||
|
||||
# Simulate neovim's sequence of operations
|
||||
# 1. Create swap file
|
||||
swap_path = os.path.join(temp_dir, "4913")
|
||||
event = FileCreatedEvent(swap_path)
|
||||
watcher_with_short_delay.on_created(event)
|
||||
|
||||
# 2. Delete swap file
|
||||
event = FileDeletedEvent(swap_path)
|
||||
watcher_with_short_delay.on_deleted(event)
|
||||
|
||||
# 3. Create backup
|
||||
backup_path = file_path + "~"
|
||||
event = FileCreatedEvent(backup_path)
|
||||
watcher_with_short_delay.on_created(event)
|
||||
|
||||
# 4. Modify original file
|
||||
create_test_file(file_path, final_content)
|
||||
event = FileModifiedEvent(file_path)
|
||||
watcher_with_short_delay.on_modified(event)
|
||||
|
||||
# 5. Delete backup
|
||||
event = FileDeletedEvent(backup_path)
|
||||
watcher_with_short_delay.on_deleted(event)
|
||||
|
||||
# Wait for debounce timer
|
||||
time.sleep(0.02) # Slightly longer than debounce_delay
|
||||
|
||||
# Should only have one event with the final content
|
||||
assert watcher_with_short_delay.event_stream.add_event.call_count == 1
|
||||
observation, source = watcher_with_short_delay.event_stream.add_event.call_args[0]
|
||||
|
||||
assert isinstance(observation, FileEditObservation)
|
||||
assert observation.path == "test.txt"
|
||||
assert observation.old_content == initial_content
|
||||
assert observation.new_content == final_content
|
||||
|
||||
|
||||
def test_debounce_timer_cancellation(watcher_with_short_delay, temp_dir):
|
||||
"""Test that pending debounce timers are properly cancelled."""
|
||||
import time
|
||||
|
||||
file_path = os.path.join(temp_dir, "test.txt")
|
||||
initial_content = "Initial content"
|
||||
|
||||
# Create initial file
|
||||
create_test_file(file_path, initial_content)
|
||||
watcher_with_short_delay.file_contents[file_path] = initial_content
|
||||
|
||||
# Start a change
|
||||
event = FileModifiedEvent(file_path)
|
||||
watcher_with_short_delay.on_modified(event)
|
||||
|
||||
# Verify timer is created
|
||||
assert file_path in watcher_with_short_delay.debounce_timers
|
||||
assert file_path in watcher_with_short_delay.pending_changes
|
||||
|
||||
# Delete the file before timer expires
|
||||
event = FileDeletedEvent(file_path)
|
||||
watcher_with_short_delay.on_deleted(event)
|
||||
|
||||
# Timer should be cancelled and removed
|
||||
assert file_path not in watcher_with_short_delay.debounce_timers
|
||||
assert file_path not in watcher_with_short_delay.pending_changes
|
||||
|
||||
# Wait to ensure no extra events
|
||||
time.sleep(0.2) # Wait longer than rename_window
|
||||
|
||||
# Should only have the deletion event
|
||||
assert watcher_with_short_delay.event_stream.add_event.call_count == 1
|
||||
observation, source = watcher_with_short_delay.event_stream.add_event.call_args[0]
|
||||
assert observation.new_content == "" # Deletion event
|
||||
|
||||
|
||||
def test_concurrent_delete_handling(watcher_with_short_delay, temp_dir):
|
||||
"""Test that concurrent delete operations are handled safely."""
|
||||
import time
|
||||
|
||||
file_path = os.path.join(temp_dir, "test.txt")
|
||||
content = "File content"
|
||||
|
||||
# Create initial file
|
||||
create_test_file(file_path, content)
|
||||
watcher_with_short_delay.file_contents[file_path] = content
|
||||
|
||||
# Simulate a delete
|
||||
event = FileDeletedEvent(file_path)
|
||||
watcher_with_short_delay.on_deleted(event)
|
||||
|
||||
# Simulate another delete before the first one is processed
|
||||
watcher_with_short_delay.on_deleted(event)
|
||||
|
||||
# Wait for both timers
|
||||
time.sleep(0.2) # Longer than rename_window
|
||||
|
||||
# Should only have one deletion event
|
||||
assert watcher_with_short_delay.event_stream.add_event.call_count == 1
|
||||
observation, source = watcher_with_short_delay.event_stream.add_event.call_args[0]
|
||||
assert observation.path == "test.txt"
|
||||
assert observation.old_content == content
|
||||
assert observation.new_content == ""
|
||||
|
||||
|
||||
def test_atomic_rename_handling(watcher_with_short_delay, temp_dir):
|
||||
"""Test that atomic renames (delete+create with same content) are handled correctly."""
|
||||
import time
|
||||
|
||||
old_path = os.path.join(temp_dir, "old.txt")
|
||||
new_path = os.path.join(temp_dir, "new.txt")
|
||||
content = "File content"
|
||||
|
||||
# Create initial file
|
||||
create_test_file(old_path, content)
|
||||
watcher_with_short_delay.file_contents[old_path] = content
|
||||
|
||||
# Simulate atomic rename (delete + create with same content)
|
||||
event = FileDeletedEvent(old_path)
|
||||
watcher_with_short_delay.on_deleted(event)
|
||||
|
||||
# Create the new file with the same content
|
||||
create_test_file(new_path, content)
|
||||
event = FileCreatedEvent(new_path)
|
||||
watcher_with_short_delay.on_created(event)
|
||||
|
||||
# Wait a bit to ensure any delayed events are processed
|
||||
time.sleep(0.02)
|
||||
|
||||
# Should have no events since it was just a rename
|
||||
assert watcher_with_short_delay.event_stream.add_event.call_count == 0
|
||||
assert new_path in watcher_with_short_delay.file_contents
|
||||
assert watcher_with_short_delay.file_contents[new_path] == content
|
||||
|
||||
# Now modify the file
|
||||
new_content = "Modified content"
|
||||
create_test_file(new_path, new_content)
|
||||
event = FileModifiedEvent(new_path)
|
||||
watcher_with_short_delay.on_modified(event)
|
||||
|
||||
# Wait for debounce timer
|
||||
time.sleep(0.02)
|
||||
|
||||
# Should now have one event for the modification
|
||||
assert watcher_with_short_delay.event_stream.add_event.call_count == 1
|
||||
observation, source = watcher_with_short_delay.event_stream.add_event.call_args[0]
|
||||
assert observation.path == "new.txt"
|
||||
assert observation.old_content == content
|
||||
assert observation.new_content == new_content
|
||||
75
tests/unit/linters/conftest.py
Normal file
75
tests/unit/linters/conftest.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def syntax_error_py_file(tmp_path):
|
||||
file_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
print("Wrong indent")
|
||||
foo(
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.py'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wrongly_indented_py_file(tmp_path):
|
||||
file_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.py'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_py_file(tmp_path):
|
||||
file_content = 'print("Hello, World!")\n'
|
||||
file_path = tmp_path / 'test_file.py'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_py_func_def(tmp_path):
|
||||
file_content = """def foo():
|
||||
print("Hello, World!")
|
||||
foo()
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.py'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_correct_ruby_file(tmp_path):
|
||||
file_content = """def foo
|
||||
puts "Hello, World!"
|
||||
end
|
||||
foo
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.rb'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_incorrect_ruby_file(tmp_path):
|
||||
file_content = """def foo():
|
||||
print("Hello, World!")
|
||||
foo()
|
||||
"""
|
||||
file_path = tmp_path / 'test_file.rb'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parenthesis_incorrect_ruby_file(tmp_path):
|
||||
file_content = """def print_hello_world()\n puts 'Hello World'\n"""
|
||||
file_path = tmp_path / 'test_file.rb'
|
||||
file_path.write_text(file_content)
|
||||
return str(file_path)
|
||||
417
tests/unit/linters/test_lint_diff.py
Normal file
417
tests/unit/linters/test_lint_diff.py
Normal file
@@ -0,0 +1,417 @@
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
from openhands.utils.diff import get_diff, parse_diff
|
||||
|
||||
OLD_CONTENT = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = UNDEFINED_VARIABLE
|
||||
foo()
|
||||
"""
|
||||
|
||||
NEW_CONTENT_V1 = (
|
||||
OLD_CONTENT
|
||||
+ """
|
||||
def new_function_that_causes_error():
|
||||
y = ANOTHER_UNDEFINED_VARIABLE
|
||||
"""
|
||||
)
|
||||
|
||||
NEW_CONTENT_V2 = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = UNDEFINED_VARIABLE
|
||||
y = ANOTHER_UNDEFINED_VARIABLE
|
||||
foo()
|
||||
"""
|
||||
|
||||
|
||||
def test_get_and_parse_diff(tmp_path):
|
||||
diff = get_diff(OLD_CONTENT, NEW_CONTENT_V1, 'test.py')
|
||||
print(diff)
|
||||
assert (
|
||||
diff
|
||||
== """
|
||||
--- test.py
|
||||
+++ test.py
|
||||
@@ -6,0 +7,3 @@
|
||||
+def new_function_that_causes_error():
|
||||
+ y = ANOTHER_UNDEFINED_VARIABLE
|
||||
+
|
||||
""".strip()
|
||||
)
|
||||
|
||||
print(
|
||||
'\n'.join(
|
||||
[f'{i+1}|{line}' for i, line in enumerate(NEW_CONTENT_V1.splitlines())]
|
||||
)
|
||||
)
|
||||
changes = parse_diff(diff)
|
||||
assert len(changes) == 3
|
||||
assert (
|
||||
changes[0].old is None
|
||||
and changes[0].new == 7
|
||||
and changes[0].line == 'def new_function_that_causes_error():'
|
||||
)
|
||||
assert (
|
||||
changes[1].old is None
|
||||
and changes[1].new == 8
|
||||
and changes[1].line == ' y = ANOTHER_UNDEFINED_VARIABLE'
|
||||
)
|
||||
assert changes[2].old is None and changes[2].new == 9 and changes[2].line == ''
|
||||
|
||||
|
||||
def test_lint_with_diff_append(tmp_path):
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(OLD_CONTENT)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(NEW_CONTENT_V1)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
print(result)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 8
|
||||
and result[0].column == 9
|
||||
and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_diff_insert(tmp_path):
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(OLD_CONTENT)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(NEW_CONTENT_V2)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 5
|
||||
and result[0].column == 9
|
||||
and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_multiple_changes_and_errors(tmp_path):
|
||||
old_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = 10
|
||||
foo()
|
||||
"""
|
||||
new_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = UNDEFINED_VARIABLE
|
||||
y = 20
|
||||
|
||||
def bar():
|
||||
z = ANOTHER_UNDEFINED_VARIABLE
|
||||
return z + 1
|
||||
|
||||
foo()
|
||||
bar()
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert (
|
||||
result[0].line == 4
|
||||
and result[0].column == 9
|
||||
and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
|
||||
)
|
||||
assert (
|
||||
result[1].line == 8
|
||||
and result[1].column == 9
|
||||
and result[1].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_introduced_and_fixed_errors(tmp_path):
|
||||
old_content = """
|
||||
x = UNDEFINED_VARIABLE
|
||||
y = 10
|
||||
"""
|
||||
new_content = """
|
||||
x = 5
|
||||
y = ANOTHER_UNDEFINED_VARIABLE
|
||||
z = UNDEFINED_VARIABLE
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert (
|
||||
result[0].line == 3
|
||||
and result[0].column == 5
|
||||
and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
assert (
|
||||
result[1].line == 4
|
||||
and result[1].column == 5
|
||||
and result[1].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_multiline_changes(tmp_path):
|
||||
old_content = """
|
||||
def complex_function(a, b, c):
|
||||
return (a +
|
||||
b +
|
||||
c)
|
||||
"""
|
||||
new_content = """
|
||||
def complex_function(a, b, c):
|
||||
return (a +
|
||||
UNDEFINED_VARIABLE +
|
||||
b +
|
||||
c)
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 4
|
||||
and result[0].column == 13
|
||||
and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_syntax_error(tmp_path):
|
||||
old_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
"""
|
||||
new_content = """
|
||||
def foo():
|
||||
print("Hello, World!"
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 3
|
||||
and result[0].column == 11
|
||||
and result[0].message == "E999 SyntaxError: '(' was never closed"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_with_docstring_changes(tmp_path):
|
||||
old_content = '''
|
||||
def foo():
|
||||
"""This is a function."""
|
||||
print("Hello, World!")
|
||||
'''
|
||||
new_content = '''
|
||||
def foo():
|
||||
"""
|
||||
This is a function.
|
||||
It now has a multi-line docstring with an UNDEFINED_VARIABLE.
|
||||
"""
|
||||
print("Hello, World!")
|
||||
'''
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
assert len(result) == 0 # Linter should ignore changes in docstrings
|
||||
|
||||
|
||||
def test_lint_with_multiple_errors_on_same_line(tmp_path):
|
||||
old_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = 10
|
||||
foo()
|
||||
"""
|
||||
new_content = """
|
||||
def foo():
|
||||
print("Hello, World!")
|
||||
x = UNDEFINED_VARIABLE + ANOTHER_UNDEFINED_VARIABLE
|
||||
foo()
|
||||
"""
|
||||
with open(tmp_path / 'old.py', 'w') as f:
|
||||
f.write(old_content)
|
||||
with open(tmp_path / 'new.py', 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(tmp_path / 'old.py'),
|
||||
str(tmp_path / 'new.py'),
|
||||
)
|
||||
print(result)
|
||||
assert len(result) == 2
|
||||
assert (
|
||||
result[0].line == 4
|
||||
and result[0].column == 9
|
||||
and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
|
||||
)
|
||||
assert (
|
||||
result[1].line == 4
|
||||
and result[1].column == 30
|
||||
and result[1].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_diff_with_empty_patch():
|
||||
diff_patch = ''
|
||||
changes = parse_diff(diff_patch)
|
||||
assert len(changes) == 0
|
||||
|
||||
|
||||
def test_lint_file_diff_ignore_existing_errors(tmp_path):
|
||||
"""
|
||||
Make sure we allow edits as long as it does not introduce new errors. In other
|
||||
words, we don't care about existing linting errors. Although they might be
|
||||
real syntax issues, sometimes they are just false positives, or errors that
|
||||
we don't care about.
|
||||
"""
|
||||
content = """def some_valid_but_weird_function():
|
||||
# this function is legitimate, yet static analysis tools like flake8
|
||||
# reports 'F821 undefined name'
|
||||
if 'variable' in locals():
|
||||
print(variable)
|
||||
def some_wrong_but_unused_function():
|
||||
# this function has a linting error, but it is not modified by us, and
|
||||
# who knows, this function might be completely dead code
|
||||
x = 1
|
||||
def sum(a, b):
|
||||
return a - b
|
||||
"""
|
||||
new_content = content.replace(' return a - b', ' return a + b')
|
||||
temp_file_old_path = tmp_path / 'problematic-file-test.py'
|
||||
temp_file_old_path.write_text(content)
|
||||
temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
|
||||
temp_file_new_path.write_text(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(temp_file_old_path),
|
||||
str(temp_file_new_path),
|
||||
)
|
||||
assert len(result) == 0 # no new errors introduced
|
||||
|
||||
|
||||
def test_lint_file_diff_catch_new_errors_in_edits(tmp_path):
|
||||
"""
|
||||
Make sure we catch new linting errors in our edit chunk, and at the same
|
||||
time, ignore old linting errors (in this case, the old linting error is
|
||||
a false positive)
|
||||
"""
|
||||
content = """def some_valid_but_weird_function():
|
||||
# this function is legitimate, yet static analysis tools like flake8
|
||||
# reports 'F821 undefined name'
|
||||
if 'variable' in locals():
|
||||
print(variable)
|
||||
def sum(a, b):
|
||||
return a - b
|
||||
"""
|
||||
|
||||
temp_file_old_path = tmp_path / 'problematic-file-test.py'
|
||||
temp_file_old_path.write_text(content)
|
||||
new_content = content.replace(' return a - b', ' return a + variable')
|
||||
temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
|
||||
temp_file_new_path.write_text(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(temp_file_old_path),
|
||||
str(temp_file_new_path),
|
||||
)
|
||||
print(result)
|
||||
assert len(result) == 1
|
||||
assert (
|
||||
result[0].line == 7
|
||||
and result[0].column == 16
|
||||
and result[0].message == "F821 undefined name 'variable'"
|
||||
)
|
||||
|
||||
|
||||
def test_lint_file_diff_catch_new_errors_outside_edits(tmp_path):
|
||||
"""
|
||||
Make sure we catch new linting errors induced by our edits, even
|
||||
though the error itself is not in the edit chunk
|
||||
"""
|
||||
content = """def valid_func1():
|
||||
print(my_sum(1, 2))
|
||||
def my_sum(a, b):
|
||||
return a - b
|
||||
def valid_func2():
|
||||
print(my_sum(0, 0))
|
||||
"""
|
||||
# Add 100 lines of invalid code, which linter shall ignore
|
||||
# because they are not being edited. For testing purpose, we
|
||||
# must add these existing linting errors, otherwise the pre-edit
|
||||
# linting would pass, and thus there won't be any comparison
|
||||
# between pre-edit and post-edit linting.
|
||||
for _ in range(100):
|
||||
content += '\ninvalid_func()'
|
||||
|
||||
temp_file_old_path = tmp_path / 'problematic-file-test.py'
|
||||
temp_file_old_path.write_text(content)
|
||||
|
||||
new_content = content.replace('def my_sum(a, b):', 'def my_sum2(a, b):')
|
||||
temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
|
||||
temp_file_new_path.write_text(new_content)
|
||||
|
||||
linter = DefaultLinter()
|
||||
result: list[LintResult] = linter.lint_file_diff(
|
||||
str(temp_file_old_path),
|
||||
str(temp_file_new_path),
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert (
|
||||
result[0].line == 2
|
||||
and result[0].column == 11
|
||||
and result[0].message == "F821 undefined name 'my_sum'"
|
||||
)
|
||||
assert (
|
||||
result[1].line == 6
|
||||
and result[1].column == 11
|
||||
and result[1].message == "F821 undefined name 'my_sum'"
|
||||
)
|
||||
84
tests/unit/linters/test_python_linter.py
Normal file
84
tests/unit/linters/test_python_linter.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
from openhands.linter.languages.python import (
|
||||
PythonLinter,
|
||||
flake_lint,
|
||||
python_compile_lint,
|
||||
)
|
||||
|
||||
|
||||
def test_wrongly_indented_py_file(wrongly_indented_py_file):
|
||||
# Test Python linter
|
||||
linter = PythonLinter()
|
||||
assert '.py' in linter.supported_extensions
|
||||
result = linter.lint(wrongly_indented_py_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=wrongly_indented_py_file,
|
||||
line=2,
|
||||
column=5,
|
||||
message='E999 IndentationError: unexpected indent',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert result[0].visualize() == (
|
||||
'1|\n'
|
||||
'\033[91m2| def foo():\033[0m\n'
|
||||
' ^ ERROR HERE: E999 IndentationError: unexpected indent\n'
|
||||
'3| print("Hello, World!")\n'
|
||||
'4|'
|
||||
)
|
||||
|
||||
# General linter should have same result as Python linter
|
||||
# bc it uses PythonLinter under the hood
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(wrongly_indented_py_file)
|
||||
assert result == linter.lint(wrongly_indented_py_file)
|
||||
|
||||
# Test flake8_lint
|
||||
assert result == flake_lint(wrongly_indented_py_file)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(wrongly_indented_py_file)
|
||||
assert isinstance(compile_result, list) and len(compile_result) == 1
|
||||
assert compile_result[0] == LintResult(
|
||||
file=wrongly_indented_py_file, line=2, column=4, message='unexpected indent'
|
||||
)
|
||||
|
||||
|
||||
def test_simple_correct_py_file(simple_correct_py_file):
|
||||
linter = PythonLinter()
|
||||
assert '.py' in linter.supported_extensions
|
||||
result = linter.lint(simple_correct_py_file)
|
||||
assert result == []
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(simple_correct_py_file)
|
||||
assert result == linter.lint(simple_correct_py_file)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(simple_correct_py_file)
|
||||
assert compile_result == []
|
||||
|
||||
# Test flake_lint
|
||||
flake_result = flake_lint(simple_correct_py_file)
|
||||
assert flake_result == []
|
||||
|
||||
|
||||
def test_simple_correct_py_func_def(simple_correct_py_func_def):
|
||||
linter = PythonLinter()
|
||||
result = linter.lint(simple_correct_py_func_def)
|
||||
assert result == []
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
assert '.py' in general_linter.supported_extensions
|
||||
result = general_linter.lint(simple_correct_py_func_def)
|
||||
assert result == linter.lint(simple_correct_py_func_def)
|
||||
|
||||
# Test flake_lint
|
||||
assert result == flake_lint(simple_correct_py_func_def)
|
||||
|
||||
# Test python_compile_lint
|
||||
compile_result = python_compile_lint(simple_correct_py_func_def)
|
||||
assert compile_result == []
|
||||
113
tests/unit/linters/test_treesitter_linter.py
Normal file
113
tests/unit/linters/test_treesitter_linter.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from openhands.linter import DefaultLinter, LintResult
|
||||
from openhands.linter.languages.treesitter import TreesitterBasicLinter
|
||||
|
||||
|
||||
def test_syntax_error_py_file(syntax_error_py_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(syntax_error_py_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=syntax_error_py_file,
|
||||
line=5,
|
||||
column=5,
|
||||
message='Syntax error',
|
||||
)
|
||||
|
||||
assert (
|
||||
result[0].visualize()
|
||||
== (
|
||||
'2| def foo():\n'
|
||||
'3| print("Hello, World!")\n'
|
||||
'4| print("Wrong indent")\n'
|
||||
'\033[91m5| foo(\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'6|'
|
||||
)
|
||||
)
|
||||
print(result[0].visualize())
|
||||
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(syntax_error_py_file)
|
||||
# NOTE: general linter returns different result
|
||||
# because it uses flake8 first, which is different from treesitter
|
||||
assert general_result != result
|
||||
|
||||
|
||||
def test_simple_correct_ruby_file(simple_correct_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(simple_correct_ruby_file)
|
||||
assert isinstance(result, list) and len(result) == 0
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(simple_correct_ruby_file)
|
||||
assert general_result == result
|
||||
|
||||
|
||||
def test_simple_incorrect_ruby_file(simple_incorrect_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(simple_incorrect_ruby_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 2
|
||||
assert result[0] == LintResult(
|
||||
file=simple_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=1,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert (
|
||||
result[0].visualize()
|
||||
== (
|
||||
'\033[91m1|def foo():\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'2| print("Hello, World!")\n'
|
||||
'3|foo()'
|
||||
)
|
||||
)
|
||||
assert result[1] == LintResult(
|
||||
file=simple_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=10,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[1].visualize())
|
||||
assert (
|
||||
result[1].visualize()
|
||||
== (
|
||||
'\033[91m1|def foo():\033[0m\n' # color red
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
'2| print("Hello, World!")\n'
|
||||
'3|foo()'
|
||||
)
|
||||
)
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(simple_incorrect_ruby_file)
|
||||
assert general_result == result
|
||||
|
||||
|
||||
def test_parenthesis_incorrect_ruby_file(parenthesis_incorrect_ruby_file):
|
||||
linter = TreesitterBasicLinter()
|
||||
result = linter.lint(parenthesis_incorrect_ruby_file)
|
||||
print(result)
|
||||
assert isinstance(result, list) and len(result) == 1
|
||||
assert result[0] == LintResult(
|
||||
file=parenthesis_incorrect_ruby_file,
|
||||
line=1,
|
||||
column=1,
|
||||
message='Syntax error',
|
||||
)
|
||||
print(result[0].visualize())
|
||||
assert result[0].visualize() == (
|
||||
'\033[91m1|def print_hello_world()\033[0m\n'
|
||||
' ^ ERROR HERE: Syntax error\n'
|
||||
"2| puts 'Hello World'"
|
||||
)
|
||||
|
||||
# Test that the general linter also returns the same result
|
||||
general_linter = DefaultLinter()
|
||||
general_result = general_linter.lint(parenthesis_incorrect_ruby_file)
|
||||
assert general_result == result
|
||||
86
tests/unit/linters/test_visualize.py
Normal file
86
tests/unit/linters/test_visualize.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.linter.base import LintResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_file_content():
|
||||
return '\n'.join([f'Line {i}' for i in range(1, 21)])
|
||||
|
||||
|
||||
def test_visualize_standard_case(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=10, column=5, message='Test error message'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=3)
|
||||
|
||||
expected_output = (
|
||||
" 7|Line 7\n"
|
||||
" 8|Line 8\n"
|
||||
" 9|Line 9\n"
|
||||
"\033[91m10|Line 10\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Test error message\n"
|
||||
"11|Line 11\n"
|
||||
"12|Line 12\n"
|
||||
"13|Line 13"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_small_window(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=10, column=5, message='Test error message'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=1)
|
||||
|
||||
expected_output = (
|
||||
" 9|Line 9\n"
|
||||
"\033[91m10|Line 10\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Test error message\n"
|
||||
"11|Line 11"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_error_at_start(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=1, column=3, message='Start error'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=2)
|
||||
|
||||
expected_output = (
|
||||
"\033[91m 1|Line 1\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: Start error\n"
|
||||
" 2|Line 2\n"
|
||||
" 3|Line 3"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_visualize_error_at_end(mock_file_content):
|
||||
lint_result = LintResult(
|
||||
file='test_file.py', line=20, column=1, message='End error'
|
||||
)
|
||||
|
||||
with patch('builtins.open', mock_open(read_data=mock_file_content)):
|
||||
result = lint_result.visualize(half_window=2)
|
||||
|
||||
expected_output = (
|
||||
"18|Line 18\n"
|
||||
"19|Line 19\n"
|
||||
"\033[91m20|Line 20\033[0m\n"
|
||||
f" {' ' * lint_result.column}^ ERROR HERE: End error"
|
||||
)
|
||||
|
||||
assert result == expected_output
|
||||
@@ -1,22 +1,22 @@
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.resolver.github_issue import GithubIssue
|
||||
from openhands.resolver.issue_definitions import IssueHandler
|
||||
from openhands.resolver.github_issue import GithubIssue
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
|
||||
def test_guess_success_multiline_explanation():
|
||||
# Mock data
|
||||
issue = GithubIssue(
|
||||
owner='test',
|
||||
repo='test',
|
||||
owner="test",
|
||||
repo="test",
|
||||
number=1,
|
||||
title='Test Issue',
|
||||
body='Test body',
|
||||
title="Test Issue",
|
||||
body="Test body",
|
||||
thread_comments=None,
|
||||
review_comments=None,
|
||||
)
|
||||
history = [MessageAction(content='Test message')]
|
||||
llm_config = LLMConfig(model='test', api_key='test')
|
||||
history = [MessageAction(content="Test message")]
|
||||
llm_config = LLMConfig(model="test", api_key="test")
|
||||
|
||||
# Create a mock response with multi-line explanation
|
||||
mock_response = """--- success
|
||||
@@ -31,7 +31,7 @@ The PR successfully addressed the issue by:
|
||||
Automatic fix generated by OpenHands 🙌"""
|
||||
|
||||
# Create a handler instance
|
||||
handler = IssueHandler('test', 'test', 'test')
|
||||
handler = IssueHandler("test", "test", "test")
|
||||
|
||||
# Mock the litellm.completion call
|
||||
def mock_completion(*args, **kwargs):
|
||||
@@ -61,11 +61,11 @@ Automatic fix generated by OpenHands 🙌"""
|
||||
|
||||
# Verify the results
|
||||
assert success is True
|
||||
assert 'The PR successfully addressed the issue by:' in explanation
|
||||
assert 'Fixed bug A' in explanation
|
||||
assert 'Added test B' in explanation
|
||||
assert 'Updated documentation C' in explanation
|
||||
assert 'Automatic fix generated by OpenHands' in explanation
|
||||
assert "The PR successfully addressed the issue by:" in explanation
|
||||
assert "Fixed bug A" in explanation
|
||||
assert "Added test B" in explanation
|
||||
assert "Updated documentation C" in explanation
|
||||
assert "Automatic fix generated by OpenHands" in explanation
|
||||
finally:
|
||||
# Restore the original function
|
||||
litellm.completion = original_completion
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.resolver.github_issue import GithubIssue, ReviewThread
|
||||
from unittest.mock import patch, MagicMock
|
||||
from openhands.resolver.issue_definitions import IssueHandler, PRHandler
|
||||
from openhands.resolver.github_issue import GithubIssue, ReviewThread
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
|
||||
def test_get_converted_issues_initializes_review_comments():
|
||||
# Mock the necessary dependencies
|
||||
with patch('requests.get') as mock_get:
|
||||
with patch("requests.get") as mock_get:
|
||||
# Mock the response for issues
|
||||
mock_issues_response = MagicMock()
|
||||
mock_issues_response.json.return_value = [
|
||||
{'number': 1, 'title': 'Test Issue', 'body': 'Test Body'}
|
||||
{"number": 1, "title": "Test Issue", "body": "Test Body"}
|
||||
]
|
||||
# Mock the response for comments
|
||||
mock_comments_response = MagicMock()
|
||||
@@ -27,7 +26,7 @@ def test_get_converted_issues_initializes_review_comments():
|
||||
] # Need two comment responses because we make two API calls
|
||||
|
||||
# Create an instance of IssueHandler
|
||||
handler = IssueHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = IssueHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Get converted issues
|
||||
issues = handler.get_converted_issues()
|
||||
@@ -40,35 +39,35 @@ def test_get_converted_issues_initializes_review_comments():
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert issues[0].number == 1
|
||||
assert issues[0].title == 'Test Issue'
|
||||
assert issues[0].body == 'Test Body'
|
||||
assert issues[0].owner == 'test-owner'
|
||||
assert issues[0].repo == 'test-repo'
|
||||
assert issues[0].title == "Test Issue"
|
||||
assert issues[0].body == "Test Body"
|
||||
assert issues[0].owner == "test-owner"
|
||||
assert issues[0].repo == "test-repo"
|
||||
|
||||
|
||||
def test_pr_handler_guess_success_with_thread_comments():
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create a mock issue with thread comments but no review comments
|
||||
issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=1,
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
thread_comments=['First comment', 'Second comment'],
|
||||
closing_issues=['Issue description'],
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
thread_comments=["First comment", "Second comment"],
|
||||
closing_issues=["Issue description"],
|
||||
review_comments=None,
|
||||
thread_ids=None,
|
||||
head_branch='test-branch',
|
||||
head_branch="test-branch",
|
||||
)
|
||||
|
||||
# Create mock history
|
||||
history = [MessageAction(content='Fixed the issue by implementing X and Y')]
|
||||
history = [MessageAction(content="Fixed the issue by implementing X and Y")]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -85,7 +84,7 @@ The changes successfully address the feedback."""
|
||||
]
|
||||
|
||||
# Test the guess_success method
|
||||
with patch('litellm.completion', return_value=mock_response):
|
||||
with patch("litellm.completion", return_value=mock_response):
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
issue, history, llm_config
|
||||
)
|
||||
@@ -93,39 +92,39 @@ The changes successfully address the feedback."""
|
||||
# Verify the results
|
||||
assert success is True
|
||||
assert success_list == [True]
|
||||
assert 'successfully address' in explanation
|
||||
assert "successfully address" in explanation
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_comments():
|
||||
# Mock the necessary dependencies
|
||||
with patch('requests.get') as mock_get:
|
||||
with patch("requests.get") as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
'number': 1,
|
||||
'title': 'Test PR',
|
||||
'body': 'Test Body fixes #1',
|
||||
'head': {'ref': 'test-branch'},
|
||||
"number": 1,
|
||||
"title": "Test PR",
|
||||
"body": "Test Body fixes #1",
|
||||
"head": {"ref": "test-branch"},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{'body': 'First comment'},
|
||||
{'body': 'Second comment'},
|
||||
{"body": "First comment"},
|
||||
{"body": "Second comment"},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {'edges': []},
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {"edges": []},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +138,7 @@ def test_pr_handler_get_converted_issues_with_comments():
|
||||
# Mock the response for fetching the external issue referenced in PR body
|
||||
mock_external_issue_response = MagicMock()
|
||||
mock_external_issue_response.json.return_value = {
|
||||
'body': 'This is additional context from an externally referenced issue.'
|
||||
"body": "This is additional context from an externally referenced issue."
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
@@ -151,11 +150,11 @@ def test_pr_handler_get_converted_issues_with_comments():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch('requests.post') as mock_post:
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues()
|
||||
@@ -164,43 +163,43 @@ def test_pr_handler_get_converted_issues_with_comments():
|
||||
assert len(prs) == 1
|
||||
|
||||
# Verify that thread_comments are set correctly
|
||||
assert prs[0].thread_comments == ['First comment', 'Second comment']
|
||||
assert prs[0].thread_comments == ["First comment", "Second comment"]
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == 'Test PR'
|
||||
assert prs[0].body == 'Test Body fixes #1'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
assert prs[0].title == "Test PR"
|
||||
assert prs[0].body == "Test Body fixes #1"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
assert prs[0].closing_issues == [
|
||||
'This is additional context from an externally referenced issue.'
|
||||
"This is additional context from an externally referenced issue."
|
||||
]
|
||||
|
||||
|
||||
def test_pr_handler_guess_success_only_review_comments():
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create a mock issue with only review comments
|
||||
issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=1,
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
thread_comments=None,
|
||||
closing_issues=['Issue description'],
|
||||
review_comments=['Please fix the formatting', 'Add more tests'],
|
||||
closing_issues=["Issue description"],
|
||||
review_comments=["Please fix the formatting", "Add more tests"],
|
||||
thread_ids=None,
|
||||
head_branch='test-branch',
|
||||
head_branch="test-branch",
|
||||
)
|
||||
|
||||
# Create mock history
|
||||
history = [MessageAction(content='Fixed the formatting and added more tests')]
|
||||
history = [MessageAction(content="Fixed the formatting and added more tests")]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -217,7 +216,7 @@ The changes successfully address the review comments."""
|
||||
]
|
||||
|
||||
# Test the guess_success method
|
||||
with patch('litellm.completion', return_value=mock_response):
|
||||
with patch("litellm.completion", return_value=mock_response):
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
issue, history, llm_config
|
||||
)
|
||||
@@ -225,32 +224,32 @@ The changes successfully address the review comments."""
|
||||
# Verify the results
|
||||
assert success is True
|
||||
assert success_list == [True]
|
||||
assert 'successfully address' in explanation
|
||||
assert "successfully address" in explanation
|
||||
|
||||
|
||||
def test_pr_handler_guess_success_no_comments():
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create a mock issue with no comments
|
||||
issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=1,
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
thread_comments=None,
|
||||
closing_issues=['Issue description'],
|
||||
closing_issues=["Issue description"],
|
||||
review_comments=None,
|
||||
thread_ids=None,
|
||||
head_branch='test-branch',
|
||||
head_branch="test-branch",
|
||||
)
|
||||
|
||||
# Create mock history
|
||||
history = [MessageAction(content='Fixed the issue')]
|
||||
history = [MessageAction(content="Fixed the issue")]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Test that it returns appropriate message when no comments are present
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
@@ -258,29 +257,29 @@ def test_pr_handler_guess_success_no_comments():
|
||||
)
|
||||
assert success is False
|
||||
assert success_list is None
|
||||
assert explanation == 'No feedback was found to process'
|
||||
assert explanation == "No feedback was found to process"
|
||||
|
||||
|
||||
def test_get_issue_comments_with_specific_comment_id():
|
||||
# Mock the necessary dependencies
|
||||
with patch('requests.get') as mock_get:
|
||||
with patch("requests.get") as mock_get:
|
||||
# Mock the response for comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{'id': 123, 'body': 'First comment'},
|
||||
{'id': 456, 'body': 'Second comment'},
|
||||
{"id": 123, "body": "First comment"},
|
||||
{"id": 456, "body": "Second comment"},
|
||||
]
|
||||
|
||||
mock_get.return_value = mock_comments_response
|
||||
|
||||
# Create an instance of IssueHandler
|
||||
handler = IssueHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = IssueHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Get comments with a specific comment_id
|
||||
specific_comment = handler._get_issue_comments(issue_number=1, comment_id=123)
|
||||
|
||||
# Verify only the specific comment is returned
|
||||
assert specific_comment == ['First comment']
|
||||
assert specific_comment == ["First comment"]
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
@@ -288,50 +287,50 @@ def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
specific_comment_id = 123
|
||||
|
||||
# Mock GraphQL response for review threads
|
||||
with patch('requests.get') as mock_get:
|
||||
with patch("requests.get") as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
'number': 1,
|
||||
'title': 'Test PR',
|
||||
'body': 'Test Body',
|
||||
'head': {'ref': 'test-branch'},
|
||||
"number": 1,
|
||||
"title": "Test PR",
|
||||
"body": "Test Body",
|
||||
"head": {"ref": "test-branch"},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{'body': 'First comment', 'id': 123},
|
||||
{'body': 'Second comment', 'id': 124},
|
||||
{"body": "First comment", "id": 123},
|
||||
{"body": "Second comment", "id": 124},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {
|
||||
'edges': [
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {
|
||||
"edges": [
|
||||
{
|
||||
'node': {
|
||||
'id': 'review-thread-1',
|
||||
'isResolved': False,
|
||||
'comments': {
|
||||
'nodes': [
|
||||
"node": {
|
||||
"id": "review-thread-1",
|
||||
"isResolved": False,
|
||||
"comments": {
|
||||
"nodes": [
|
||||
{
|
||||
'fullDatabaseId': 121,
|
||||
'body': 'Specific review comment',
|
||||
'path': 'file1.txt',
|
||||
"fullDatabaseId": 121,
|
||||
"body": "Specific review comment",
|
||||
"path": "file1.txt",
|
||||
},
|
||||
{
|
||||
'fullDatabaseId': 456,
|
||||
'body': 'Another review comment',
|
||||
'path': 'file2.txt',
|
||||
"fullDatabaseId": 456,
|
||||
"body": "Another review comment",
|
||||
"path": "file2.txt",
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -357,11 +356,11 @@ def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch('requests.post') as mock_post:
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues(comment_id=specific_comment_id)
|
||||
@@ -370,17 +369,17 @@ def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
assert len(prs) == 1
|
||||
|
||||
# Verify that thread_comments are set correctly
|
||||
assert prs[0].thread_comments == ['First comment']
|
||||
assert prs[0].thread_comments == ["First comment"]
|
||||
assert prs[0].review_comments == []
|
||||
assert prs[0].review_threads == []
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == 'Test PR'
|
||||
assert prs[0].body == 'Test Body'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
assert prs[0].title == "Test PR"
|
||||
assert prs[0].body == "Test Body"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
@@ -388,50 +387,50 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
specific_comment_id = 123
|
||||
|
||||
# Mock GraphQL response for review threads
|
||||
with patch('requests.get') as mock_get:
|
||||
with patch("requests.get") as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
'number': 1,
|
||||
'title': 'Test PR',
|
||||
'body': 'Test Body',
|
||||
'head': {'ref': 'test-branch'},
|
||||
"number": 1,
|
||||
"title": "Test PR",
|
||||
"body": "Test Body",
|
||||
"head": {"ref": "test-branch"},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{'body': 'First comment', 'id': 120},
|
||||
{'body': 'Second comment', 'id': 124},
|
||||
{"body": "First comment", "id": 120},
|
||||
{"body": "Second comment", "id": 124},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {
|
||||
'edges': [
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {
|
||||
"edges": [
|
||||
{
|
||||
'node': {
|
||||
'id': 'review-thread-1',
|
||||
'isResolved': False,
|
||||
'comments': {
|
||||
'nodes': [
|
||||
"node": {
|
||||
"id": "review-thread-1",
|
||||
"isResolved": False,
|
||||
"comments": {
|
||||
"nodes": [
|
||||
{
|
||||
'fullDatabaseId': specific_comment_id,
|
||||
'body': 'Specific review comment',
|
||||
'path': 'file1.txt',
|
||||
"fullDatabaseId": specific_comment_id,
|
||||
"body": "Specific review comment",
|
||||
"path": "file1.txt",
|
||||
},
|
||||
{
|
||||
'fullDatabaseId': 456,
|
||||
'body': 'Another review comment',
|
||||
'path': 'file1.txt',
|
||||
"fullDatabaseId": 456,
|
||||
"body": "Another review comment",
|
||||
"path": "file1.txt",
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -457,11 +456,11 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch('requests.post') as mock_post:
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues(comment_id=specific_comment_id)
|
||||
@@ -476,17 +475,17 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
assert isinstance(prs[0].review_threads[0], ReviewThread)
|
||||
assert (
|
||||
prs[0].review_threads[0].comment
|
||||
== 'Specific review comment\n---\nlatest feedback:\nAnother review comment\n'
|
||||
== "Specific review comment\n---\nlatest feedback:\nAnother review comment\n"
|
||||
)
|
||||
assert prs[0].review_threads[0].files == ['file1.txt']
|
||||
assert prs[0].review_threads[0].files == ["file1.txt"]
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == 'Test PR'
|
||||
assert prs[0].body == 'Test Body'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
assert prs[0].title == "Test PR"
|
||||
assert prs[0].body == "Test Body"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
@@ -494,50 +493,50 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
specific_comment_id = 123
|
||||
|
||||
# Mock GraphQL response for review threads
|
||||
with patch('requests.get') as mock_get:
|
||||
with patch("requests.get") as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
'number': 1,
|
||||
'title': 'Test PR fixes #3',
|
||||
'body': 'Test Body',
|
||||
'head': {'ref': 'test-branch'},
|
||||
"number": 1,
|
||||
"title": "Test PR fixes #3",
|
||||
"body": "Test Body",
|
||||
"head": {"ref": "test-branch"},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{'body': 'First comment', 'id': 120},
|
||||
{'body': 'Second comment', 'id': 124},
|
||||
{"body": "First comment", "id": 120},
|
||||
{"body": "Second comment", "id": 124},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {
|
||||
'edges': [
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {
|
||||
"edges": [
|
||||
{
|
||||
'node': {
|
||||
'id': 'review-thread-1',
|
||||
'isResolved': False,
|
||||
'comments': {
|
||||
'nodes': [
|
||||
"node": {
|
||||
"id": "review-thread-1",
|
||||
"isResolved": False,
|
||||
"comments": {
|
||||
"nodes": [
|
||||
{
|
||||
'fullDatabaseId': specific_comment_id,
|
||||
'body': 'Specific review comment that references #6',
|
||||
'path': 'file1.txt',
|
||||
"fullDatabaseId": specific_comment_id,
|
||||
"body": "Specific review comment that references #6",
|
||||
"path": "file1.txt",
|
||||
},
|
||||
{
|
||||
'fullDatabaseId': 456,
|
||||
'body': 'Another review comment referencing #7',
|
||||
'path': 'file2.txt',
|
||||
"fullDatabaseId": 456,
|
||||
"body": "Another review comment referencing #7",
|
||||
"path": "file2.txt",
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -558,13 +557,13 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
# Mock the response for fetching the external issue referenced in PR body
|
||||
mock_external_issue_response_in_body = MagicMock()
|
||||
mock_external_issue_response_in_body.json.return_value = {
|
||||
'body': 'External context #1.'
|
||||
"body": "External context #1."
|
||||
}
|
||||
|
||||
# Mock the response for fetching the external issue referenced in review thread
|
||||
mock_external_issue_response_review_thread = MagicMock()
|
||||
mock_external_issue_response_review_thread.json.return_value = {
|
||||
'body': 'External context #2.'
|
||||
"body": "External context #2."
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
@@ -577,11 +576,11 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch('requests.post') as mock_post:
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues(comment_id=specific_comment_id)
|
||||
@@ -596,52 +595,52 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
assert isinstance(prs[0].review_threads[0], ReviewThread)
|
||||
assert (
|
||||
prs[0].review_threads[0].comment
|
||||
== 'Specific review comment that references #6\n---\nlatest feedback:\nAnother review comment referencing #7\n'
|
||||
== "Specific review comment that references #6\n---\nlatest feedback:\nAnother review comment referencing #7\n"
|
||||
)
|
||||
assert prs[0].closing_issues == [
|
||||
'External context #1.',
|
||||
'External context #2.',
|
||||
"External context #1.",
|
||||
"External context #2.",
|
||||
] # Only includes references inside comment ID and body PR
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == 'Test PR fixes #3'
|
||||
assert prs[0].body == 'Test Body'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
assert prs[0].title == "Test PR fixes #3"
|
||||
assert prs[0].body == "Test Body"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
|
||||
|
||||
def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
# Mock the necessary dependencies
|
||||
with patch('requests.get') as mock_get:
|
||||
with patch("requests.get") as mock_get:
|
||||
# Mock the response for PRs
|
||||
mock_prs_response = MagicMock()
|
||||
mock_prs_response.json.return_value = [
|
||||
{
|
||||
'number': 1,
|
||||
'title': 'Test PR',
|
||||
'body': 'Test Body fixes #1',
|
||||
'head': {'ref': 'test-branch'},
|
||||
"number": 1,
|
||||
"title": "Test PR",
|
||||
"body": "Test Body fixes #1",
|
||||
"head": {"ref": "test-branch"},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock the response for PR comments
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = [
|
||||
{'body': 'First comment addressing #1'},
|
||||
{'body': 'Second comment addressing #2'},
|
||||
{"body": "First comment addressing #1"},
|
||||
{"body": "Second comment addressing #2"},
|
||||
]
|
||||
|
||||
# Mock the response for PR metadata (GraphQL)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.return_value = {
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
'closingIssuesReferences': {'edges': []},
|
||||
'reviews': {'nodes': []},
|
||||
'reviewThreads': {'edges': []},
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"closingIssuesReferences": {"edges": []},
|
||||
"reviews": {"nodes": []},
|
||||
"reviewThreads": {"edges": []},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,13 +654,13 @@ def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
# Mock the response for fetching the external issue referenced in PR body
|
||||
mock_external_issue_response_in_body = MagicMock()
|
||||
mock_external_issue_response_in_body.json.return_value = {
|
||||
'body': 'External context #1.'
|
||||
"body": "External context #1."
|
||||
}
|
||||
|
||||
# Mock the response for fetching the external issue referenced in review thread
|
||||
mock_external_issue_response_in_comment = MagicMock()
|
||||
mock_external_issue_response_in_comment.json.return_value = {
|
||||
'body': 'External context #2.'
|
||||
"body": "External context #2."
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
@@ -674,11 +673,11 @@ def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
]
|
||||
|
||||
# Mock the post request for GraphQL
|
||||
with patch('requests.post') as mock_post:
|
||||
with patch("requests.post") as mock_post:
|
||||
mock_post.return_value = mock_graphql_response
|
||||
|
||||
# Create an instance of PRHandler
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Get converted issues
|
||||
prs = handler.get_converted_issues()
|
||||
@@ -688,18 +687,18 @@ def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
|
||||
# Verify that thread_comments are set correctly
|
||||
assert prs[0].thread_comments == [
|
||||
'First comment addressing #1',
|
||||
'Second comment addressing #2',
|
||||
"First comment addressing #1",
|
||||
"Second comment addressing #2",
|
||||
]
|
||||
|
||||
# Verify other fields are set correctly
|
||||
assert prs[0].number == 1
|
||||
assert prs[0].title == 'Test PR'
|
||||
assert prs[0].body == 'Test Body fixes #1'
|
||||
assert prs[0].owner == 'test-owner'
|
||||
assert prs[0].repo == 'test-repo'
|
||||
assert prs[0].head_branch == 'test-branch'
|
||||
assert prs[0].title == "Test PR"
|
||||
assert prs[0].body == "Test Body fixes #1"
|
||||
assert prs[0].owner == "test-owner"
|
||||
assert prs[0].repo == "test-repo"
|
||||
assert prs[0].head_branch == "test-branch"
|
||||
assert prs[0].closing_issues == [
|
||||
'External context #1.',
|
||||
'External context #2.',
|
||||
"External context #1.",
|
||||
"External context #2.",
|
||||
]
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from openhands.resolver.issue_definitions import PRHandler
|
||||
|
||||
|
||||
def test_handle_nonexistent_issue_reference():
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Mock the requests.get to simulate a 404 error
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
'404 Client Error: Not Found'
|
||||
)
|
||||
|
||||
with patch('requests.get', return_value=mock_response):
|
||||
# Call the method with a non-existent issue reference
|
||||
result = handler._PRHandler__get_context_from_external_issues_references(
|
||||
closing_issues=[],
|
||||
closing_issue_numbers=[],
|
||||
issue_body='This references #999999', # Non-existent issue
|
||||
review_comments=[],
|
||||
review_threads=[],
|
||||
thread_comments=None,
|
||||
)
|
||||
|
||||
# The method should return an empty list since the referenced issue couldn't be fetched
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_handle_rate_limit_error():
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Mock the requests.get to simulate a rate limit error
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
'403 Client Error: Rate Limit Exceeded'
|
||||
)
|
||||
|
||||
with patch('requests.get', return_value=mock_response):
|
||||
# Call the method with an issue reference
|
||||
result = handler._PRHandler__get_context_from_external_issues_references(
|
||||
closing_issues=[],
|
||||
closing_issue_numbers=[],
|
||||
issue_body='This references #123',
|
||||
review_comments=[],
|
||||
review_threads=[],
|
||||
thread_comments=None,
|
||||
)
|
||||
|
||||
# The method should return an empty list since the request was rate limited
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_handle_network_error():
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Mock the requests.get to simulate a network error
|
||||
with patch(
|
||||
'requests.get', side_effect=requests.exceptions.ConnectionError('Network Error')
|
||||
):
|
||||
# Call the method with an issue reference
|
||||
result = handler._PRHandler__get_context_from_external_issues_references(
|
||||
closing_issues=[],
|
||||
closing_issue_numbers=[],
|
||||
issue_body='This references #123',
|
||||
review_comments=[],
|
||||
review_threads=[],
|
||||
thread_comments=None,
|
||||
)
|
||||
|
||||
# The method should return an empty list since the network request failed
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_successful_issue_reference():
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Mock a successful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {'body': 'This is the referenced issue body'}
|
||||
|
||||
with patch('requests.get', return_value=mock_response):
|
||||
# Call the method with an issue reference
|
||||
result = handler._PRHandler__get_context_from_external_issues_references(
|
||||
closing_issues=[],
|
||||
closing_issue_numbers=[],
|
||||
issue_body='This references #123',
|
||||
review_comments=[],
|
||||
review_threads=[],
|
||||
thread_comments=None,
|
||||
)
|
||||
|
||||
# The method should return a list with the referenced issue body
|
||||
assert result == ['This is the referenced issue body']
|
||||
@@ -1,42 +0,0 @@
|
||||
from openhands.resolver.issue_definitions import IssueHandler
|
||||
|
||||
|
||||
def test_extract_issue_references():
|
||||
handler = IssueHandler('test-owner', 'test-repo', 'test-token')
|
||||
|
||||
# Test basic issue reference
|
||||
assert handler._extract_issue_references('Fixes #123') == [123]
|
||||
|
||||
# Test multiple issue references
|
||||
assert handler._extract_issue_references('Fixes #123, #456') == [123, 456]
|
||||
|
||||
# Test issue references in code blocks should be ignored
|
||||
assert handler._extract_issue_references("""
|
||||
Here's a code block:
|
||||
```python
|
||||
# This is a comment with #123
|
||||
def func():
|
||||
pass # Another #456
|
||||
```
|
||||
But this #789 should be extracted
|
||||
""") == [789]
|
||||
|
||||
# Test issue references in inline code should be ignored
|
||||
assert handler._extract_issue_references(
|
||||
'This `#123` should be ignored but #456 should be extracted'
|
||||
) == [456]
|
||||
|
||||
# Test issue references in URLs should be ignored
|
||||
assert handler._extract_issue_references(
|
||||
'Check http://example.com/#123 but #456 should be extracted'
|
||||
) == [456]
|
||||
|
||||
# Test issue references in markdown links should be extracted
|
||||
assert handler._extract_issue_references(
|
||||
'[Link to #123](http://example.com) and #456'
|
||||
) == [123, 456]
|
||||
|
||||
# Test issue references with text around them
|
||||
assert handler._extract_issue_references(
|
||||
'Issue #123 is fixed and #456 is pending'
|
||||
) == [123, 456]
|
||||
@@ -1,39 +1,39 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.resolver.github_issue import GithubIssue, ReviewThread
|
||||
from openhands.resolver.issue_definitions import PRHandler
|
||||
from openhands.resolver.github_issue import GithubIssue, ReviewThread
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
|
||||
def test_guess_success_review_threads_litellm_call():
|
||||
"""Test that the litellm.completion() call for review threads contains the expected content."""
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create a mock issue with review threads
|
||||
issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=1,
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
thread_comments=None,
|
||||
closing_issues=['Issue 1 description', 'Issue 2 description'],
|
||||
closing_issues=["Issue 1 description", "Issue 2 description"],
|
||||
review_comments=None,
|
||||
review_threads=[
|
||||
ReviewThread(
|
||||
comment='Please fix the formatting\n---\nlatest feedback:\nAdd docstrings',
|
||||
files=['/src/file1.py', '/src/file2.py'],
|
||||
comment="Please fix the formatting\n---\nlatest feedback:\nAdd docstrings",
|
||||
files=["/src/file1.py", "/src/file2.py"],
|
||||
),
|
||||
ReviewThread(
|
||||
comment='Add more tests\n---\nlatest feedback:\nAdd test cases',
|
||||
files=['/tests/test_file.py'],
|
||||
comment="Add more tests\n---\nlatest feedback:\nAdd test cases",
|
||||
files=["/tests/test_file.py"],
|
||||
),
|
||||
],
|
||||
thread_ids=['1', '2'],
|
||||
head_branch='test-branch',
|
||||
thread_ids=["1", "2"],
|
||||
head_branch="test-branch",
|
||||
)
|
||||
|
||||
# Create mock history with a detailed response
|
||||
@@ -47,7 +47,7 @@ def test_guess_success_review_threads_litellm_call():
|
||||
]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -64,7 +64,7 @@ The changes successfully address the feedback."""
|
||||
]
|
||||
|
||||
# Test the guess_success method
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
issue, history, llm_config
|
||||
@@ -75,63 +75,63 @@ The changes successfully address the feedback."""
|
||||
|
||||
# Check first call
|
||||
first_call = mock_completion.call_args_list[0]
|
||||
first_prompt = first_call[1]['messages'][0]['content']
|
||||
first_prompt = first_call[1]["messages"][0]["content"]
|
||||
assert (
|
||||
'Issue descriptions:\n'
|
||||
+ json.dumps(['Issue 1 description', 'Issue 2 description'], indent=4)
|
||||
"Issue descriptions:\n"
|
||||
+ json.dumps(["Issue 1 description", "Issue 2 description"], indent=4)
|
||||
in first_prompt
|
||||
)
|
||||
assert (
|
||||
'Feedback:\nPlease fix the formatting\n---\nlatest feedback:\nAdd docstrings'
|
||||
"Feedback:\nPlease fix the formatting\n---\nlatest feedback:\nAdd docstrings"
|
||||
in first_prompt
|
||||
)
|
||||
assert (
|
||||
'Files locations:\n'
|
||||
+ json.dumps(['/src/file1.py', '/src/file2.py'], indent=4)
|
||||
"Files locations:\n"
|
||||
+ json.dumps(["/src/file1.py", "/src/file2.py"], indent=4)
|
||||
in first_prompt
|
||||
)
|
||||
assert 'Last message from AI agent:\n' + history[0].content in first_prompt
|
||||
assert "Last message from AI agent:\n" + history[0].content in first_prompt
|
||||
|
||||
# Check second call
|
||||
second_call = mock_completion.call_args_list[1]
|
||||
second_prompt = second_call[1]['messages'][0]['content']
|
||||
second_prompt = second_call[1]["messages"][0]["content"]
|
||||
assert (
|
||||
'Issue descriptions:\n'
|
||||
+ json.dumps(['Issue 1 description', 'Issue 2 description'], indent=4)
|
||||
"Issue descriptions:\n"
|
||||
+ json.dumps(["Issue 1 description", "Issue 2 description"], indent=4)
|
||||
in second_prompt
|
||||
)
|
||||
assert (
|
||||
'Feedback:\nAdd more tests\n---\nlatest feedback:\nAdd test cases'
|
||||
"Feedback:\nAdd more tests\n---\nlatest feedback:\nAdd test cases"
|
||||
in second_prompt
|
||||
)
|
||||
assert (
|
||||
'Files locations:\n' + json.dumps(['/tests/test_file.py'], indent=4)
|
||||
"Files locations:\n" + json.dumps(["/tests/test_file.py"], indent=4)
|
||||
in second_prompt
|
||||
)
|
||||
assert 'Last message from AI agent:\n' + history[0].content in second_prompt
|
||||
assert "Last message from AI agent:\n" + history[0].content in second_prompt
|
||||
|
||||
|
||||
def test_guess_success_thread_comments_litellm_call():
|
||||
"""Test that the litellm.completion() call for thread comments contains the expected content."""
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create a mock issue with thread comments
|
||||
issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=1,
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
thread_comments=[
|
||||
'Please improve error handling',
|
||||
'Add input validation',
|
||||
'latest feedback:\nHandle edge cases',
|
||||
"Please improve error handling",
|
||||
"Add input validation",
|
||||
"latest feedback:\nHandle edge cases",
|
||||
],
|
||||
closing_issues=['Issue 1 description', 'Issue 2 description'],
|
||||
closing_issues=["Issue 1 description", "Issue 2 description"],
|
||||
review_comments=None,
|
||||
thread_ids=None,
|
||||
head_branch='test-branch',
|
||||
head_branch="test-branch",
|
||||
)
|
||||
|
||||
# Create mock history with a detailed response
|
||||
@@ -145,7 +145,7 @@ def test_guess_success_thread_comments_litellm_call():
|
||||
]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -162,7 +162,7 @@ The changes successfully address the feedback."""
|
||||
]
|
||||
|
||||
# Test the guess_success method
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
issue, history, llm_config
|
||||
@@ -171,77 +171,77 @@ The changes successfully address the feedback."""
|
||||
# Verify the litellm.completion() call
|
||||
mock_completion.assert_called_once()
|
||||
call_args = mock_completion.call_args
|
||||
prompt = call_args[1]['messages'][0]['content']
|
||||
prompt = call_args[1]["messages"][0]["content"]
|
||||
|
||||
# Check prompt content
|
||||
assert (
|
||||
'Issue descriptions:\n'
|
||||
+ json.dumps(['Issue 1 description', 'Issue 2 description'], indent=4)
|
||||
"Issue descriptions:\n"
|
||||
+ json.dumps(["Issue 1 description", "Issue 2 description"], indent=4)
|
||||
in prompt
|
||||
)
|
||||
assert 'PR Thread Comments:\n' + '\n---\n'.join(issue.thread_comments) in prompt
|
||||
assert 'Last message from AI agent:\n' + history[0].content in prompt
|
||||
assert "PR Thread Comments:\n" + "\n---\n".join(issue.thread_comments) in prompt
|
||||
assert "Last message from AI agent:\n" + history[0].content in prompt
|
||||
|
||||
|
||||
def test_check_feedback_with_llm():
|
||||
"""Test the _check_feedback_with_llm helper function."""
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Test cases for different LLM responses
|
||||
test_cases = [
|
||||
{
|
||||
'response': '--- success\ntrue\n--- explanation\nChanges look good',
|
||||
'expected': (True, 'Changes look good'),
|
||||
"response": "--- success\ntrue\n--- explanation\nChanges look good",
|
||||
"expected": (True, "Changes look good"),
|
||||
},
|
||||
{
|
||||
'response': '--- success\nfalse\n--- explanation\nNot all issues fixed',
|
||||
'expected': (False, 'Not all issues fixed'),
|
||||
"response": "--- success\nfalse\n--- explanation\nNot all issues fixed",
|
||||
"expected": (False, "Not all issues fixed"),
|
||||
},
|
||||
{
|
||||
'response': 'Invalid response format',
|
||||
'expected': (
|
||||
"response": "Invalid response format",
|
||||
"expected": (
|
||||
False,
|
||||
'Failed to decode answer from LLM response: Invalid response format',
|
||||
"Failed to decode answer from LLM response: Invalid response format",
|
||||
),
|
||||
},
|
||||
{
|
||||
'response': '--- success\ntrue\n--- explanation\nMultiline\nexplanation\nhere',
|
||||
'expected': (True, 'Multiline\nexplanation\nhere'),
|
||||
"response": "--- success\ntrue\n--- explanation\nMultiline\nexplanation\nhere",
|
||||
"expected": (True, "Multiline\nexplanation\nhere"),
|
||||
},
|
||||
]
|
||||
|
||||
for case in test_cases:
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock(message=MagicMock(content=case['response']))]
|
||||
mock_response.choices = [MagicMock(message=MagicMock(content=case["response"]))]
|
||||
|
||||
# Test the function
|
||||
with patch('litellm.completion', return_value=mock_response):
|
||||
with patch("litellm.completion", return_value=mock_response):
|
||||
success, explanation = handler._check_feedback_with_llm(
|
||||
'test prompt', llm_config
|
||||
"test prompt", llm_config
|
||||
)
|
||||
assert (success, explanation) == case['expected']
|
||||
assert (success, explanation) == case["expected"]
|
||||
|
||||
|
||||
def test_check_review_thread():
|
||||
"""Test the _check_review_thread helper function."""
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create test data
|
||||
review_thread = ReviewThread(
|
||||
comment='Please fix the formatting\n---\nlatest feedback:\nAdd docstrings',
|
||||
files=['/src/file1.py', '/src/file2.py'],
|
||||
comment="Please fix the formatting\n---\nlatest feedback:\nAdd docstrings",
|
||||
files=["/src/file1.py", "/src/file2.py"],
|
||||
)
|
||||
issues_context = json.dumps(
|
||||
['Issue 1 description', 'Issue 2 description'], indent=4
|
||||
["Issue 1 description", "Issue 2 description"], indent=4
|
||||
)
|
||||
last_message = 'I have fixed the formatting and added docstrings'
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
last_message = "I have fixed the formatting and added docstrings"
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -258,7 +258,7 @@ Changes look good"""
|
||||
]
|
||||
|
||||
# Test the function
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
success, explanation = handler._check_review_thread(
|
||||
review_thread, issues_context, last_message, llm_config
|
||||
@@ -267,37 +267,37 @@ Changes look good"""
|
||||
# Verify the litellm.completion() call
|
||||
mock_completion.assert_called_once()
|
||||
call_args = mock_completion.call_args
|
||||
prompt = call_args[1]['messages'][0]['content']
|
||||
prompt = call_args[1]["messages"][0]["content"]
|
||||
|
||||
# Check prompt content
|
||||
assert 'Issue descriptions:\n' + issues_context in prompt
|
||||
assert 'Feedback:\n' + review_thread.comment in prompt
|
||||
assert "Issue descriptions:\n" + issues_context in prompt
|
||||
assert "Feedback:\n" + review_thread.comment in prompt
|
||||
assert (
|
||||
'Files locations:\n' + json.dumps(review_thread.files, indent=4) in prompt
|
||||
"Files locations:\n" + json.dumps(review_thread.files, indent=4) in prompt
|
||||
)
|
||||
assert 'Last message from AI agent:\n' + last_message in prompt
|
||||
assert "Last message from AI agent:\n" + last_message in prompt
|
||||
|
||||
# Check result
|
||||
assert success is True
|
||||
assert explanation == 'Changes look good'
|
||||
assert explanation == "Changes look good"
|
||||
|
||||
|
||||
def test_check_thread_comments():
|
||||
"""Test the _check_thread_comments helper function."""
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create test data
|
||||
thread_comments = [
|
||||
'Please improve error handling',
|
||||
'Add input validation',
|
||||
'latest feedback:\nHandle edge cases',
|
||||
"Please improve error handling",
|
||||
"Add input validation",
|
||||
"latest feedback:\nHandle edge cases",
|
||||
]
|
||||
issues_context = json.dumps(
|
||||
['Issue 1 description', 'Issue 2 description'], indent=4
|
||||
["Issue 1 description", "Issue 2 description"], indent=4
|
||||
)
|
||||
last_message = 'I have added error handling and input validation'
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
last_message = "I have added error handling and input validation"
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -314,7 +314,7 @@ Changes look good"""
|
||||
]
|
||||
|
||||
# Test the function
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
success, explanation = handler._check_thread_comments(
|
||||
thread_comments, issues_context, last_message, llm_config
|
||||
@@ -323,34 +323,34 @@ Changes look good"""
|
||||
# Verify the litellm.completion() call
|
||||
mock_completion.assert_called_once()
|
||||
call_args = mock_completion.call_args
|
||||
prompt = call_args[1]['messages'][0]['content']
|
||||
prompt = call_args[1]["messages"][0]["content"]
|
||||
|
||||
# Check prompt content
|
||||
assert 'Issue descriptions:\n' + issues_context in prompt
|
||||
assert 'PR Thread Comments:\n' + '\n---\n'.join(thread_comments) in prompt
|
||||
assert 'Last message from AI agent:\n' + last_message in prompt
|
||||
assert "Issue descriptions:\n" + issues_context in prompt
|
||||
assert "PR Thread Comments:\n" + "\n---\n".join(thread_comments) in prompt
|
||||
assert "Last message from AI agent:\n" + last_message in prompt
|
||||
|
||||
# Check result
|
||||
assert success is True
|
||||
assert explanation == 'Changes look good'
|
||||
assert explanation == "Changes look good"
|
||||
|
||||
|
||||
def test_check_review_comments():
|
||||
"""Test the _check_review_comments helper function."""
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create test data
|
||||
review_comments = [
|
||||
'Please improve code readability',
|
||||
'Add comments to complex functions',
|
||||
'Follow PEP 8 style guide',
|
||||
"Please improve code readability",
|
||||
"Add comments to complex functions",
|
||||
"Follow PEP 8 style guide",
|
||||
]
|
||||
issues_context = json.dumps(
|
||||
['Issue 1 description', 'Issue 2 description'], indent=4
|
||||
["Issue 1 description", "Issue 2 description"], indent=4
|
||||
)
|
||||
last_message = 'I have improved code readability and added comments'
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
last_message = "I have improved code readability and added comments"
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -367,7 +367,7 @@ Changes look good"""
|
||||
]
|
||||
|
||||
# Test the function
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
success, explanation = handler._check_review_comments(
|
||||
review_comments, issues_context, last_message, llm_config
|
||||
@@ -376,39 +376,39 @@ Changes look good"""
|
||||
# Verify the litellm.completion() call
|
||||
mock_completion.assert_called_once()
|
||||
call_args = mock_completion.call_args
|
||||
prompt = call_args[1]['messages'][0]['content']
|
||||
prompt = call_args[1]["messages"][0]["content"]
|
||||
|
||||
# Check prompt content
|
||||
assert 'Issue descriptions:\n' + issues_context in prompt
|
||||
assert 'PR Review Comments:\n' + '\n---\n'.join(review_comments) in prompt
|
||||
assert 'Last message from AI agent:\n' + last_message in prompt
|
||||
assert "Issue descriptions:\n" + issues_context in prompt
|
||||
assert "PR Review Comments:\n" + "\n---\n".join(review_comments) in prompt
|
||||
assert "Last message from AI agent:\n" + last_message in prompt
|
||||
|
||||
# Check result
|
||||
assert success is True
|
||||
assert explanation == 'Changes look good'
|
||||
assert explanation == "Changes look good"
|
||||
|
||||
|
||||
def test_guess_success_review_comments_litellm_call():
|
||||
"""Test that the litellm.completion() call for review comments contains the expected content."""
|
||||
# Create a PR handler instance
|
||||
handler = PRHandler('test-owner', 'test-repo', 'test-token')
|
||||
handler = PRHandler("test-owner", "test-repo", "test-token")
|
||||
|
||||
# Create a mock issue with review comments
|
||||
issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=1,
|
||||
title='Test PR',
|
||||
body='Test Body',
|
||||
title="Test PR",
|
||||
body="Test Body",
|
||||
thread_comments=None,
|
||||
closing_issues=['Issue 1 description', 'Issue 2 description'],
|
||||
closing_issues=["Issue 1 description", "Issue 2 description"],
|
||||
review_comments=[
|
||||
'Please improve code readability',
|
||||
'Add comments to complex functions',
|
||||
'Follow PEP 8 style guide',
|
||||
"Please improve code readability",
|
||||
"Add comments to complex functions",
|
||||
"Follow PEP 8 style guide",
|
||||
],
|
||||
thread_ids=None,
|
||||
head_branch='test-branch',
|
||||
head_branch="test-branch",
|
||||
)
|
||||
|
||||
# Create mock history with a detailed response
|
||||
@@ -422,7 +422,7 @@ def test_guess_success_review_comments_litellm_call():
|
||||
]
|
||||
|
||||
# Create mock LLM config
|
||||
llm_config = LLMConfig(model='test-model', api_key='test-key')
|
||||
llm_config = LLMConfig(model="test-model", api_key="test-key")
|
||||
|
||||
# Mock the LLM response
|
||||
mock_response = MagicMock()
|
||||
@@ -439,7 +439,7 @@ The changes successfully address the feedback."""
|
||||
]
|
||||
|
||||
# Test the guess_success method
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
success, success_list, explanation = handler.guess_success(
|
||||
issue, history, llm_config
|
||||
@@ -448,13 +448,13 @@ The changes successfully address the feedback."""
|
||||
# Verify the litellm.completion() call
|
||||
mock_completion.assert_called_once()
|
||||
call_args = mock_completion.call_args
|
||||
prompt = call_args[1]['messages'][0]['content']
|
||||
prompt = call_args[1]["messages"][0]["content"]
|
||||
|
||||
# Check prompt content
|
||||
assert (
|
||||
'Issue descriptions:\n'
|
||||
+ json.dumps(['Issue 1 description', 'Issue 2 description'], indent=4)
|
||||
"Issue descriptions:\n"
|
||||
+ json.dumps(["Issue 1 description", "Issue 2 description"], indent=4)
|
||||
in prompt
|
||||
)
|
||||
assert 'PR Review Comments:\n' + '\n---\n'.join(issue.review_comments) in prompt
|
||||
assert 'Last message from AI agent:\n' + history[0].content in prompt
|
||||
assert "PR Review Comments:\n" + "\n---\n".join(issue.review_comments) in prompt
|
||||
assert "Last message from AI agent:\n" + history[0].content in prompt
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from openhands.resolver.github_issue import GithubIssue
|
||||
from openhands.resolver.send_pull_request import make_commit
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_commit_message_with_quotes():
|
||||
# Create a temporary directory and initialize git repo
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
subprocess.run(['git', 'init', temp_dir], check=True)
|
||||
subprocess.run(["git", "init", temp_dir], check=True)
|
||||
|
||||
# Create a test file and add it to git
|
||||
test_file = os.path.join(temp_dir, 'test.txt')
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test content')
|
||||
test_file = os.path.join(temp_dir, "test.txt")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test content")
|
||||
|
||||
subprocess.run(['git', '-C', temp_dir, 'add', 'test.txt'], check=True)
|
||||
subprocess.run(["git", "-C", temp_dir, "add", "test.txt"], check=True)
|
||||
|
||||
# Create a test issue with problematic title
|
||||
issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=123,
|
||||
title="Issue with 'quotes' and \"double quotes\" and <class 'ValueError'>",
|
||||
body='Test body',
|
||||
body="Test body",
|
||||
labels=[],
|
||||
assignees=[],
|
||||
state='open',
|
||||
created_at='2024-01-01T00:00:00Z',
|
||||
updated_at='2024-01-01T00:00:00Z',
|
||||
state="open",
|
||||
created_at="2024-01-01T00:00:00Z",
|
||||
updated_at="2024-01-01T00:00:00Z",
|
||||
closed_at=None,
|
||||
head_branch=None,
|
||||
thread_ids=None,
|
||||
)
|
||||
|
||||
# Make the commit
|
||||
make_commit(temp_dir, issue, 'issue')
|
||||
make_commit(temp_dir, issue, "issue")
|
||||
|
||||
# Get the commit message
|
||||
result = subprocess.run(
|
||||
['git', '-C', temp_dir, 'log', '-1', '--pretty=%B'],
|
||||
["git", "-C", temp_dir, "log", "-1", "--pretty=%B"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
@@ -49,7 +48,7 @@ def test_commit_message_with_quotes():
|
||||
|
||||
# The commit message should contain the quotes without excessive escaping
|
||||
expected = "Fix issue #123: Issue with 'quotes' and \"double quotes\" and <class 'ValueError'>"
|
||||
assert commit_msg == expected, f'Expected: {expected}\nGot: {commit_msg}'
|
||||
assert commit_msg == expected, f"Expected: {expected}\nGot: {commit_msg}"
|
||||
|
||||
|
||||
def test_pr_title_with_quotes(monkeypatch):
|
||||
@@ -57,39 +56,39 @@ def test_pr_title_with_quotes(monkeypatch):
|
||||
class MockResponse:
|
||||
def __init__(self, status_code=201):
|
||||
self.status_code = status_code
|
||||
self.text = ''
|
||||
self.text = ""
|
||||
|
||||
def json(self):
|
||||
return {'html_url': 'https://github.com/test/test/pull/1'}
|
||||
return {"html_url": "https://github.com/test/test/pull/1"}
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def mock_post(*args, **kwargs):
|
||||
# Verify that the PR title is not over-escaped
|
||||
data = kwargs.get('json', {})
|
||||
title = data.get('title', '')
|
||||
data = kwargs.get("json", {})
|
||||
title = data.get("title", "")
|
||||
expected = "Fix issue #123: Issue with 'quotes' and \"double quotes\" and <class 'ValueError'>"
|
||||
assert (
|
||||
title == expected
|
||||
), f'PR title was incorrectly escaped.\nExpected: {expected}\nGot: {title}'
|
||||
), f"PR title was incorrectly escaped.\nExpected: {expected}\nGot: {title}"
|
||||
return MockResponse()
|
||||
|
||||
class MockGetResponse:
|
||||
def __init__(self, status_code=200):
|
||||
self.status_code = status_code
|
||||
self.text = ''
|
||||
self.text = ""
|
||||
|
||||
def json(self):
|
||||
return {'default_branch': 'main'}
|
||||
return {"default_branch": "main"}
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr('requests.post', mock_post)
|
||||
monkeypatch.setattr('requests.get', lambda *args, **kwargs: MockGetResponse())
|
||||
monkeypatch.setattr("requests.post", mock_post)
|
||||
monkeypatch.setattr("requests.get", lambda *args, **kwargs: MockGetResponse())
|
||||
monkeypatch.setattr(
|
||||
'openhands.resolver.send_pull_request.branch_exists',
|
||||
"openhands.resolver.send_pull_request.branch_exists",
|
||||
lambda *args, **kwargs: False,
|
||||
)
|
||||
|
||||
@@ -98,69 +97,69 @@ def test_pr_title_with_quotes(monkeypatch):
|
||||
|
||||
def mock_run(*args, **kwargs):
|
||||
print(f"Running command: {args[0] if args else kwargs.get('args', [])}")
|
||||
if isinstance(args[0], list) and args[0][0] == 'git':
|
||||
if 'push' in args[0]:
|
||||
if isinstance(args[0], list) and args[0][0] == "git":
|
||||
if "push" in args[0]:
|
||||
return subprocess.CompletedProcess(
|
||||
args[0], returncode=0, stdout='', stderr=''
|
||||
args[0], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
return original_run(*args, **kwargs)
|
||||
return original_run(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr('subprocess.run', mock_run)
|
||||
monkeypatch.setattr("subprocess.run", mock_run)
|
||||
|
||||
# Create a temporary directory and initialize git repo
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
print('Initializing git repo...')
|
||||
subprocess.run(['git', 'init', temp_dir], check=True)
|
||||
print("Initializing git repo...")
|
||||
subprocess.run(["git", "init", temp_dir], check=True)
|
||||
|
||||
# Add these lines to configure git
|
||||
subprocess.run(
|
||||
['git', '-C', temp_dir, 'config', 'user.name', 'Test User'], check=True
|
||||
["git", "-C", temp_dir, "config", "user.name", "Test User"], check=True
|
||||
)
|
||||
subprocess.run(
|
||||
['git', '-C', temp_dir, 'config', 'user.email', 'test@example.com'],
|
||||
["git", "-C", temp_dir, "config", "user.email", "test@example.com"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Create a test file and add it to git
|
||||
test_file = os.path.join(temp_dir, 'test.txt')
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test content')
|
||||
test_file = os.path.join(temp_dir, "test.txt")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test content")
|
||||
|
||||
print('Adding and committing test file...')
|
||||
subprocess.run(['git', '-C', temp_dir, 'add', 'test.txt'], check=True)
|
||||
print("Adding and committing test file...")
|
||||
subprocess.run(["git", "-C", temp_dir, "add", "test.txt"], check=True)
|
||||
subprocess.run(
|
||||
['git', '-C', temp_dir, 'commit', '-m', 'Initial commit'], check=True
|
||||
["git", "-C", temp_dir, "commit", "-m", "Initial commit"], check=True
|
||||
)
|
||||
|
||||
# Create a test issue with problematic title
|
||||
print('Creating test issue...')
|
||||
print("Creating test issue...")
|
||||
issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
owner="test-owner",
|
||||
repo="test-repo",
|
||||
number=123,
|
||||
title="Issue with 'quotes' and \"double quotes\" and <class 'ValueError'>",
|
||||
body='Test body',
|
||||
body="Test body",
|
||||
labels=[],
|
||||
assignees=[],
|
||||
state='open',
|
||||
created_at='2024-01-01T00:00:00Z',
|
||||
updated_at='2024-01-01T00:00:00Z',
|
||||
state="open",
|
||||
created_at="2024-01-01T00:00:00Z",
|
||||
updated_at="2024-01-01T00:00:00Z",
|
||||
closed_at=None,
|
||||
head_branch=None,
|
||||
thread_ids=None,
|
||||
)
|
||||
|
||||
# Try to send a PR - this will fail if the title is incorrectly escaped
|
||||
print('Sending PR...')
|
||||
from openhands.core.config import LLMConfig
|
||||
print("Sending PR...")
|
||||
from openhands.resolver.send_pull_request import send_pull_request
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
send_pull_request(
|
||||
github_issue=issue,
|
||||
github_token='dummy-token',
|
||||
github_username='test-user',
|
||||
github_token="dummy-token",
|
||||
github_username="test-user",
|
||||
patch_dir=temp_dir,
|
||||
llm_config=LLMConfig(model='test-model', api_key='test-key'),
|
||||
pr_type='ready',
|
||||
llm_config=LLMConfig(model="test-model", api_key="test-key"),
|
||||
pr_type="ready",
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ from openhands.resolver.send_pull_request import (
|
||||
load_single_resolver_output,
|
||||
make_commit,
|
||||
process_all_successful_issues,
|
||||
create_pull_request_from_resolver_output,
|
||||
process_single_issue,
|
||||
reply_to_comment,
|
||||
send_pull_request,
|
||||
update_existing_pull_request,
|
||||
@@ -607,7 +607,7 @@ def test_process_single_pr_update(
|
||||
)
|
||||
mock_initialize_repo.return_value = f'{mock_output_dir}/patches/pr_1'
|
||||
|
||||
create_pull_request_from_resolver_output(
|
||||
process_single_issue(
|
||||
mock_output_dir,
|
||||
resolver_output,
|
||||
github_token,
|
||||
@@ -639,7 +639,7 @@ def test_process_single_pr_update(
|
||||
@patch('openhands.resolver.send_pull_request.apply_patch')
|
||||
@patch('openhands.resolver.send_pull_request.send_pull_request')
|
||||
@patch('openhands.resolver.send_pull_request.make_commit')
|
||||
def test_create_pull_request_from_resolver_output(
|
||||
def test_process_single_issue(
|
||||
mock_make_commit,
|
||||
mock_send_pull_request,
|
||||
mock_apply_patch,
|
||||
@@ -679,7 +679,7 @@ def test_create_pull_request_from_resolver_output(
|
||||
mock_initialize_repo.return_value = f'{mock_output_dir}/patches/issue_1'
|
||||
|
||||
# Call the function
|
||||
create_pull_request_from_resolver_output(
|
||||
process_single_issue(
|
||||
mock_output_dir,
|
||||
resolver_output,
|
||||
github_token,
|
||||
@@ -714,7 +714,7 @@ def test_create_pull_request_from_resolver_output(
|
||||
@patch('openhands.resolver.send_pull_request.apply_patch')
|
||||
@patch('openhands.resolver.send_pull_request.send_pull_request')
|
||||
@patch('openhands.resolver.send_pull_request.make_commit')
|
||||
def test_create_pull_request_from_resolver_output_unsuccessful(
|
||||
def test_process_single_issue_unsuccessful(
|
||||
mock_make_commit,
|
||||
mock_send_pull_request,
|
||||
mock_apply_patch,
|
||||
@@ -748,7 +748,7 @@ def test_create_pull_request_from_resolver_output_unsuccessful(
|
||||
)
|
||||
|
||||
# Call the function
|
||||
create_pull_request_from_resolver_output(
|
||||
process_single_issue(
|
||||
mock_output_dir,
|
||||
resolver_output,
|
||||
github_token,
|
||||
@@ -767,9 +767,9 @@ def test_create_pull_request_from_resolver_output_unsuccessful(
|
||||
|
||||
|
||||
@patch('openhands.resolver.send_pull_request.load_all_resolver_outputs')
|
||||
@patch('openhands.resolver.send_pull_request.create_pull_request_from_resolver_output')
|
||||
@patch('openhands.resolver.send_pull_request.process_single_issue')
|
||||
def test_process_all_successful_issues(
|
||||
mock_create_pull_request, mock_load_all_resolver_outputs, mock_llm_config
|
||||
mock_process_single_issue, mock_load_all_resolver_outputs, mock_llm_config
|
||||
):
|
||||
# Create ResolverOutput objects with properly initialized GithubIssue instances
|
||||
resolver_output_1 = ResolverOutput(
|
||||
@@ -849,10 +849,10 @@ def test_process_all_successful_issues(
|
||||
)
|
||||
|
||||
# Assert that process_single_issue was called for successful issues only
|
||||
assert mock_create_pull_request.call_count == 2
|
||||
assert mock_process_single_issue.call_count == 2
|
||||
|
||||
# Check that the function was called with the correct arguments for successful issues
|
||||
mock_create_pull_request.assert_has_calls(
|
||||
mock_process_single_issue.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
'output_dir',
|
||||
@@ -945,7 +945,7 @@ def test_send_pull_request_branch_naming(
|
||||
|
||||
@patch('openhands.resolver.send_pull_request.argparse.ArgumentParser')
|
||||
@patch('openhands.resolver.send_pull_request.process_all_successful_issues')
|
||||
@patch('openhands.resolver.send_pull_request.create_pull_request_from_resolver_output')
|
||||
@patch('openhands.resolver.send_pull_request.process_single_issue')
|
||||
@patch('openhands.resolver.send_pull_request.load_single_resolver_output')
|
||||
@patch('os.path.exists')
|
||||
@patch('os.getenv')
|
||||
@@ -953,7 +953,7 @@ def test_main(
|
||||
mock_getenv,
|
||||
mock_path_exists,
|
||||
mock_load_single_resolver_output,
|
||||
mock_create_pull_request,
|
||||
mock_process_single_issue,
|
||||
mock_process_all_successful_issues,
|
||||
mock_parser,
|
||||
):
|
||||
@@ -999,7 +999,7 @@ def test_main(
|
||||
mock_getenv.assert_any_call('GITHUB_TOKEN')
|
||||
mock_path_exists.assert_called_with('/mock/output')
|
||||
mock_load_single_resolver_output.assert_called_with('/mock/output/output.jsonl', 42)
|
||||
mock_create_pull_request.assert_called_with(
|
||||
mock_process_single_issue.assert_called_with(
|
||||
'/mock/output',
|
||||
mock_resolver_output,
|
||||
'mock_token',
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from openhands.core.config import AppConfig, LLMConfig, SandboxConfig
|
||||
from openhands.resolver.github_issue import GithubIssue
|
||||
from openhands.resolver.resolver_output import ResolverOutput
|
||||
from openhands.resolver.send_pull_request import ProcessIssueResult
|
||||
from openhands.core.config import AppConfig
|
||||
|
||||
|
||||
# Mock the SessionManager to avoid asyncio issues
|
||||
@@ -15,12 +8,6 @@ class MockSessionManager:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def attach_to_conversation(self, sid):
|
||||
return {'id': sid}
|
||||
|
||||
async def detach_from_conversation(self, conversation):
|
||||
pass
|
||||
|
||||
|
||||
# Mock StaticFiles
|
||||
class MockStaticFiles:
|
||||
@@ -32,11 +19,7 @@ class MockStaticFiles:
|
||||
with patch('openhands.server.session.SessionManager', MockSessionManager), patch(
|
||||
'fastapi.staticfiles.StaticFiles', MockStaticFiles
|
||||
):
|
||||
from openhands.server.listen import (
|
||||
app,
|
||||
is_extension_allowed,
|
||||
load_file_upload_config,
|
||||
)
|
||||
from openhands.server.listen import is_extension_allowed, load_file_upload_config
|
||||
|
||||
|
||||
def test_load_file_upload_config():
|
||||
@@ -93,160 +76,3 @@ def test_is_extension_allowed_wildcard():
|
||||
assert is_extension_allowed('file.pdf')
|
||||
assert is_extension_allowed('file.doc')
|
||||
assert is_extension_allowed('file')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client():
|
||||
"""Create a test client for the FastAPI app."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock config for testing."""
|
||||
config = AppConfig(
|
||||
sandbox=SandboxConfig(runtime_container_image='test-image'),
|
||||
llms={'test': LLMConfig(model='test-model', api_key='test-key')},
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_resolve_issue():
|
||||
"""Create a mock for resolve_github_issue."""
|
||||
with patch('openhands.server.listen.resolve_github_issue') as mock:
|
||||
test_issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=123,
|
||||
title='Test Issue',
|
||||
body='Test body',
|
||||
)
|
||||
test_output = ResolverOutput(
|
||||
issue=test_issue,
|
||||
issue_type='issue',
|
||||
instruction='Test instruction',
|
||||
base_commit='abc123',
|
||||
git_patch='test patch',
|
||||
history=[],
|
||||
metrics={},
|
||||
success=True,
|
||||
success_explanation='Test success',
|
||||
error=None,
|
||||
comment_success=[],
|
||||
)
|
||||
mock.return_value = test_output
|
||||
yield mock
|
||||
|
||||
|
||||
def test_resolve_issue_endpoint(test_client, mock_config, mock_resolve_issue):
|
||||
"""Test the resolve issue endpoint."""
|
||||
# Create test data using Pydantic models
|
||||
test_issue = GithubIssue(
|
||||
owner='test-owner',
|
||||
repo='test-repo',
|
||||
number=123,
|
||||
title='Test Issue',
|
||||
body='Test body',
|
||||
)
|
||||
test_output = ResolverOutput(
|
||||
issue=test_issue,
|
||||
issue_type='issue',
|
||||
instruction='Test instruction',
|
||||
base_commit='abc123',
|
||||
git_patch='test patch',
|
||||
history=[],
|
||||
metrics={},
|
||||
success=True,
|
||||
success_explanation='Test success',
|
||||
error=None,
|
||||
comment_success=[],
|
||||
)
|
||||
|
||||
# Set environment variables before creating the test client
|
||||
with patch.dict(
|
||||
'os.environ', {'GITHUB_TOKEN': 'test-token', 'GITHUB_USERNAME': 'test-user'}
|
||||
):
|
||||
with patch('openhands.server.listen.config', mock_config), patch(
|
||||
'openhands.server.listen.get_sid_from_token', return_value='test-sid'
|
||||
), patch(
|
||||
'openhands.server.listen.create_pull_request_from_resolver_output',
|
||||
return_value=ProcessIssueResult(
|
||||
success=True, url='https://github.com/test/test/pull/123'
|
||||
),
|
||||
) as mock_send_pr:
|
||||
# Test successful resolution and PR creation
|
||||
request_data = {
|
||||
'owner': 'test-owner',
|
||||
'repo': 'test-repo',
|
||||
'token': 'test-token',
|
||||
'username': 'test-user',
|
||||
'max_iterations': 50,
|
||||
'issue_type': 'issue',
|
||||
'issue_number': 123,
|
||||
'comment_id': None,
|
||||
'pr_type': 'draft',
|
||||
'fork_owner': None,
|
||||
'send_on_failure': False,
|
||||
}
|
||||
|
||||
# Create a temp directory for our test
|
||||
with tempfile.TemporaryDirectory() as test_dir:
|
||||
# Mock tempfile.mkdtemp to return our test dir
|
||||
with patch('tempfile.mkdtemp', return_value=test_dir):
|
||||
response = test_client.post(
|
||||
'/api/resolver/resolve-issue',
|
||||
json=request_data,
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()['status'] == 'success'
|
||||
assert (
|
||||
response.json()['result']['url']
|
||||
== 'https://github.com/test/test/pull/123'
|
||||
)
|
||||
|
||||
# Verify mocks were called correctly
|
||||
mock_resolve_issue.assert_called_once()
|
||||
call_args = mock_resolve_issue.call_args[1]
|
||||
assert call_args['owner'] == request_data['owner']
|
||||
assert call_args['repo'] == request_data['repo']
|
||||
assert call_args['issue_number'] == request_data['issue_number']
|
||||
|
||||
mock_send_pr.assert_called_once_with(
|
||||
output_dir=test_dir,
|
||||
resolver_output=test_output,
|
||||
github_token='test-token',
|
||||
github_username='test-user',
|
||||
pr_type='draft',
|
||||
llm_config=mock_config.get_llm_config(),
|
||||
fork_owner=None,
|
||||
send_on_failure=False,
|
||||
)
|
||||
|
||||
# Test error handling
|
||||
mock_resolve_issue.side_effect = Exception('Test error')
|
||||
response = test_client.post(
|
||||
'/api/resolver/resolve-issue',
|
||||
json=request_data,
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()['status'] == 'error'
|
||||
assert response.json()['message'] == 'Test error'
|
||||
|
||||
# Test missing resolver output
|
||||
mock_resolve_issue.side_effect = None
|
||||
mock_resolve_issue.return_value = None
|
||||
response = test_client.post(
|
||||
'/api/resolver/resolve-issue',
|
||||
json=request_data,
|
||||
headers={'Authorization': 'Bearer test-token'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()['status'] == 'error'
|
||||
assert (
|
||||
response.json()['message']
|
||||
== 'No resolver output generated for issue 123'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user