diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml index 3adf543cc2b5..9bb11c3c482e 100644 --- a/.github/workflows/openhands-resolver.yml +++ b/.github/workflows/openhands-resolver.yml @@ -1,15 +1,265 @@ -name: Resolve Issues with OpenHands +name: Auto-Fix Tagged Issue with OpenHands on: + workflow_call: + inputs: + max_iterations: + required: false + type: number + default: 50 + macro: + required: false + type: string + default: "@openhands-agent" + secrets: + LLM_MODEL: + required: true + LLM_API_KEY: + required: true + LLM_BASE_URL: + required: false + PAT_TOKEN: + required: true + PAT_USERNAME: + required: true + issues: types: [labeled] pull_request: types: [labeled] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + +permissions: + contents: write + pull-requests: write + issues: write jobs: - call-openhands-resolver: - uses: All-Hands-AI/openhands-resolver/.github/workflows/openhands-resolver.yml@main - if: github.event.label.name == 'fix-me' - with: - max_iterations: 50 - secrets: inherit + + auto-fix: + if: | + github.event_name == 'workflow_call' || + github.event.label.name == 'fix-me' || + github.event.label.name == 'fix-me-experimental' || + + ( + ((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') && + startsWith(github.event.comment.body, inputs.macro || '@openhands-agent') && + (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER') + ) || + + (github.event_name == 'pull_request_review' && + startsWith(github.event.review.body, inputs.macro || '@openhands-agent') && + (github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER') + ) + ) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Get latest versions and create requirements.txt + run: | + python -m pip index versions openhands-ai > openhands_versions.txt + OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()') + echo "openhands-resolver==${OPENHANDS_VERSION}" >> requirements.txt + cat requirements.txt + + - name: Cache pip dependencies + if: github.event.label.name != 'fix-me-experimental' + uses: actions/cache@v3 + with: + path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/* + key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('requirements.txt') }} + + - name: Check required environment variables + env: + LLM_MODEL: ${{ secrets.LLM_MODEL }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + PAT_USERNAME: ${{ secrets.PAT_USERNAME }} + run: | + required_vars=("LLM_MODEL" "LLM_API_KEY" "PAT_TOKEN" "PAT_USERNAME") + for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo "Error: Required environment variable $var is not set." + exit 1 + fi + done + + - name: Set environment variables + run: | + if [ -n "${{ github.event.review.body }}" ]; then + echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV + echo "ISSUE_TYPE=pr" >> $GITHUB_ENV + elif [ -n "${{ github.event.issue.pull_request }}" ]; then + echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV + echo "ISSUE_TYPE=pr" >> $GITHUB_ENV + elif [ -n "${{ github.event.pull_request.number }}" ]; then + echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV + echo "ISSUE_TYPE=pr" >> $GITHUB_ENV + else + echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV + echo "ISSUE_TYPE=issue" >> $GITHUB_ENV + fi + + if [ -n "${{ github.event.review.body }}" ]; then + echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV + else + echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV + fi + + echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV + echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV + + - name: Comment on issue with start message + uses: actions/github-script@v7 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + const issueType = process.env.ISSUE_TYPE; + github.rest.issues.createComment({ + issue_number: ${{ env.ISSUE_NUMBER }}, + owner: context.repo.owner, + repo: context.repo.repo, + body: `OpenHands started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).` + }); + + - name: Install OpenHands + run: | + if [ "${{ github.event.label.name }}" == "fix-me-experimental" ]; then + python -m pip install --upgrade pip + pip install git+https://github.com/all-hands-ai/openhands.git + else + python -m pip install --upgrade -r requirements.txt + fi + + - name: Attempt to resolve issue + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LLM_MODEL: ${{ secrets.LLM_MODEL }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + PYTHONPATH: "" + run: | + cd /tmp && python -m openhands.resolver.resolve_issue \ + --repo ${{ github.repository }} \ + --issue-number ${{ env.ISSUE_NUMBER }} \ + --issue-type ${{ env.ISSUE_TYPE }} \ + --max-iterations ${{ env.MAX_ITERATIONS }} \ + --comment-id ${{ env.COMMENT_ID }} + + - name: Check resolution result + id: check_result + run: | + if cd /tmp && grep -q '"success":true' output/output.jsonl; then + echo "RESOLUTION_SUCCESS=true" >> $GITHUB_OUTPUT + else + echo "RESOLUTION_SUCCESS=false" >> $GITHUB_OUTPUT + fi + + - name: Upload output.jsonl as artifact + uses: actions/upload-artifact@v4 + if: always() # Upload even if the previous steps fail + with: + name: resolver-output + path: /tmp/output/output.jsonl + retention-days: 30 # Keep the artifact for 30 days + + - name: Create draft PR or push branch + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + LLM_MODEL: ${{ secrets.LLM_MODEL }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + PYTHONPATH: "" + run: | + if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then + cd /tmp && python -m openhands.resolver.send_pull_request \ + --issue-number ${{ env.ISSUE_NUMBER }} \ + --pr-type draft | tee pr_result.txt && \ + grep "draft created" pr_result.txt | sed 's/.*\///g' > pr_number.txt + else + cd /tmp && python -m openhands.resolver.send_pull_request \ + --issue-number ${{ env.ISSUE_NUMBER }} \ + --pr-type branch \ + --send-on-failure | tee branch_result.txt && \ + grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt + fi + + - name: Comment on issue + uses: actions/github-script@v7 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + const fs = require('fs'); + const issueNumber = ${{ env.ISSUE_NUMBER }}; + const success = ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}; + + let prNumber = ''; + let branchName = ''; + let logContent = ''; + const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`; + + try { + if (success){ + logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim(); + } else { + logContent = fs.readFileSync('/tmp/branch_result.txt', 'utf8').trim(); + } + } catch (error) { + console.error('Error reading results file:', error); + } + + try { + if (success) { + prNumber = fs.readFileSync('/tmp/pr_number.txt', 'utf8').trim(); + } else { + branchName = fs.readFileSync('/tmp/branch_name.txt', 'utf8').trim(); + } + } catch (error) { + console.error('Error reading file:', error); + } + + if (logContent.includes(noChangesMessage)) { + github.rest.issues.createComment({ + issue_number: issueNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.` + }); + } else if (success && prNumber) { + github.rest.issues.createComment({ + issue_number: issueNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.` + }); + } else if (!success && branchName) { + github.rest.issues.createComment({ + issue_number: issueNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.` + }); + } else { + github.rest.issues.createComment({ + issue_number: issueNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: `The workflow to fix this issue encountered an error. Please check the workflow logs for more information.` + }); + } diff --git a/.gitignore b/.gitignore index 0cc7d149d781..6d3108331456 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,9 @@ evaluation/gorilla/data evaluation/toolqa/data evaluation/scienceagentbench/benchmark +# openhands resolver +output/ + # frontend # dependencies diff --git a/openhands/core/main.py b/openhands/core/main.py index 94ee0cf3b20c..06dede3d5d55 100644 --- a/openhands/core/main.py +++ b/openhands/core/main.py @@ -35,8 +35,8 @@ class FakeUserResponseFunc(Protocol): def __call__( self, state: State, - encapsulate_solution: bool = ..., - try_parse: Callable[[Action], str] = ..., + encapsulate_solution: bool = False, + try_parse: Callable[[Action | None], str] | None = None, ) -> str: ... diff --git a/openhands/resolver/README.md b/openhands/resolver/README.md new file mode 100644 index 000000000000..d91699fe0c20 --- /dev/null +++ b/openhands/resolver/README.md @@ -0,0 +1,182 @@ +# OpenHands Github Issue Resolver 🙌 + +Need help resolving a GitHub issue but don't have the time to do it yourself? Let an AI agent help you out! + +This tool allows you to use open-source AI agents based on [OpenHands](https://github.com/all-hands-ai/openhands) +to attempt to resolve GitHub issues automatically. While it can handle multiple issues, it's primarily designed +to help you resolve one issue at a time with high quality. + +Getting started is simple - just follow the instructions below. + +## Using the GitHub Actions Workflow + +This repository includes a GitHub Actions workflow that can automatically attempt to fix individual issues labeled with 'fix-me'. +Follow these steps to use this workflow in your own repository: + +1. [Create a personal access token](https://github.com/settings/tokens?type=beta) with read/write scope for "contents", "issues", "pull requests", and "workflows" + +2. Create an API key for the [Claude API](https://www.anthropic.com/api) (recommended) or another supported LLM service + +3. Copy `examples/openhands-resolver.yml` to your repository's `.github/workflows/` directory + +4. Configure repository permissions: + - Go to `Settings -> Actions -> General -> Workflow permissions` + - Select "Read and write permissions" + - Enable "Allow Github Actions to create and approve pull requests" + + Note: If the "Read and write permissions" option is greyed out: + - First check if permissions need to be set at the organization level + - If still greyed out at the organization level, permissions need to be set in the [Enterprise policy settings](https://docs.github.com/en/enterprise-cloud@latest/admin/enforcing-policies/enforcing-policies-for-your-enterprise/enforcing-policies-for-github-actions-in-your-enterprise#enforcing-a-policy-for-workflow-permissions-in-your-enterprise) + +5. Set up [GitHub secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions): + - Required: + - `PAT_USERNAME`: GitHub username for the personal access token + - `PAT_TOKEN`: The personal access token + - `LLM_MODEL`: LLM model to use (e.g., "anthropic/claude-3-5-sonnet-20241022") + - `LLM_API_KEY`: Your LLM API key + - Optional: + - `LLM_BASE_URL`: Base URL for LLM API (only if using a proxy) + + Note: You can set these secrets at the organization level to use across multiple repositories. + +6. Usage: + There are two ways to trigger the OpenHands agent: + + a. Using the 'fix-me' label: + - Add the 'fix-me' label to any issue you want the AI to resolve + - The agent will consider all comments in the issue thread when resolving + - The workflow will: + 1. Attempt to resolve the issue using OpenHands + 2. Create a draft PR if successful, or push a branch if unsuccessful + 3. Comment on the issue with the results + 4. Remove the 'fix-me' label once processed + + b. Using `@openhands-agent` mention: + - Create a new comment containing `@openhands-agent` in any issue + - The agent will only consider the comment where it's mentioned + - The workflow will: + 1. Attempt to resolve the issue based on the specific comment + 2. Create a draft PR if successful, or push a branch if unsuccessful + 3. Comment on the issue with the results + +Need help? Feel free to [open an issue](https://github.com/all-hands-ai/openhands-resolver/issues) or email us at [contact@all-hands.dev](mailto:contact@all-hands.dev). + +## Manual Installation + +If you prefer to run the resolver programmatically instead of using GitHub Actions, follow these steps: + +1. Install the package: + +```bash +pip install openhands-ai +``` + +2. Create a GitHub access token: + - Visit [GitHub's token settings](https://github.com/settings/personal-access-tokens/new) + - Create a fine-grained token with these scopes: + - "Content" + - "Pull requests" + - "Issues" + - "Workflows" + - If you don't have push access to the target repo, you can fork it first + +3. Set up environment variables: + +```bash +# GitHub credentials +export GITHUB_TOKEN="your-github-token" +export GITHUB_USERNAME="your-github-username" # Optional, defaults to token owner + +# LLM configuration +export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022" # Recommended +export LLM_API_KEY="your-llm-api-key" +export LLM_BASE_URL="your-api-url" # Optional, for API proxies +``` + +Note: OpenHands works best with powerful models like Anthropic's Claude or OpenAI's GPT-4. While other models are supported, they may not perform as well for complex issue resolution. + +## Resolving Issues + +The resolver can automatically attempt to fix a single issue in your repository using the following command: + +```bash +python -m openhands.resolver.resolve_issue --repo [OWNER]/[REPO] --issue-number [NUMBER] +``` + +For instance, if you want to resolve issue #100 in this repo, you would run: + +```bash +python -m openhands.resolver.resolve_issue --repo all-hands-ai/openhands-resolver --issue-number 100 +``` + +The output will be written to the `output/` directory. + +If you've installed the package from source using poetry, you can use: + +```bash +poetry run python openhands/resolver/resolve_issue.py --repo all-hands-ai/openhands-resolver --issue-number 100 +``` + +For resolving multiple issues at once (e.g., in a batch process), you can use the `resolve_all_issues` command: + +```bash +python -m openhands.resolver.resolve_all_issues --repo [OWNER]/[REPO] --issue-numbers [NUMBERS] +``` + +For example: + +```bash +python -m openhands.resolver.resolve_all_issues --repo all-hands-ai/openhands-resolver --issue-numbers 100,101,102 +``` + +## Responding to PR Comments + +The resolver can also respond to comments on pull requests using: + +```bash +python -m openhands.resolver.send_pull_request --issue-number PR_NUMBER --issue-type pr +``` + +This functionality is available both through the GitHub Actions workflow and when running the resolver locally. + +## Visualizing successful PRs + +To find successful PRs, you can run the following command: + +```bash +grep '"success":true' output/output.jsonl | sed 's/.*\("number":[0-9]*\).*/\1/g' +``` + +Then you can go through and visualize the ones you'd like. + +```bash +python -m openhands.resolver.visualize_resolver_output --issue-number ISSUE_NUMBER --vis-method json +``` + +## Uploading PRs + +If you find any PRs that were successful, you can upload them. +There are three ways you can upload: + +1. `branch` - upload a branch without creating a PR +2. `draft` - create a draft PR +3. `ready` - create a non-draft PR that's ready for review + +```bash +python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --github-username YOUR_GITHUB_USERNAME --pr-type draft +``` + +If you want to upload to a fork, you can do so by specifying the `fork-owner`: + +```bash +python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --github-username YOUR_GITHUB_USERNAME --pr-type draft --fork-owner YOUR_GITHUB_USERNAME +``` + +## Providing Custom Instructions + +You can customize how the AI agent approaches issue resolution by adding a `.openhands_instructions` file to the root of your repository. If present, this file's contents will be injected into the prompt for openhands edits. + +## Troubleshooting + +If you have any issues, please open an issue on this github repo, we're happy to help! +Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the [OpenHands Slack workspace](https://join.slack.com/t/opendevin/shared_invite/zt-2oikve2hu-UDxHeo8nsE69y6T7yFX_BA) and ask there. diff --git a/openhands/resolver/__init__.py b/openhands/resolver/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openhands/resolver/examples/openhands-resolver.yml b/openhands/resolver/examples/openhands-resolver.yml new file mode 100644 index 000000000000..6555e15057c7 --- /dev/null +++ b/openhands/resolver/examples/openhands-resolver.yml @@ -0,0 +1,34 @@ +name: Resolve Issue with OpenHands + +on: + issues: + types: [labeled] + pull_request: + types: [labeled] + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + call-openhands-resolver: + if: | + ${{ + github.event.label.name == 'fix-me' || + (github.event_name == 'issue_comment' && + startsWith(github.event.comment.body, vars.OPENHANDS_MACRO || '@openhands-agent') && + (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')) + }} + uses: All-Hands-AI/OpenHands/.github/workflows/openhands-resolver.yml@main + with: + macro: ${{ vars.OPENHANDS_MACRO || '@openhands-agent' }} + max_iterations: 50 + secrets: + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + PAT_USERNAME: ${{ secrets.PAT_USERNAME }} + LLM_MODEL: ${{ secrets.LLM_MODEL }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} diff --git a/openhands/resolver/github_issue.py b/openhands/resolver/github_issue.py new file mode 100644 index 000000000000..9b8f58d589a4 --- /dev/null +++ b/openhands/resolver/github_issue.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + + +class ReviewThread(BaseModel): + comment: str + files: list[str] + + +class GithubIssue(BaseModel): + owner: str + repo: str + number: int + title: str + body: str + thread_comments: list[str] | None = None # Added field for issue thread comments + closing_issues: list[str] | None = None + review_comments: list[str] | None = None + review_threads: list[ReviewThread] | None = None + thread_ids: list[str] | None = None + head_branch: str | None = None diff --git a/openhands/resolver/io_utils.py b/openhands/resolver/io_utils.py new file mode 100644 index 000000000000..ce87bc625c47 --- /dev/null +++ b/openhands/resolver/io_utils.py @@ -0,0 +1,17 @@ +import json +from typing import Iterable + +from openhands.resolver.resolver_output import ResolverOutput + + +def load_all_resolver_outputs(output_jsonl: str) -> Iterable[ResolverOutput]: + with open(output_jsonl, 'r') as f: + for line in f: + yield ResolverOutput.model_validate(json.loads(line)) + + +def load_single_resolver_output(output_jsonl: str, issue_number: int) -> ResolverOutput: + for resolver_output in load_all_resolver_outputs(output_jsonl): + if resolver_output.issue.number == issue_number: + return resolver_output + raise ValueError(f'Issue number {issue_number} not found in {output_jsonl}') diff --git a/openhands/resolver/issue_definitions.py b/openhands/resolver/issue_definitions.py new file mode 100644 index 000000000000..2d40923ca568 --- /dev/null +++ b/openhands/resolver/issue_definitions.py @@ -0,0 +1,728 @@ +import json +import os +import re +from abc import ABC, abstractmethod +from typing import Any, ClassVar + +import jinja2 +import litellm +import requests + +from openhands.core.config import LLMConfig +from openhands.core.logger import openhands_logger as logger +from openhands.events.event import Event +from openhands.resolver.github_issue import GithubIssue, ReviewThread + + +class IssueHandlerInterface(ABC): + issue_type: ClassVar[str] + + @abstractmethod + def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]: + """Download issues from GitHub.""" + pass + + @abstractmethod + def get_instruction( + self, + issue: GithubIssue, + prompt_template: str, + repo_instruction: str | None = None, + ) -> tuple[str, list[str]]: + """Generate instruction and image urls for the agent.""" + pass + + @abstractmethod + def guess_success( + self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig + ) -> tuple[bool, list[bool] | None, str]: + """Guess if the issue has been resolved based on the agent's output.""" + pass + + +class IssueHandler(IssueHandlerInterface): + issue_type: ClassVar[str] = 'issue' + + def __init__(self, owner: str, repo: str, token: str): + self.download_url = 'https://api.github.com/repos/{}/{}/issues' + self.owner = owner + self.repo = repo + self.token = token + + def _download_issues_from_github(self) -> list[Any]: + url = self.download_url.format(self.owner, self.repo) + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json', + } + params: dict[str, int | str] = {'state': 'open', 'per_page': 100, 'page': 1} + all_issues = [] + + while True: + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + issues = response.json() + + if not issues: + break + + if not isinstance(issues, list) or any( + [not isinstance(issue, dict) for issue in issues] + ): + raise ValueError('Expected list of dictionaries from Github API.') + + all_issues.extend(issues) + assert isinstance(params['page'], int) + params['page'] += 1 + + return all_issues + + def _extract_image_urls(self, issue_body: str) -> list[str]: + # Regular expression to match Markdown image syntax ![alt text](image_url) + image_pattern = r'!\[.*?\]\((https?://[^\s)]+)\)' + return re.findall(image_pattern, issue_body) + + def _extract_issue_references(self, body: str) -> list[int]: + pattern = r'#(\d+)' + return [int(match) for match in re.findall(pattern, body)] + + def _get_issue_comments( + self, issue_number: int, comment_id: int | None = None + ) -> list[str] | None: + """Download comments for a specific issue from Github.""" + url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments' + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json', + } + params = {'per_page': 100, 'page': 1} + all_comments = [] + + while True: + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + comments = response.json() + + if not comments: + break + + if comment_id: + matching_comment = next( + ( + comment['body'] + for comment in comments + if comment['id'] == comment_id + ), + None, + ) + if matching_comment: + return [matching_comment] + else: + all_comments.extend([comment['body'] for comment in comments]) + + params['page'] += 1 + + return all_comments if all_comments else None + + def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]: + """Download issues from Github. + + Returns: + List of Github issues. + """ + all_issues = self._download_issues_from_github() + converted_issues = [] + for issue in all_issues: + if any([issue.get(key) is None for key in ['number', 'title', 'body']]): + logger.warning( + f'Skipping issue {issue} as it is missing number, title, or body.' + ) + continue + + if 'pull_request' in issue: + continue + + # Get issue thread comments + thread_comments = self._get_issue_comments( + issue['number'], comment_id=comment_id + ) + # Convert empty lists to None for optional fields + issue_details = GithubIssue( + owner=self.owner, + repo=self.repo, + number=issue['number'], + title=issue['title'], + body=issue['body'], + thread_comments=thread_comments, + review_comments=None, # Initialize review comments as None for regular issues + ) + + converted_issues.append(issue_details) + + return converted_issues + + def get_instruction( + self, + issue: GithubIssue, + prompt_template: str, + repo_instruction: str | None = None, + ) -> tuple[str, list[str]]: + """Generate instruction for the agent.""" + # Format thread comments if they exist + thread_context = '' + if issue.thread_comments: + thread_context = '\n\nIssue Thread Comments:\n' + '\n---\n'.join( + issue.thread_comments + ) + + images = [] + images.extend(self._extract_image_urls(issue.body)) + images.extend(self._extract_image_urls(thread_context)) + + template = jinja2.Template(prompt_template) + return ( + template.render( + body=issue.title + '\n\n' + issue.body + thread_context, + repo_instruction=repo_instruction, + ), + images, + ) + + def guess_success( + self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig + ) -> tuple[bool, None | list[bool], str]: + """Guess if the issue is fixed based on the history and the issue description.""" + last_message = history[-1].message + # Include thread comments in the prompt if they exist + issue_context = issue.body + if issue.thread_comments: + issue_context += '\n\nIssue Thread Comments:\n' + '\n---\n'.join( + issue.thread_comments + ) + + with open( + os.path.join( + os.path.dirname(__file__), + 'prompts/guess_success/issue-success-check.jinja', + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + prompt = template.render(issue_context=issue_context, last_message=last_message) + + response = litellm.completion( + model=llm_config.model, + messages=[{'role': 'user', 'content': prompt}], + api_key=llm_config.api_key, + base_url=llm_config.base_url, + ) + + answer = response.choices[0].message.content.strip() + pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)' + match = re.search(pattern, answer) + if match: + return match.group(1).lower() == 'true', None, match.group(2) + + return False, None, f'Failed to decode answer from LLM response: {answer}' + + +class PRHandler(IssueHandler): + issue_type: ClassVar[str] = 'pr' + + def __init__(self, owner: str, repo: str, token: str): + super().__init__(owner, repo, token) + self.download_url = 'https://api.github.com/repos/{}/{}/pulls' + + def __download_pr_metadata( + self, pull_number: int, comment_id: int | None = None + ) -> tuple[list[str], list[int], list[str], list[ReviewThread], list[str]]: + """Run a GraphQL query against the GitHub API for information. + + Retrieves information about: + 1. unresolved review comments + 2. referenced issues the pull request would close + + Args: + pull_number: The number of the pull request to query. + comment_id: Optional ID of a specific comment to focus on. + query: The GraphQL query as a string. + variables: A dictionary of variables for the query. + token: Your GitHub personal access token. + + Returns: + The JSON response from the GitHub API. + """ + # Using graphql as REST API doesn't indicate resolved status for review comments + # TODO: grabbing the first 10 issues, 100 review threads, and 100 coments; add pagination to retrieve all + query = """ + query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + closingIssuesReferences(first: 10) { + edges { + node { + body + number + } + } + } + url + reviews(first: 100) { + nodes { + body + state + fullDatabaseId + } + } + reviewThreads(first: 100) { + edges{ + node{ + id + isResolved + comments(first: 100) { + totalCount + nodes { + body + path + fullDatabaseId + } + } + } + } + } + } + } + } + """ + + variables = {'owner': self.owner, 'repo': self.repo, 'pr': pull_number} + + url = 'https://api.github.com/graphql' + headers = { + 'Authorization': f'Bearer {self.token}', + 'Content-Type': 'application/json', + } + + response = requests.post( + url, json={'query': query, 'variables': variables}, headers=headers + ) + response.raise_for_status() + response_json = response.json() + + # Parse the response to get closing issue references and unresolved review comments + pr_data = ( + response_json.get('data', {}).get('repository', {}).get('pullRequest', {}) + ) + + # Get closing issues + closing_issues = pr_data.get('closingIssuesReferences', {}).get('edges', []) + closing_issues_bodies = [issue['node']['body'] for issue in closing_issues] + closing_issue_numbers = [ + issue['node']['number'] for issue in closing_issues + ] # Extract issue numbers + + # Get review comments + reviews = pr_data.get('reviews', {}).get('nodes', []) + if comment_id is not None: + reviews = [ + review + for review in reviews + if int(review['fullDatabaseId']) == comment_id + ] + review_bodies = [review['body'] for review in reviews] + + # Get unresolved review threads + review_threads = [] + thread_ids = [] # Store thread IDs; agent replies to the thread + raw_review_threads = pr_data.get('reviewThreads', {}).get('edges', []) + for thread in raw_review_threads: + node = thread.get('node', {}) + if not node.get( + 'isResolved', True + ): # Check if the review thread is unresolved + id = node.get('id') + thread_contains_comment_id = False + my_review_threads = node.get('comments', {}).get('nodes', []) + message = '' + files = [] + for i, review_thread in enumerate(my_review_threads): + if ( + comment_id is not None + and int(review_thread['fullDatabaseId']) == comment_id + ): + thread_contains_comment_id = True + + if ( + i == len(my_review_threads) - 1 + ): # Check if it's the last thread in the thread + if len(my_review_threads) > 1: + message += '---\n' # Add "---" before the last message if there's more than one thread + message += 'latest feedback:\n' + review_thread['body'] + '\n' + else: + message += ( + review_thread['body'] + '\n' + ) # Add each thread in a new line + + file = review_thread.get('path') + if file and file not in files: + files.append(file) + + if comment_id is None or thread_contains_comment_id: + unresolved_thread = ReviewThread(comment=message, files=files) + review_threads.append(unresolved_thread) + thread_ids.append(id) + + return ( + closing_issues_bodies, + closing_issue_numbers, + review_bodies, + review_threads, + thread_ids, + ) + + # Override processing of downloaded issues + def _get_pr_comments( + self, pr_number: int, comment_id: int | None = None + ) -> list[str] | None: + """Download comments for a specific pull request from Github.""" + url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments' + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json', + } + params = {'per_page': 100, 'page': 1} + all_comments = [] + + while True: + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + comments = response.json() + + if not comments: + break + + if comment_id is not None: + matching_comment = next( + ( + comment['body'] + for comment in comments + if comment['id'] == comment_id + ), + None, + ) + if matching_comment: + return [matching_comment] + else: + all_comments.extend([comment['body'] for comment in comments]) + + params['page'] += 1 + + return all_comments if all_comments else None + + def __get_context_from_external_issues_references( + self, + closing_issues: list[str], + closing_issue_numbers: list[int], + issue_body: str, + review_comments: list[str], + review_threads: list[ReviewThread], + thread_comments: list[str] | None, + ): + new_issue_references = [] + + if issue_body: + new_issue_references.extend(self._extract_issue_references(issue_body)) + + if review_comments: + for comment in review_comments: + new_issue_references.extend(self._extract_issue_references(comment)) + + if review_threads: + for review_thread in review_threads: + new_issue_references.extend( + self._extract_issue_references(review_thread.comment) + ) + + if thread_comments: + for thread_comment in thread_comments: + new_issue_references.extend( + self._extract_issue_references(thread_comment) + ) + + non_duplicate_references = set(new_issue_references) + unique_issue_references = non_duplicate_references.difference( + closing_issue_numbers + ) + + for issue_number in unique_issue_references: + 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 + + def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]: + all_issues = self._download_issues_from_github() + converted_issues = [] + for issue in all_issues: + # For PRs, body can be None + if any([issue.get(key) is None for key in ['number', 'title']]): + logger.warning(f'Skipping #{issue} as it is missing number or title.') + continue + + # Handle None body for PRs + body = issue.get('body') if issue.get('body') is not None else '' + ( + closing_issues, + closing_issues_numbers, + review_comments, + review_threads, + thread_ids, + ) = self.__download_pr_metadata(issue['number'], comment_id=comment_id) + head_branch = issue['head']['ref'] + + # Get PR thread comments + thread_comments = self._get_pr_comments( + issue['number'], comment_id=comment_id + ) + + closing_issues = self.__get_context_from_external_issues_references( + closing_issues, + closing_issues_numbers, + body, + review_comments, + review_threads, + thread_comments, + ) + + issue_details = GithubIssue( + owner=self.owner, + repo=self.repo, + number=issue['number'], + title=issue['title'], + body=body, + closing_issues=closing_issues, + review_comments=review_comments, + review_threads=review_threads, + thread_ids=thread_ids, + head_branch=head_branch, + thread_comments=thread_comments, + ) + + converted_issues.append(issue_details) + + return converted_issues + + def get_instruction( + self, + issue: GithubIssue, + prompt_template: str, + repo_instruction: str | None = None, + ) -> tuple[str, list[str]]: + """Generate instruction for the agent.""" + template = jinja2.Template(prompt_template) + images = [] + + issues_str = None + if issue.closing_issues: + issues_str = json.dumps(issue.closing_issues, indent=4) + images.extend(self._extract_image_urls(issues_str)) + + # Handle PRs with review comments + review_comments_str = None + if issue.review_comments: + review_comments_str = json.dumps(issue.review_comments, indent=4) + images.extend(self._extract_image_urls(review_comments_str)) + + # Handle PRs with file-specific review comments + review_thread_str = None + review_thread_file_str = None + if issue.review_threads: + review_threads = [ + review_thread.comment for review_thread in issue.review_threads + ] + review_thread_files = [] + for review_thread in issue.review_threads: + review_thread_files.extend(review_thread.files) + review_thread_str = json.dumps(review_threads, indent=4) + review_thread_file_str = json.dumps(review_thread_files, indent=4) + images.extend(self._extract_image_urls(review_thread_str)) + + # Format thread comments if they exist + thread_context = '' + if issue.thread_comments: + thread_context = '\n\nPR Thread Comments:\n' + '\n---\n'.join( + issue.thread_comments + ) + images.extend(self._extract_image_urls(thread_context)) + + instruction = template.render( + issues=issues_str, + review_comments=review_comments_str, + review_threads=review_thread_str, + files=review_thread_file_str, + thread_context=thread_context, + repo_instruction=repo_instruction, + ) + return instruction, images + + def _check_feedback_with_llm( + self, prompt: str, llm_config: LLMConfig + ) -> tuple[bool, str]: + """Helper function to check feedback with LLM and parse response.""" + response = litellm.completion( + model=llm_config.model, + messages=[{'role': 'user', 'content': prompt}], + api_key=llm_config.api_key, + base_url=llm_config.base_url, + ) + + answer = response.choices[0].message.content.strip() + pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)' + match = re.search(pattern, answer) + if match: + return match.group(1).lower() == 'true', match.group(2).strip() + return False, f'Failed to decode answer from LLM response: {answer}' + + def _check_review_thread( + self, + review_thread: ReviewThread, + issues_context: str, + last_message: str, + llm_config: LLMConfig, + ) -> tuple[bool, str]: + """Check if a review thread's feedback has been addressed.""" + files_context = json.dumps(review_thread.files, indent=4) + + with open( + os.path.join( + os.path.dirname(__file__), + 'prompts/guess_success/pr-feedback-check.jinja', + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + + prompt = template.render( + issue_context=issues_context, + feedback=review_thread.comment, + files_context=files_context, + last_message=last_message, + ) + + return self._check_feedback_with_llm(prompt, llm_config) + + def _check_thread_comments( + self, + thread_comments: list[str], + issues_context: str, + last_message: str, + llm_config: LLMConfig, + ) -> tuple[bool, str]: + """Check if thread comments feedback has been addressed.""" + thread_context = '\n---\n'.join(thread_comments) + + with open( + os.path.join( + os.path.dirname(__file__), 'prompts/guess_success/pr-thread-check.jinja' + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + + prompt = template.render( + issue_context=issues_context, + thread_context=thread_context, + last_message=last_message, + ) + + return self._check_feedback_with_llm(prompt, llm_config) + + def _check_review_comments( + self, + review_comments: list[str], + issues_context: str, + last_message: str, + llm_config: LLMConfig, + ) -> tuple[bool, str]: + """Check if review comments feedback has been addressed.""" + review_context = '\n---\n'.join(review_comments) + + with open( + os.path.join( + os.path.dirname(__file__), 'prompts/guess_success/pr-review-check.jinja' + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + + prompt = template.render( + issue_context=issues_context, + review_context=review_context, + last_message=last_message, + ) + + return self._check_feedback_with_llm(prompt, llm_config) + + def guess_success( + self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig + ) -> tuple[bool, None | list[bool], str]: + """Guess if the issue is fixed based on the history and the issue description.""" + last_message = history[-1].message + issues_context = json.dumps(issue.closing_issues, indent=4) + success_list = [] + explanation_list = [] + + # Handle PRs with file-specific review comments + if issue.review_threads: + for review_thread in issue.review_threads: + if issues_context and last_message: + success, explanation = self._check_review_thread( + review_thread, issues_context, last_message, llm_config + ) + else: + success, explanation = False, 'Missing context or message' + success_list.append(success) + explanation_list.append(explanation) + # Handle PRs with only thread comments (no file-specific review comments) + elif issue.thread_comments: + if issue.thread_comments and issues_context and last_message: + success, explanation = self._check_thread_comments( + issue.thread_comments, issues_context, last_message, llm_config + ) + else: + success, explanation = ( + False, + 'Missing thread comments, context or message', + ) + success_list.append(success) + explanation_list.append(explanation) + elif issue.review_comments: + # Handle PRs with only review comments (no file-specific review comments or thread comments) + if issue.review_comments and issues_context and last_message: + success, explanation = self._check_review_comments( + issue.review_comments, issues_context, last_message, llm_config + ) + else: + success, explanation = ( + False, + 'Missing review comments, context or message', + ) + success_list.append(success) + explanation_list.append(explanation) + else: + # No review comments, thread comments, or file-level review comments found + return False, None, 'No feedback was found to process' + + # Return overall success (all must be true) and explanations + if not success_list: + return False, None, 'No feedback was processed' + return all(success_list), success_list, '\n'.join(explanation_list) diff --git a/openhands/resolver/patching/README.md b/openhands/resolver/patching/README.md new file mode 100644 index 000000000000..e0c7b98eac78 --- /dev/null +++ b/openhands/resolver/patching/README.md @@ -0,0 +1,4 @@ +# Patching code + +Originally from [whatthepatch](https://github.com/cscorley/whatthepatch) +(MIT license) diff --git a/openhands/resolver/patching/__init__.py b/openhands/resolver/patching/__init__.py new file mode 100644 index 000000000000..5c31f160a0a0 --- /dev/null +++ b/openhands/resolver/patching/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from .patch import parse_patch +from .apply import apply_diff + +__all__ = ["parse_patch", "apply_diff"] diff --git a/openhands/resolver/patching/apply.py b/openhands/resolver/patching/apply.py new file mode 100644 index 000000000000..f13e814292cb --- /dev/null +++ b/openhands/resolver/patching/apply.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +import os.path +import subprocess +import tempfile + +from .exceptions import HunkApplyException, SubprocessException +from .snippets import remove, which + + +def _apply_diff_with_subprocess(diff, lines, reverse=False): + # call out to patch program + patchexec = which("patch") + if not patchexec: + 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") + + with open(patchfilepath, "w") as f: + f.write(diff.text) + + args = [ + patchexec, + "--reverse" if reverse else "--forward", + "--quiet", + "--no-backup-if-mismatch", + "-o", + newfilepath, + "-i", + patchfilepath, + "-r", + rejfilepath, + oldfilepath, + ] + ret = subprocess.call(args) + + with open(newfilepath) as f: + lines = f.read().splitlines() + + try: + with open(rejfilepath) as f: + rejlines = f.read().splitlines() + except IOError: + rejlines = None + + remove(oldfilepath) + remove(newfilepath) + remove(rejfilepath) + remove(patchfilepath) + + # do this last to ensure files get cleaned up + if ret != 0: + raise SubprocessException("patch program failed", code=ret) + + return lines, rejlines + + +def _reverse(changes): + def _reverse_change(c): + return c._replace(old=c.new, new=c.old) + + return [_reverse_change(c) for c in changes] + + +def apply_diff(diff, text, reverse=False, use_patch=False): + try: + lines = text.splitlines() + except AttributeError: + lines = list(text) + + if use_patch: + return _apply_diff_with_subprocess(diff, lines, reverse) + + n_lines = len(lines) + + changes = _reverse(diff.changes) if reverse else diff.changes + # check that the source text matches the context of the diff + for old, new, line, hunk in changes: + # might have to check for line is None here for ed scripts + if old is not None and line is not None: + if old > n_lines: + raise HunkApplyException( + 'context line {n}, "{line}" does not exist in source'.format( + n=old, line=line + ), + hunk=hunk, + ) + if lines[old - 1] != line: + raise HunkApplyException( + 'context line {n}, "{line}" does not match "{sl}"'.format( + n=old, line=line, sl=lines[old - 1] + ), + hunk=hunk, + ) + + # for calculating the old line + r = 0 + i = 0 + + for old, new, line, hunk in changes: + if old is not None and new is None: + del lines[old - 1 - r + i] + r += 1 + elif old is None and new is not None: + lines.insert(new - 1, line) + i += 1 + elif old is not None and new is not None: + # Sometimes, people remove hunks from patches, making these + # numbers completely unreliable. Because they're jerks. + pass + + return lines diff --git a/openhands/resolver/patching/exceptions.py b/openhands/resolver/patching/exceptions.py new file mode 100644 index 000000000000..594b079e8365 --- /dev/null +++ b/openhands/resolver/patching/exceptions.py @@ -0,0 +1,31 @@ +class PatchingException(Exception): + pass + + +class HunkException(PatchingException): + def __init__(self, msg, hunk=None): + self.hunk = hunk + if hunk is not None: + super(HunkException, self).__init__( + "{msg}, in hunk #{n}".format(msg=msg, n=hunk) + ) + else: + super(HunkException, self).__init__(msg) + + +class ApplyException(PatchingException): + pass + + +class SubprocessException(ApplyException): + def __init__(self, msg, code): + super(SubprocessException, self).__init__(msg) + self.code = code + + +class HunkApplyException(HunkException, ApplyException, ValueError): + pass + + +class ParseException(HunkException, ValueError): + pass diff --git a/openhands/resolver/patching/patch.py b/openhands/resolver/patching/patch.py new file mode 100644 index 000000000000..c0304e06543b --- /dev/null +++ b/openhands/resolver/patching/patch.py @@ -0,0 +1,1015 @@ +# -*- coding: utf-8 -*- +import base64 +import re +import zlib +from collections import namedtuple + +from . import exceptions +from .snippets import findall_regex, split_by_regex + +header = namedtuple( + "header", + "index_path old_path old_version new_path new_version", +) + +diffobj = namedtuple("diffobj", "header changes text") +Change = namedtuple("Change", "old new line hunk") + +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("^([-+ ])(.*)$") + +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("^.$") +# much like forward ed, but no 'c' type +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("^([><]) (.*)$") + +# 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!#$%&()*+;<=>?@^_`{|}~-]+$") + +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".*(\(.*\))$") + +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.* (.+):(.*) (.+):(.*)$") + + +def parse_patch(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + # maybe use this to nuke all of those line endings? + # lines = [x.splitlines()[0] for x in lines] + lines = [x if len(x) == 0 else x.splitlines()[0] for x in lines] + + check = [ + unified_header_index, + diffcmd_header, + cvs_header_rcs, + git_header_index, + context_header_old_line, + unified_header_old_line, + ] + + diffs = [] + for c in check: + diffs = split_by_regex(lines, c) + if len(diffs) > 1: + break + + for diff in diffs: + difftext = "\n".join(diff) + "\n" + h = parse_header(diff) + d = parse_diff(diff) + if h or d: + yield diffobj(header=h, changes=d, text=difftext) + + +def parse_header(text): + h = parse_scm_header(text) + if h is None: + h = parse_diff_header(text) + return h + + +def parse_scm_header(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + check = [ + (git_header_index, parse_git_header), + (old_cvs_diffcmd_header, parse_cvs_header), + (cvs_header_rcs, parse_cvs_header), + (svn_header_index, parse_svn_header), + ] + + for regex, parser in check: + diffs = findall_regex(lines, regex) + if len(diffs) > 0: + git_opt = findall_regex(lines, git_diffcmd_header) + if len(git_opt) > 0: + res = parser(lines) + if res: + old_path = res.old_path + new_path = res.new_path + if old_path.startswith("a/"): + old_path = old_path[2:] + + if new_path.startswith("b/"): + new_path = new_path[2:] + + return header( + index_path=res.index_path, + old_path=old_path, + old_version=res.old_version, + new_path=new_path, + new_version=res.new_version, + ) + else: + res = parser(lines) + + return res + + return None + + +def parse_diff_header(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + check = [ + (unified_header_new_line, parse_unified_header), + (context_header_old_line, parse_context_header), + (diffcmd_header, parse_diffcmd_header), + # TODO: + # git_header can handle version-less unified headers, but + # will trim a/ and b/ in the paths if they exist... + (git_header_new_line, parse_git_header), + ] + + for regex, parser in check: + diffs = findall_regex(lines, regex) + if len(diffs) > 0: + return parser(lines) + + return None # no header? + + +def parse_diff(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + check = [ + (unified_hunk_start, parse_unified_diff), + (context_hunk_start, parse_context_diff), + (default_hunk_start, parse_default_diff), + (ed_hunk_start, parse_ed_diff), + (rcs_ed_hunk_start, parse_rcs_ed_diff), + (git_binary_patch_start, parse_git_binary_diff), + ] + + for hunk, parser in check: + diffs = findall_regex(lines, hunk) + if len(diffs) > 0: + return parser(lines) + return None + + +def parse_git_header(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + old_version = None + new_version = None + old_path = None + new_path = None + cmd_old_path = None + cmd_new_path = None + for line in lines: + hm = git_diffcmd_header.match(line) + if hm: + cmd_old_path = hm.group(1) + cmd_new_path = hm.group(2) + continue + + g = git_header_index.match(line) + if g: + old_version = g.group(1) + new_version = g.group(2) + continue + + # git always has it's own special headers + o = git_header_old_line.match(line) + if o: + old_path = o.group(1) + + n = git_header_new_line.match(line) + if n: + new_path = n.group(1) + + binary = git_header_binary_file.match(line) + if binary: + old_path = binary.group(1) + new_path = binary.group(2) + + if old_path and new_path: + if old_path.startswith("a/"): + old_path = old_path[2:] + + if new_path.startswith("b/"): + new_path = new_path[2:] + return header( + index_path=None, + old_path=old_path, + old_version=old_version, + new_path=new_path, + new_version=new_version, + ) + + # 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/"): + cmd_old_path = cmd_old_path[2:] + + 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_version=old_version, + new_path="/dev/null" if new_version == "0000000" else cmd_new_path, + new_version=new_version, + ) + + return None + + +def parse_svn_header(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + headers = findall_regex(lines, svn_header_index) + if len(headers) == 0: + return None + + while len(lines) > 0: + i = svn_header_index.match(lines[0]) + del lines[0] + if not i: + continue + + diff_header = parse_diff_header(lines) + if not diff_header: + return header( + index_path=i.group(1), + old_path=i.group(1), + old_version=None, + new_path=i.group(1), + new_version=None, + ) + + opath = diff_header.old_path + over = diff_header.old_version + if over: + oend = svn_header_timestamp_version.match(over) + if oend and oend.group(1): + over = int(oend.group(1)) + elif opath: + ts = svn_header_timestamp.match(opath) + if ts: + opath = opath[: -len(ts.group(1))] + oend = svn_header_timestamp_version.match(ts.group(1)) + if oend and oend.group(1): + over = int(oend.group(1)) + + npath = diff_header.new_path + nver = diff_header.new_version + if nver: + nend = svn_header_timestamp_version.match(diff_header.new_version) + if nend and nend.group(1): + nver = int(nend.group(1)) + elif npath: + ts = svn_header_timestamp.match(npath) + if ts: + npath = npath[: -len(ts.group(1))] + nend = svn_header_timestamp_version.match(ts.group(1)) + if nend and nend.group(1): + nver = int(nend.group(1)) + + if not isinstance(over, int): + over = None + + if not isinstance(nver, int): + nver = None + + return header( + index_path=i.group(1), + old_path=opath, + old_version=over, + new_path=npath, + new_version=nver, + ) + + return None + + +def parse_cvs_header(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + headers = findall_regex(lines, cvs_header_rcs) + headers_old = findall_regex(lines, old_cvs_diffcmd_header) + + if headers: + # parse rcs style headers + while len(lines) > 0: + i = cvs_header_index.match(lines[0]) + del lines[0] + if not i: + continue + + diff_header = parse_diff_header(lines) + if diff_header: + over = diff_header.old_version + if over: + oend = cvs_header_timestamp.match(over) + oend_c = cvs_header_timestamp_colon.match(over) + if oend: + over = oend.group(2) + elif oend_c: + over = oend_c.group(1) + + nver = diff_header.new_version + if nver: + nend = cvs_header_timestamp.match(nver) + nend_c = cvs_header_timestamp_colon.match(nver) + if nend: + nver = nend.group(2) + elif nend_c: + nver = nend_c.group(1) + + return header( + index_path=i.group(1), + old_path=diff_header.old_path, + old_version=over, + new_path=diff_header.new_path, + new_version=nver, + ) + return header( + index_path=i.group(1), + old_path=i.group(1), + old_version=None, + new_path=i.group(1), + new_version=None, + ) + elif headers_old: + # parse old style headers + while len(lines) > 0: + i = cvs_header_index.match(lines[0]) + del lines[0] + if not i: + continue + + d = old_cvs_diffcmd_header.match(lines[0]) + if not d: + return header( + index_path=i.group(1), + old_path=i.group(1), + old_version=None, + new_path=i.group(1), + new_version=None, + ) + + # will get rid of the useless stuff for us + parse_diff_header(lines) + over = d.group(2) if d.group(2) else None + nver = d.group(4) if d.group(4) else None + return header( + index_path=i.group(1), + old_path=d.group(1), + old_version=over, + new_path=d.group(3), + new_version=nver, + ) + + return None + + +def parse_diffcmd_header(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + headers = findall_regex(lines, diffcmd_header) + if len(headers) == 0: + return None + + while len(lines) > 0: + d = diffcmd_header.match(lines[0]) + del lines[0] + if d: + return header( + index_path=None, + old_path=d.group(1), + old_version=None, + new_path=d.group(2), + new_version=None, + ) + return None + + +def parse_unified_header(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + headers = findall_regex(lines, unified_header_new_line) + if len(headers) == 0: + return None + + while len(lines) > 1: + o = unified_header_old_line.match(lines[0]) + del lines[0] + if o: + n = unified_header_new_line.match(lines[0]) + del lines[0] + if n: + over = o.group(2) + if len(over) == 0: + over = None + + nver = n.group(2) + if len(nver) == 0: + nver = None + + return header( + index_path=None, + old_path=o.group(1), + old_version=over, + new_path=n.group(1), + new_version=nver, + ) + + return None + + +def parse_context_header(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + headers = findall_regex(lines, context_header_old_line) + if len(headers) == 0: + return None + + while len(lines) > 1: + o = context_header_old_line.match(lines[0]) + del lines[0] + if o: + n = context_header_new_line.match(lines[0]) + del lines[0] + if n: + over = o.group(2) + if len(over) == 0: + over = None + + nver = n.group(2) + if len(nver) == 0: + nver = None + + return header( + index_path=None, + old_path=o.group(1), + old_version=over, + new_path=n.group(1), + new_version=nver, + ) + + return None + + +def parse_default_diff(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + old = 0 + new = 0 + old_len = 0 + new_len = 0 + r = 0 + i = 0 + + changes = list() + + hunks = split_by_regex(lines, default_hunk_start) + for hunk_n, hunk in enumerate(hunks): + if not len(hunk): + continue + + r = 0 + i = 0 + while len(hunk) > 0: + h = default_hunk_start.match(hunk[0]) + c = default_change.match(hunk[0]) + del hunk[0] + if h: + old = int(h.group(1)) + if len(h.group(2)) > 0: + old_len = int(h.group(2)) - old + 1 + else: + old_len = 0 + + new = int(h.group(4)) + if len(h.group(5)) > 0: + new_len = int(h.group(5)) - new + 1 + else: + new_len = 0 + + elif c: + kind = c.group(1) + line = c.group(2) + + 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): + changes.append(Change(None, new + i, line, hunk_n)) + i += 1 + + if len(changes) > 0: + return changes + + return None + + +def parse_unified_diff(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + old = 0 + new = 0 + r = 0 + i = 0 + old_len = 0 + new_len = 0 + + changes = list() + + hunks = split_by_regex(lines, unified_hunk_start) + for hunk_n, hunk in enumerate(hunks): + # reset counters + r = 0 + i = 0 + while len(hunk) > 0: + h = unified_hunk_start.match(hunk[0]) + del hunk[0] + if h: + old = int(h.group(1)) + if len(h.group(2)) > 0: + old_len = int(h.group(2)) + else: + old_len = 0 + + new = int(h.group(3)) + if len(h.group(4)) > 0: + new_len = int(h.group(4)) + else: + new_len = 0 + + h = None + break + + for n in hunk: + c = unified_change.match(n) + if c: + kind = c.group(1) + line = c.group(2) + + 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): + changes.append(Change(None, new + i, line, hunk_n)) + i += 1 + elif kind == " ": + if r != old_len and i != new_len: + changes.append(Change(old + r, new + i, line, hunk_n)) + r += 1 + i += 1 + + if len(changes) > 0: + return changes + + return None + + +def parse_context_diff(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + old = 0 + new = 0 + j = 0 + k = 0 + + changes = list() + + hunks = split_by_regex(lines, context_hunk_start) + for hunk_n, hunk in enumerate(hunks): + if not len(hunk): + continue + + j = 0 + k = 0 + parts = split_by_regex(hunk, context_hunk_new) + if len(parts) != 2: + raise exceptions.ParseException("Context diff invalid", hunk_n) + + old_hunk = parts[0] + new_hunk = parts[1] + + while len(old_hunk) > 0: + o = context_hunk_old.match(old_hunk[0]) + del old_hunk[0] + + if not o: + continue + + old = int(o.group(1)) + old_len = int(o.group(2)) + 1 - old + while len(new_hunk) > 0: + n = context_hunk_new.match(new_hunk[0]) + del new_hunk[0] + + if not n: + continue + + new = int(n.group(1)) + new_len = int(n.group(2)) + 1 - new + break + break + + # 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: " + # only removes left? + while len(old_hunk) > 0: + c = context_change.match(old_hunk[0]) + del old_hunk[0] + + if not c: + continue + + kind = c.group(1) + line = c.group(2) + + if kind == "-" and (j != old_len or j == 0): + changes.append(Change(old + j, None, line, hunk_n)) + j += 1 + 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 == "!": + 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: " + # only insertions left? + while len(new_hunk) > 0: + c = context_change.match(new_hunk[0]) + del new_hunk[0] + + if not c: + continue + + kind = c.group(1) + line = c.group(2) + + if kind == "+" and (k != new_len or k == 0): + changes.append(Change(None, new + k, line, hunk_n)) + k += 1 + 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 == "!": + raise exceptions.ParseException(msg + kind, hunk_n) + continue + + # both + while len(old_hunk) > 0 and len(new_hunk) > 0: + oc = context_change.match(old_hunk[0]) + nc = context_change.match(new_hunk[0]) + okind = None + nkind = None + + if oc: + okind = oc.group(1) + oline = oc.group(2) + + if nc: + nkind = nc.group(1) + nline = nc.group(2) + + if not (oc or nc): + del old_hunk[0] + del new_hunk[0] + 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): + 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): + changes.append(Change(None, new + k, nline, hunk_n)) + k += 1 + del new_hunk[0] + else: + return None + + if len(changes) > 0: + return changes + + return None + + +def parse_ed_diff(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + old = 0 + j = 0 + k = 0 + + r = 0 + i = 0 + + changes = list() + + hunks = split_by_regex(lines, ed_hunk_start) + hunks.reverse() + for hunk_n, hunk in enumerate(hunks): + if not len(hunk): + continue + j = 0 + k = 0 + while len(hunk) > 0: + o = ed_hunk_start.match(hunk[0]) + del hunk[0] + + if not o: + continue + + old = int(o.group(1)) + old_end = int(o.group(2)) if len(o.group(2)) else old + + hunk_kind = o.group(3) + if hunk_kind == "d": + k = 0 + while old_end >= old: + changes.append(Change(old + k, None, None, hunk_n)) + r += 1 + k += 1 + old_end -= 1 + continue + + while len(hunk) > 0: + e = ed_hunk_end.match(hunk[0]) + if not e and hunk_kind == "c": + k = 0 + while old_end >= old: + changes.append(Change(old + k, None, None, hunk_n)) + r += 1 + k += 1 + old_end -= 1 + + # I basically have no idea why this works + # for these tests. + changes.append( + Change( + None, + old - r + i + k + j, + hunk[0], + hunk_n, + ) + ) + i += 1 + j += 1 + if not e and hunk_kind == "a": + changes.append( + Change( + None, + old - r + i + 1, + hunk[0], + hunk_n, + ) + ) + i += 1 + + del hunk[0] + + if len(changes) > 0: + return changes + + return None + + +def parse_rcs_ed_diff(text): + # much like forward ed, but no 'c' type + try: + lines = text.splitlines() + except AttributeError: + lines = text + + old = 0 + j = 0 + size = 0 + total_change_size = 0 + + changes = list() + + hunks = split_by_regex(lines, rcs_ed_hunk_start) + for hunk_n, hunk in enumerate(hunks): + if len(hunk): + j = 0 + while len(hunk) > 0: + o = rcs_ed_hunk_start.match(hunk[0]) + del hunk[0] + + if not o: + continue + + hunk_kind = o.group(1) + old = int(o.group(2)) + size = int(o.group(3)) + + if hunk_kind == "a": + old += total_change_size + 1 + total_change_size += size + while size > 0 and len(hunk) > 0: + changes.append(Change(None, old + j, hunk[0], hunk_n)) + j += 1 + size -= 1 + + del hunk[0] + + elif hunk_kind == "d": + total_change_size -= size + while size > 0: + changes.append(Change(old + j, None, None, hunk_n)) + j += 1 + size -= 1 + + if len(changes) > 0: + return changes + + return None + + +def parse_git_binary_diff(text): + try: + lines = text.splitlines() + except AttributeError: + lines = text + + changes: list[Change] = list() + + old_version = None + new_version = None + cmd_old_path = None + cmd_new_path = None + # the sizes are used as latch-up + new_size = 0 + old_size = 0 + 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) + if hm: + cmd_old_path = hm.group(1) + cmd_new_path = hm.group(2) + continue + + if old_version is None and new_version is None: + g = git_header_index.match(line) + if g: + old_version = g.group(1) + new_version = g.group(2) + continue + + # the first is added file + if new_size == 0: + literal = git_binary_literal_start.match(line) + if literal: + new_size = int(literal.group(1)) + continue + delta = git_binary_delta_start.match(line) + if delta: + # not supported + new_size = 0 + continue + elif new_size > 0: + if base85string.match(line): + assert len(line) >= 6 and ((len(line) - 1) % 5) == 0 + new_encoded += line[1:] + elif 0 == len(line): + if new_encoded: + decoded = base64.b85decode(new_encoded) + added_data = zlib.decompress(decoded) + assert new_size == len(added_data) + change = Change(None, 0, added_data, None) + changes.append(change) + new_size = 0 + new_encoded = "" + else: + # Invalid line format + new_size = 0 + new_encoded = "" + + # the second is removed file + if old_size == 0: + literal = git_binary_literal_start.match(line) + if literal: + old_size = int(literal.group(1)) + delta = git_binary_delta_start.match(line) + if delta: + # not supported + old_size = 0 + continue + elif old_size > 0: + if base85string.match(line): + assert len(line) >= 6 and ((len(line) - 1) % 5) == 0 + old_encoded += line[1:] + elif 0 == len(line): + if old_encoded: + decoded = base64.b85decode(old_encoded) + removed_data = zlib.decompress(decoded) + assert old_size == len(removed_data) + change = Change(0, None, None, removed_data) + changes.append(change) + old_size = 0 + old_encoded = "" + else: + # Invalid line format + old_size = 0 + old_encoded = "" + + return changes diff --git a/openhands/resolver/patching/snippets.py b/openhands/resolver/patching/snippets.py new file mode 100644 index 000000000000..710b1191b560 --- /dev/null +++ b/openhands/resolver/patching/snippets.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +import os +from shutil import rmtree + + +def remove(path): + if os.path.exists(path): + if os.path.isdir(path): + rmtree(path) + else: + os.remove(path) + + +# find all indices of a list of strings that match a regex +def findall_regex(items, regex): + found = list() + for i in range(0, len(items)): + k = regex.match(items[i]) + if k: + found.append(i) + k = None + + return found + + +def split_by_regex(items, regex): + splits = list() + indices = findall_regex(items, regex) + if not indices: + splits.append(items) + return splits + + # Add first chunk before first match + splits.append(items[0 : indices[0]]) + + # Add chunks between matches + for i in range(len(indices) - 1): + splits.append(items[indices[i] : indices[i + 1]]) + + # Add final chunk after last match + splits.append(items[indices[-1] :]) + + return splits + + +# http://stackoverflow.com/questions/377017/test-if-executable-exists-in-python +def which(program): + def is_exe(fpath): + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + + fpath, fname = os.path.split(program) + if fpath: + if is_exe(program): + return program + else: + for path in os.environ["PATH"].split(os.pathsep): + path = path.strip('"') + exe_file = os.path.join(path, program) + if is_exe(exe_file): + return exe_file + + return None diff --git a/openhands/resolver/prompts/guess_success/issue-success-check.jinja b/openhands/resolver/prompts/guess_success/issue-success-check.jinja new file mode 100644 index 000000000000..5e882cb731a5 --- /dev/null +++ b/openhands/resolver/prompts/guess_success/issue-success-check.jinja @@ -0,0 +1,18 @@ +Given the following issue description and the last message from an AI agent attempting to fix it, determine if the issue has been successfully resolved. + +Issue description: +{{ issue_context }} + +Last message from AI agent: +{{ last_message }} + +(1) has the issue been successfully resolved? +(2) If the issue has been resolved, please provide an explanation of what was done in the PR that can be sent to a human reviewer on github. If the issue has not been resolved, please provide an explanation of why. + +Answer in exactly the format below, with only true or false for success, and an explanation of the result. + +--- success +true/false + +--- explanation +... diff --git a/openhands/resolver/prompts/guess_success/pr-feedback-check.jinja b/openhands/resolver/prompts/guess_success/pr-feedback-check.jinja new file mode 100644 index 000000000000..19e57d48ca10 --- /dev/null +++ b/openhands/resolver/prompts/guess_success/pr-feedback-check.jinja @@ -0,0 +1,24 @@ +You are given one or more issue descriptions, a piece of feedback to resolve the issues, and the last message from an AI agent attempting to incorporate the feedback. If the feedback is addressed to a specific code file, then the file locations will be provided as well. Determine if the feedback has been successfully resolved. + +Issue descriptions: +{{ issue_context }} + +Feedback: +{{ feedback }} + +Files locations: +{{ files_context }} + +Last message from AI agent: +{{ last_message }} + +(1) has the feedback been successfully incorporated? +(2) If the feedback has been incorporated, please provide an explanation of what was done that can be sent to a human reviewer on github. If the feedback has not been resolved, please provide an explanation of why. + +Answer in exactly the format below, with only true or false for success, and an explanation of the result. + +--- success +true/false + +--- explanation +... diff --git a/openhands/resolver/prompts/guess_success/pr-review-check.jinja b/openhands/resolver/prompts/guess_success/pr-review-check.jinja new file mode 100644 index 000000000000..49a7ef39afc8 --- /dev/null +++ b/openhands/resolver/prompts/guess_success/pr-review-check.jinja @@ -0,0 +1,21 @@ +You are given one or more issue descriptions, the PR review comments, and the last message from an AI agent attempting to address the feedback. Determine if the feedback has been successfully resolved. + +Issue descriptions: +{{ issue_context }} + +PR Review Comments: +{{ review_context }} + +Last message from AI agent: +{{ last_message }} + +(1) has the feedback been successfully incorporated? +(2) If the feedback has been incorporated, please provide an explanation of what was done that can be sent to a human reviewer on github. If the feedback has not been resolved, please provide an explanation of why. + +Answer in exactly the format below, with only true or false for success, and an explanation of the result. + +--- success +true/false + +--- explanation +... diff --git a/openhands/resolver/prompts/guess_success/pr-thread-check.jinja b/openhands/resolver/prompts/guess_success/pr-thread-check.jinja new file mode 100644 index 000000000000..ebe9c242c551 --- /dev/null +++ b/openhands/resolver/prompts/guess_success/pr-thread-check.jinja @@ -0,0 +1,21 @@ +You are given one or more issue descriptions, the PR thread comments, and the last message from an AI agent attempting to address the feedback. Determine if the feedback has been successfully resolved. + +Issue descriptions: +{{ issue_context }} + +PR Thread Comments: +{{ thread_context }} + +Last message from AI agent: +{{ last_message }} + +(1) has the feedback been successfully incorporated? +(2) If the feedback has been incorporated, please provide an explanation of what was done that can be sent to a human reviewer on github. If the feedback has not been resolved, please provide an explanation of why. + +Answer in exactly the format below, with only true or false for success, and an explanation of the result. + +--- success +true/false + +--- explanation +... diff --git a/openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands-resolver.txt b/openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands-resolver.txt new file mode 100644 index 000000000000..ca040d591683 --- /dev/null +++ b/openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands-resolver.txt @@ -0,0 +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` \ No newline at end of file diff --git a/openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands.txt b/openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands.txt new file mode 100644 index 000000000000..015ea101a075 --- /dev/null +++ b/openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands.txt @@ -0,0 +1,7 @@ +OpenHands is an automated AI software engineer. It is a repo with a Python backend +(in the `openhands` directory) and typescript frontend (in the `frontend` directory). + +- Setup: To set up the repo, including frontend/backend you can `make build` +- Backend Testing: All tests are in `tests/unit/test_*.py`. To test new code, you + can do `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate + file for the current functionality. Write all tests with pytest. diff --git a/openhands/resolver/prompts/repo_instructions/rbren___rss-parser.txt b/openhands/resolver/prompts/repo_instructions/rbren___rss-parser.txt new file mode 100644 index 000000000000..b6e8fba1a200 --- /dev/null +++ b/openhands/resolver/prompts/repo_instructions/rbren___rss-parser.txt @@ -0,0 +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. \ No newline at end of file diff --git a/openhands/resolver/prompts/resolve/basic-followup.jinja b/openhands/resolver/prompts/resolve/basic-followup.jinja new file mode 100644 index 000000000000..cf26d80b3bdd --- /dev/null +++ b/openhands/resolver/prompts/resolve/basic-followup.jinja @@ -0,0 +1,24 @@ +The current code is an attempt at fixing one or more issues. The code is not satisfactory and follow up feedback have been provided to address this. +The feedback may be addressed to specific code files. In this case the file locations will be provided. +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 }} + +# Review comments +{{ review_comments }} + +# Review threads +{{ review_threads }} + +# Review thread files +{{ files }} + +IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP. +You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instruction %} + +Some basic information about this repository: +{{ repo_instruction }}{% endif %} + +When you think you have fixed the issue through code changes, please finish the interaction. \ No newline at end of file diff --git a/openhands/resolver/prompts/resolve/basic-with-tests.jinja b/openhands/resolver/prompts/resolve/basic-with-tests.jinja new file mode 100644 index 000000000000..54c35910ec62 --- /dev/null +++ b/openhands/resolver/prompts/resolve/basic-with-tests.jinja @@ -0,0 +1,17 @@ +Please fix the following issue for the repository in /workspace. +An environment has been set up for you to start working. You may assume all necessary tools are installed. + +# Problem Statement +{{ body }} + +IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP. +You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instruction %} + +Some basic information about this repository: +{{ repo_instruction }}{% endif %} + +For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed. +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. \ No newline at end of file diff --git a/openhands/resolver/prompts/resolve/basic.jinja b/openhands/resolver/prompts/resolve/basic.jinja new file mode 100644 index 000000000000..b3bec7ef7f53 --- /dev/null +++ b/openhands/resolver/prompts/resolve/basic.jinja @@ -0,0 +1,13 @@ +Please fix the following issue for the repository in /workspace. +An environment has been set up for you to start working. You may assume all necessary tools are installed. + +# Problem Statement +{{ body }} + +IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP. +You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instruction %} + +Some basic information about this repository: +{{ repo_instruction }}{% endif %} + +When you think you have fixed the issue through code changes, please finish the interaction. \ No newline at end of file diff --git a/openhands/resolver/prompts/resolve/pr-changes-summary.jinja b/openhands/resolver/prompts/resolve/pr-changes-summary.jinja new file mode 100644 index 000000000000..ba9b1bcab4b0 --- /dev/null +++ b/openhands/resolver/prompts/resolve/pr-changes-summary.jinja @@ -0,0 +1,3 @@ +Please create a concise overview of the following changes, commenting on whether all issues have been successfully resolved or if there are still issues remaining: + +{{ comment_message }} diff --git a/openhands/resolver/resolve_all_issues.py b/openhands/resolver/resolve_all_issues.py new file mode 100644 index 000000000000..a561b24a61a7 --- /dev/null +++ b/openhands/resolver/resolve_all_issues.py @@ -0,0 +1,387 @@ +# flake8: noqa: E501 + +import argparse +import asyncio +import multiprocessing as mp +import os +import pathlib +import subprocess +from typing import Awaitable, TextIO + +from tqdm import tqdm + +import openhands +from openhands.core.config import LLMConfig +from openhands.core.logger import openhands_logger as logger +from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.resolve_issue import ( + issue_handler_factory, + process_issue, +) +from openhands.resolver.resolver_output import ResolverOutput + + +def cleanup(): + print('Cleaning up child processes...') + for process in mp.active_children(): + print(f'Terminating child process: {process.name}') + process.terminate() + process.join() + + +# This function tracks the progress AND write the output to a JSONL file +async def update_progress( + output: Awaitable[ResolverOutput], output_fp: TextIO, pbar: tqdm +) -> None: + resolved_output = await output + pbar.update(1) + pbar.set_description(f'issue {resolved_output.issue.number}') + pbar.set_postfix_str( + f'Test Result: {resolved_output.metrics.get("test_result", "N/A") if resolved_output.metrics else "N/A"}' + ) + logger.info( + f'Finished issue {resolved_output.issue.number}: {resolved_output.metrics.get("test_result", "N/A") if resolved_output.metrics else "N/A"}' + ) + output_fp.write(resolved_output.model_dump_json() + '\n') + output_fp.flush() + + +async def resolve_issues( + owner: str, + repo: str, + token: str, + username: str, + max_iterations: int, + limit_issues: int | None, + num_workers: int, + output_dir: str, + llm_config: LLMConfig, + runtime_container_image: str, + prompt_template: str, + issue_type: str, + repo_instruction: str | None, + issue_numbers: list[int] | None, +) -> None: + """Resolve multiple github issues. + + Args: + owner: Github owner of the repo. + repo: Github repository to resolve issues in form of `owner/repo`. + token: Github token to access the repository. + username: Github username to access the repository. + max_iterations: Maximum number of iterations to run. + limit_issues: Limit the number of issues to resolve. + num_workers: Number of workers to use for parallel processing. + output_dir: Output directory to write the results. + llm_config: Configuration for the language model. + runtime_container_image: Container image to use. + prompt_template: Prompt template to use. + issue_type: Type of issue to resolve (issue or pr). + repo_instruction: Repository instruction to use. + issue_numbers: List of issue numbers to resolve. + """ + issue_handler = issue_handler_factory(issue_type, owner, repo, token) + + # Load dataset + issues: list[GithubIssue] = issue_handler.get_converted_issues() + + if issue_numbers is not None: + issues = [issue for issue in issues if issue.number in issue_numbers] + logger.info(f'Limiting resolving to issues {issue_numbers}.') + if limit_issues is not None: + issues = issues[:limit_issues] + logger.info(f'Limiting resolving to first {limit_issues} issues.') + + # TEST METADATA + model_name = llm_config.model.split('/')[-1] + + pathlib.Path(output_dir).mkdir(parents=True, exist_ok=True) + pathlib.Path(os.path.join(output_dir, 'infer_logs')).mkdir( + parents=True, exist_ok=True + ) + logger.info(f'Using output directory: {output_dir}') + + # checkout the repo + repo_dir = os.path.join(output_dir, 'repo') + if not os.path.exists(repo_dir): + checkout_output = subprocess.check_output( + [ + 'git', + 'clone', + f'https://{username}:{token}@github.com/{owner}/{repo}', + f'{output_dir}/repo', + ] + ).decode('utf-8') + if 'fatal' in checkout_output: + raise RuntimeError(f'Failed to clone repository: {checkout_output}') + + # get the commit id of current repo for reproducibility + base_commit = ( + subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) + .decode('utf-8') + .strip() + ) + logger.info(f'Base commit: {base_commit}') + + if repo_instruction is None: + # Check for .openhands_instructions file in the workspace directory + openhands_instructions_path = os.path.join(repo_dir, '.openhands_instructions') + if os.path.exists(openhands_instructions_path): + with open(openhands_instructions_path, 'r') as f: + repo_instruction = f.read() + + # OUTPUT FILE + output_file = os.path.join(output_dir, 'output.jsonl') + logger.info(f'Writing output to {output_file}') + finished_numbers = set() + if os.path.exists(output_file): + with open(output_file, 'r') as f: + for line in f: + data = ResolverOutput.model_validate_json(line) + finished_numbers.add(data.issue.number) + logger.warning( + f'Output file {output_file} already exists. Loaded {len(finished_numbers)} finished issues.' + ) + output_fp = open(output_file, 'a') + + logger.info( + f'Resolving issues with model {model_name}, max iterations {max_iterations}.' + ) + + # ============================================= + # filter out finished issues + new_issues = [] + for issue in issues: + if issue.number in finished_numbers: + logger.info(f'Skipping issue {issue.number} as it is already finished.') + continue + new_issues.append(issue) + logger.info( + f'Finished issues: {len(finished_numbers)}, Remaining issues: {len(issues)}' + ) + # ============================================= + + pbar = tqdm(total=len(issues)) + + # This sets the multi-processing + logger.info(f'Using {num_workers} workers.') + + try: + tasks = [] + for issue in issues: + # checkout to pr branch + if issue_type == 'pr': + logger.info( + f'Checking out to PR branch {issue.head_branch} for issue {issue.number}' + ) + + subprocess.check_output( + ['git', 'checkout', f'{issue.head_branch}'], + cwd=repo_dir, + ) + + base_commit = ( + subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) + .decode('utf-8') + .strip() + ) + + task = update_progress( + process_issue( + issue, + base_commit, + max_iterations, + llm_config, + output_dir, + runtime_container_image, + prompt_template, + issue_handler, + repo_instruction, + bool(num_workers > 1), + ), + output_fp, + pbar, + ) + tasks.append(task) + + # Use asyncio.gather with a semaphore to limit concurrency + sem = asyncio.Semaphore(num_workers) + + async def run_with_semaphore(task): + async with sem: + return await task + + await asyncio.gather(*[run_with_semaphore(task) for task in tasks]) + + except KeyboardInterrupt: + print('KeyboardInterrupt received. Cleaning up...') + cleanup() + + output_fp.close() + logger.info('Finished.') + + +def main(): + parser = argparse.ArgumentParser(description='Resolve multiple issues from Github.') + parser.add_argument( + '--repo', + type=str, + required=True, + help='Github repository to resolve issues in form of `owner/repo`.', + ) + parser.add_argument( + '--token', + type=str, + default=None, + help='Github token to access the repository.', + ) + parser.add_argument( + '--username', + type=str, + default=None, + help='Github username to access the repository.', + ) + parser.add_argument( + '--runtime-container-image', + type=str, + default=None, + help='Container image to use.', + ) + parser.add_argument( + '--max-iterations', + type=int, + default=50, + help='Maximum number of iterations to run.', + ) + parser.add_argument( + '--limit-issues', + type=int, + default=None, + help='Limit the number of issues to resolve.', + ) + parser.add_argument( + '--issue-numbers', + type=str, + default=None, + help='Comma separated list of issue numbers to resolve.', + ) + parser.add_argument( + '--num-workers', + type=int, + default=1, + help='Number of workers to use for parallel processing.', + ) + parser.add_argument( + '--output-dir', + type=str, + default='output', + help='Output directory to write the results.', + ) + parser.add_argument( + '--llm-model', + type=str, + default=None, + help='LLM model to use.', + ) + parser.add_argument( + '--llm-api-key', + type=str, + default=None, + help='LLM API key to use.', + ) + parser.add_argument( + '--llm-base-url', + type=str, + default=None, + help='LLM base URL to use.', + ) + parser.add_argument( + '--prompt-file', + type=str, + default=None, + help='Path to the prompt template file in Jinja format.', + ) + parser.add_argument( + '--repo-instruction-file', + type=str, + default=None, + help='Path to the repository instruction file in text format.', + ) + parser.add_argument( + '--issue-type', + type=str, + default='issue', + choices=['issue', 'pr'], + help='Type of issue to resolve, either open issue or pr comments.', + ) + + my_args = parser.parse_args() + + runtime_container_image = my_args.runtime_container_image + if runtime_container_image is None: + runtime_container_image = ( + f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik' + ) + + owner, repo = my_args.repo.split('/') + token = my_args.token if my_args.token else os.getenv('GITHUB_TOKEN') + username = my_args.username if my_args.username else os.getenv('GITHUB_USERNAME') + if not username: + raise ValueError('Github username is required.') + + if not token: + raise ValueError('Github token is required.') + + llm_config = LLMConfig( + model=my_args.llm_model or os.environ['LLM_MODEL'], + api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'], + base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None), + ) + + repo_instruction = None + if my_args.repo_instruction_file: + with open(my_args.repo_instruction_file, 'r') as f: + repo_instruction = f.read() + + issue_numbers = None + if my_args.issue_numbers: + issue_numbers = [int(number) for number in my_args.issue_numbers.split(',')] + + issue_type = my_args.issue_type + + # Read the prompt template + prompt_file = my_args.prompt_file + if prompt_file is None: + if issue_type == 'issue': + prompt_file = os.path.join( + os.path.dirname(__file__), 'prompts/resolve/basic-with-tests.jinja' + ) + else: + prompt_file = os.path.join( + os.path.dirname(__file__), 'prompts/resolve/basic-followup.jinja' + ) + with open(prompt_file, 'r') as f: + prompt_template = f.read() + + asyncio.run( + resolve_issues( + owner=owner, + repo=repo, + token=token, + username=username, + runtime_container_image=runtime_container_image, + max_iterations=my_args.max_iterations, + limit_issues=my_args.limit_issues, + num_workers=my_args.num_workers, + output_dir=my_args.output_dir, + llm_config=llm_config, + prompt_template=prompt_template, + issue_type=issue_type, + repo_instruction=repo_instruction, + issue_numbers=issue_numbers, + ) + ) + + +if __name__ == '__main__': + main() diff --git a/openhands/resolver/resolve_issue.py b/openhands/resolver/resolve_issue.py new file mode 100644 index 000000000000..67eb20bee1e0 --- /dev/null +++ b/openhands/resolver/resolve_issue.py @@ -0,0 +1,624 @@ +# flake8: noqa: E501 + +import asyncio +import dataclasses +import json +import os +import pathlib +import shutil +import subprocess +from typing import Any +from uuid import uuid4 + +from termcolor import colored + +import openhands +from openhands.controller.state.state import State +from openhands.core.config import ( + AgentConfig, + AppConfig, + LLMConfig, + SandboxConfig, +) +from openhands.core.logger import openhands_logger as logger +from openhands.core.main import create_runtime, run_controller +from openhands.events.action import CmdRunAction, MessageAction +from openhands.events.observation import ( + CmdOutputObservation, + ErrorObservation, + Observation, +) +from openhands.events.stream import EventStreamSubscriber +from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.issue_definitions import ( + IssueHandler, + IssueHandlerInterface, + PRHandler, +) +from openhands.resolver.resolver_output import ResolverOutput +from openhands.resolver.utils import ( + codeact_user_response, + reset_logger_for_multiprocessing, +) +from openhands.runtime.base import Runtime + +# Don't make this confgurable for now, unless we have other competitive agents +AGENT_CLASS = 'CodeActAgent' + + +def initialize_runtime( + runtime: Runtime, +): + """Initialize the runtime for the agent. + + This function is called before the runtime is used to run the agent. + Currently it does nothing. + """ + logger.info('-' * 30) + logger.info('BEGIN Runtime Completion Fn') + logger.info('-' * 30) + obs: Observation + + action = CmdRunAction(command='cd /workspace') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: + raise RuntimeError(f'Failed to change directory to /workspace.\n{obs}') + + action = CmdRunAction(command='git config --global core.pager ""') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: + raise RuntimeError(f'Failed to set git config.\n{obs}') + + +async def complete_runtime( + runtime: Runtime, + base_commit: str, +) -> dict[str, Any]: + """Complete the runtime for the agent. + + This function is called before the runtime is used to run the agent. + If you need to do something in the sandbox to get the correctness metric after + the agent has run, modify this function. + """ + logger.info('-' * 30) + logger.info('BEGIN Runtime Completion Fn') + logger.info('-' * 30) + obs: Observation + + action = CmdRunAction(command='cd /workspace') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: + raise RuntimeError( + f'Failed to change directory to /workspace. Observation: {obs}' + ) + + action = CmdRunAction(command='git config --global core.pager ""') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: + raise RuntimeError(f'Failed to set git config. Observation: {obs}') + + action = CmdRunAction(command='git config --global --add safe.directory /workspace') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: + raise RuntimeError(f'Failed to set git config. Observation: {obs}') + + action = CmdRunAction(command='git add -A') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: + raise RuntimeError(f'Failed to git add. Observation: {obs}') + + n_retries = 0 + git_patch = None + while n_retries < 5: + action = CmdRunAction( + command=f'git diff --no-color --cached {base_commit}', + keep_prompt=False, + ) + action.timeout = 600 + 100 * n_retries + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + n_retries += 1 + if isinstance(obs, CmdOutputObservation): + if obs.exit_code == 0: + git_patch = obs.content.strip() + break + else: + logger.info('Failed to get git diff, retrying...') + await asyncio.sleep(10) + elif isinstance(obs, ErrorObservation): + logger.error(f'Error occurred: {obs.content}. Retrying...') + await asyncio.sleep(10) + else: + raise ValueError(f'Unexpected observation type: {type(obs)}') + + logger.info('-' * 30) + logger.info('END Runtime Completion Fn') + logger.info('-' * 30) + return {'git_patch': git_patch} + + +async def process_issue( + issue: GithubIssue, + base_commit: str, + max_iterations: int, + llm_config: LLMConfig, + output_dir: str, + runtime_container_image: str, + prompt_template: str, + issue_handler: IssueHandlerInterface, + repo_instruction: str | None = None, + reset_logger: bool = False, +) -> ResolverOutput: + # Setup the logger properly, so you can run multi-processing to parallelize processing + if reset_logger: + log_dir = os.path.join(output_dir, 'infer_logs') + reset_logger_for_multiprocessing(logger, str(issue.number), log_dir) + else: + logger.info(f'Starting fixing issue {issue.number}.') + + workspace_base = os.path.join( + output_dir, 'workspace', f'{issue_handler.issue_type}_{issue.number}' + ) + + # Get the absolute path of the workspace base + workspace_base = os.path.abspath(workspace_base) + # write the repo to the workspace + if os.path.exists(workspace_base): + shutil.rmtree(workspace_base) + shutil.copytree(os.path.join(output_dir, 'repo'), workspace_base) + + config = AppConfig( + default_agent='CodeActAgent', + runtime='eventstream', + max_budget_per_task=4, + max_iterations=max_iterations, + sandbox=SandboxConfig( + runtime_container_image=runtime_container_image, + enable_auto_lint=False, + use_host_network=False, + # large enough timeout, since some testcases take very long to run + timeout=300, + ), + # do not mount workspace + workspace_base=workspace_base, + workspace_mount_path=workspace_base, + agents={'CodeActAgent': AgentConfig(disabled_microagents=['github'])}, + ) + config.set_llm_config(llm_config) + + runtime = create_runtime(config, sid=f'{issue.number}') + await runtime.connect() + + async def on_event(evt): + logger.info(evt) + + runtime.event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, str(uuid4())) + + initialize_runtime(runtime) + + instruction, images_urls = issue_handler.get_instruction( + issue, prompt_template, repo_instruction + ) + # Here's how you can run the agent (similar to the `main` function) and get the final task state + action = MessageAction(content=instruction, image_urls=images_urls) + try: + state: State | None = await run_controller( + config=config, + initial_user_action=action, + runtime=runtime, + fake_user_response_fn=codeact_user_response, + ) + if state is None: + raise RuntimeError('Failed to run the agent.') + except (ValueError, RuntimeError) as e: + error_msg = f'Agent failed with error: {str(e)}' + logger.error(error_msg) + state = None + last_error: str | None = error_msg + + # Get git patch + return_val = await complete_runtime(runtime, base_commit) + git_patch = return_val['git_patch'] + logger.info( + f'Got git diff for instance {issue.number}:\n--------\n{git_patch}\n--------' + ) + + # Serialize histories and set defaults for failed state + if state is None: + histories = [] + metrics = None + success = False + comment_success = None + success_explanation = 'Agent failed to run' + last_error = 'Agent failed to run or crashed' + else: + histories = [dataclasses.asdict(event) for event in state.history] + metrics = state.metrics.get() if state.metrics else None + # determine success based on the history and the issue description + success, comment_success, success_explanation = issue_handler.guess_success( + issue, state.history, llm_config + ) + + if issue_handler.issue_type == 'pr' and comment_success: + success_log = 'I have updated the PR and resolved some of the issues that were cited in the pull request review. Specifically, I identified the following revision requests, and all the ones that I think I successfully resolved are checked off. All the unchecked ones I was not able to resolve, so manual intervention may be required:\n' + try: + explanations = json.loads(success_explanation) + except json.JSONDecodeError: + logger.error( + f'Failed to parse success_explanation as JSON: {success_explanation}' + ) + explanations = [str(success_explanation)] # Use raw string as fallback + + for success_indicator, explanation in zip(comment_success, explanations): + status = ( + colored('[X]', 'red') + if success_indicator + else colored('[ ]', 'red') + ) + bullet_point = colored('-', 'yellow') + success_log += f'\n{bullet_point} {status}: {explanation}' + logger.info(success_log) + last_error = state.last_error if state.last_error else None + + # Save the output + output = ResolverOutput( + issue=issue, + issue_type=issue_handler.issue_type, + instruction=instruction, + base_commit=base_commit, + git_patch=git_patch, + history=histories, + metrics=metrics, + success=success, + comment_success=comment_success, + success_explanation=success_explanation, + error=last_error, + ) + return output + + +def issue_handler_factory( + issue_type: str, owner: str, repo: str, token: str +) -> IssueHandlerInterface: + if issue_type == 'issue': + return IssueHandler(owner, repo, token) + elif issue_type == 'pr': + return PRHandler(owner, repo, token) + else: + raise ValueError(f'Invalid issue type: {issue_type}') + + +async def resolve_issue( + owner: str, + repo: str, + token: str, + username: str, + max_iterations: int, + output_dir: str, + llm_config: LLMConfig, + runtime_container_image: str, + prompt_template: str, + issue_type: str, + repo_instruction: str | None, + issue_number: int, + comment_id: int | None, + reset_logger: bool = False, +) -> None: + """Resolve a single github issue. + + Args: + owner: Github owner of the repo. + repo: Github repository to resolve issues in form of `owner/repo`. + token: Github token to access the repository. + username: Github username to access the repository. + max_iterations: Maximum number of iterations to run. + output_dir: Output directory to write the results. + llm_config: Configuration for the language model. + runtime_container_image: Container image to use. + prompt_template: Prompt template to use. + issue_type: Type of issue to resolve (issue or pr). + repo_instruction: Repository instruction to use. + issue_number: Issue number to resolve. + comment_id: Optional ID of a specific comment to focus on. + reset_logger: Whether to reset the logger for multiprocessing. + """ + issue_handler = issue_handler_factory(issue_type, owner, repo, token) + + # Load dataset + issues: list[GithubIssue] = issue_handler.get_converted_issues( + comment_id=comment_id + ) + + # Find the specific issue + issue = next((i for i in issues if i.number == issue_number), None) + if not issue: + raise ValueError(f'Issue {issue_number} not found') + + if comment_id is not None: + if ( + issue_type == 'pr' + and not issue.review_comments + and not issue.review_threads + and not issue.thread_comments + ): + raise ValueError( + f'Comment ID {comment_id} did not have a match for issue {issue.number}' + ) + + if issue_type == 'issue' and not issue.thread_comments: + raise ValueError( + f'Comment ID {comment_id} did not have a match for issue {issue.number}' + ) + + # TEST METADATA + model_name = llm_config.model.split('/')[-1] + + pathlib.Path(output_dir).mkdir(parents=True, exist_ok=True) + pathlib.Path(os.path.join(output_dir, 'infer_logs')).mkdir( + parents=True, exist_ok=True + ) + logger.info(f'Using output directory: {output_dir}') + + # checkout the repo + repo_dir = os.path.join(output_dir, 'repo') + if not os.path.exists(repo_dir): + checkout_output = subprocess.check_output( + [ + 'git', + 'clone', + f'https://{username}:{token}@github.com/{owner}/{repo}', + f'{output_dir}/repo', + ] + ).decode('utf-8') + if 'fatal' in checkout_output: + raise RuntimeError(f'Failed to clone repository: {checkout_output}') + + # get the commit id of current repo for reproducibility + base_commit = ( + subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) + .decode('utf-8') + .strip() + ) + logger.info(f'Base commit: {base_commit}') + + if repo_instruction is None: + # Check for .openhands_instructions file in the workspace directory + openhands_instructions_path = os.path.join(repo_dir, '.openhands_instructions') + if os.path.exists(openhands_instructions_path): + with open(openhands_instructions_path, 'r') as f: + repo_instruction = f.read() + + # OUTPUT FILE + output_file = os.path.join(output_dir, 'output.jsonl') + logger.info(f'Writing output to {output_file}') + + # Check if this issue was already processed + if os.path.exists(output_file): + with open(output_file, 'r') as f: + for line in f: + data = ResolverOutput.model_validate_json(line) + if data.issue.number == issue_number: + logger.warning( + f'Issue {issue_number} was already processed. Skipping.' + ) + return + + output_fp = open(output_file, 'a') + + logger.info( + f'Resolving issue {issue_number} with Agent {AGENT_CLASS}, model {model_name}, max iterations {max_iterations}.' + ) + + try: + # checkout to pr branch if needed + if issue_type == 'pr': + logger.info( + f'Checking out to PR branch {issue.head_branch} for issue {issue.number}' + ) + + subprocess.check_output( + ['git', 'checkout', f'{issue.head_branch}'], + cwd=repo_dir, + ) + + base_commit = ( + subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) + .decode('utf-8') + .strip() + ) + + output = await process_issue( + issue, + base_commit, + max_iterations, + llm_config, + output_dir, + runtime_container_image, + prompt_template, + issue_handler, + repo_instruction, + reset_logger, + ) + output_fp.write(output.model_dump_json() + '\n') + output_fp.flush() + + finally: + output_fp.close() + logger.info('Finished.') + + +def main(): + import argparse + + def int_or_none(value): + if value.lower() == 'none': + return None + else: + return int(value) + + parser = argparse.ArgumentParser(description='Resolve a single issue from Github.') + parser.add_argument( + '--repo', + type=str, + required=True, + help='Github repository to resolve issues in form of `owner/repo`.', + ) + parser.add_argument( + '--token', + type=str, + default=None, + help='Github token to access the repository.', + ) + parser.add_argument( + '--username', + type=str, + default=None, + help='Github username to access the repository.', + ) + parser.add_argument( + '--runtime-container-image', + type=str, + default=None, + help='Container image to use.', + ) + parser.add_argument( + '--max-iterations', + type=int, + default=50, + help='Maximum number of iterations to run.', + ) + parser.add_argument( + '--issue-number', + type=int, + required=True, + help='Issue number to resolve.', + ) + parser.add_argument( + '--comment-id', + type=int_or_none, + required=False, + default=None, + help='Resolve a specific comment', + ) + parser.add_argument( + '--output-dir', + type=str, + default='output', + help='Output directory to write the results.', + ) + parser.add_argument( + '--llm-model', + type=str, + default=None, + help='LLM model to use.', + ) + parser.add_argument( + '--llm-api-key', + type=str, + default=None, + help='LLM API key to use.', + ) + parser.add_argument( + '--llm-base-url', + type=str, + default=None, + help='LLM base URL to use.', + ) + parser.add_argument( + '--prompt-file', + type=str, + default=None, + help='Path to the prompt template file in Jinja format.', + ) + parser.add_argument( + '--repo-instruction-file', + type=str, + default=None, + help='Path to the repository instruction file in text format.', + ) + parser.add_argument( + '--issue-type', + type=str, + default='issue', + choices=['issue', 'pr'], + help='Type of issue to resolve, either open issue or pr comments.', + ) + + my_args = parser.parse_args() + + runtime_container_image = my_args.runtime_container_image + if runtime_container_image is None: + runtime_container_image = ( + f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik' + ) + + owner, repo = my_args.repo.split('/') + token = my_args.token if my_args.token else os.getenv('GITHUB_TOKEN') + username = my_args.username if my_args.username else os.getenv('GITHUB_USERNAME') + if not username: + raise ValueError('Github username is required.') + + if not token: + raise ValueError('Github token is required.') + + llm_config = LLMConfig( + model=my_args.llm_model or os.environ['LLM_MODEL'], + api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'], + base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None), + ) + + repo_instruction = None + if my_args.repo_instruction_file: + with open(my_args.repo_instruction_file, 'r') as f: + repo_instruction = f.read() + + issue_type = my_args.issue_type + + # Read the prompt template + prompt_file = my_args.prompt_file + if prompt_file is None: + if issue_type == 'issue': + prompt_file = os.path.join( + os.path.dirname(__file__), 'prompts/resolve/basic-with-tests.jinja' + ) + else: + prompt_file = os.path.join( + os.path.dirname(__file__), 'prompts/resolve/basic-followup.jinja' + ) + with open(prompt_file, 'r') as f: + prompt_template = f.read() + + asyncio.run( + resolve_issue( + owner=owner, + repo=repo, + token=token, + username=username, + runtime_container_image=runtime_container_image, + max_iterations=my_args.max_iterations, + output_dir=my_args.output_dir, + llm_config=llm_config, + prompt_template=prompt_template, + issue_type=issue_type, + repo_instruction=repo_instruction, + issue_number=my_args.issue_number, + comment_id=my_args.comment_id, + ) + ) + + +if __name__ == '__main__': + main() diff --git a/openhands/resolver/resolver_output.py b/openhands/resolver/resolver_output.py new file mode 100644 index 000000000000..5978620f0359 --- /dev/null +++ b/openhands/resolver/resolver_output.py @@ -0,0 +1,20 @@ +from typing import Any + +from litellm import BaseModel + +from openhands.resolver.github_issue import GithubIssue + + +class ResolverOutput(BaseModel): + # NOTE: User-specified + issue: GithubIssue + issue_type: str + instruction: str + base_commit: str + git_patch: str + history: list[dict[str, Any]] + metrics: dict[str, Any] | None + success: bool + comment_success: list[bool] | None + success_explanation: str + error: str | None diff --git a/openhands/resolver/send_pull_request.py b/openhands/resolver/send_pull_request.py new file mode 100644 index 000000000000..eade7fcfc419 --- /dev/null +++ b/openhands/resolver/send_pull_request.py @@ -0,0 +1,632 @@ +import argparse +import json +import os +import shutil +import subprocess + +import jinja2 +import litellm +import requests + +from openhands.core.config import LLMConfig +from openhands.core.logger import openhands_logger as logger +from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.io_utils import ( + load_all_resolver_outputs, + load_single_resolver_output, +) +from openhands.resolver.patching import apply_diff, parse_patch +from openhands.resolver.resolver_output import ResolverOutput + + +def apply_patch(repo_dir: str, patch: str) -> None: + diffs = parse_patch(patch) + for diff in diffs: + if not diff.header.new_path: + print('Warning: Could not determine file to patch') + continue + + # Remove both "a/" and "b/" prefixes from paths + old_path = ( + os.path.join( + repo_dir, diff.header.old_path.removeprefix('a/').removeprefix('b/') + ) + if diff.header.old_path and diff.header.old_path != '/dev/null' + else None + ) + new_path = os.path.join( + repo_dir, diff.header.new_path.removeprefix('a/').removeprefix('b/') + ) + + # Check if the file is being deleted + if diff.header.new_path == '/dev/null': + assert old_path is not None + if os.path.exists(old_path): + os.remove(old_path) + print(f'Deleted file: {old_path}') + continue + + # Handle file rename + if old_path and new_path and 'rename from' in patch: + # Create parent directory of new path + os.makedirs(os.path.dirname(new_path), exist_ok=True) + try: + # Try to move the file directly + shutil.move(old_path, new_path) + except shutil.SameFileError: + # If it's the same file (can happen with directory renames), copy first then remove + shutil.copy2(old_path, new_path) + os.remove(old_path) + + # Try to remove empty parent directories + old_dir = os.path.dirname(old_path) + while old_dir and old_dir.startswith(repo_dir): + try: + os.rmdir(old_dir) + old_dir = os.path.dirname(old_dir) + except OSError: + # Directory not empty or other error, stop trying to remove parents + break + continue + + if old_path: + # Open the file in binary mode to detect line endings + with open(old_path, 'rb') as f: + original_content = f.read() + + # Detect line endings + if b'\r\n' in original_content: + newline = '\r\n' + elif b'\n' in original_content: + newline = '\n' + else: + newline = None # Let Python decide + + try: + with open(old_path, 'r', newline=newline) as f: + split_content = [x.strip(newline) for x in f.readlines()] + except UnicodeDecodeError as e: + logger.error(f'Error reading file {old_path}: {e}') + split_content = [] + else: + newline = '\n' + split_content = [] + + if diff.changes is None: + print(f'Warning: No changes to apply for {old_path}') + continue + + new_content = apply_diff(diff, split_content) + + # Ensure the directory exists before writing the file + os.makedirs(os.path.dirname(new_path), exist_ok=True) + + # Write the new content using the detected line endings + with open(new_path, 'w', newline=newline) as f: + for line in new_content: + print(line, file=f) + + print('Patch applied successfully') + + +def initialize_repo( + output_dir: str, issue_number: int, issue_type: str, base_commit: str | None = None +) -> str: + src_dir = os.path.join(output_dir, 'repo') + dest_dir = os.path.join(output_dir, 'patches', f'{issue_type}_{issue_number}') + + if not os.path.exists(src_dir): + raise ValueError(f'Source directory {src_dir} does not exist.') + + if os.path.exists(dest_dir): + shutil.rmtree(dest_dir) + + shutil.copytree(src_dir, dest_dir) + print(f'Copied repository to {dest_dir}') + + if base_commit: + result = subprocess.run( + f'git -C {dest_dir} checkout {base_commit}', + shell=True, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f'Error checking out commit: {result.stderr}') + raise RuntimeError('Failed to check out commit') + + return dest_dir + + +def make_commit(repo_dir: str, issue: GithubIssue, issue_type: str) -> None: + # Check if git username is set + result = subprocess.run( + f'git -C {repo_dir} config user.name', + shell=True, + capture_output=True, + text=True, + ) + + if not result.stdout.strip(): + # If username is not set, configure git + subprocess.run( + f'git -C {repo_dir} config user.name "openhands" && ' + f'git -C {repo_dir} config user.email "openhands@all-hands.dev" && ' + f'git -C {repo_dir} config alias.git "git --no-pager"', + shell=True, + check=True, + ) + print('Git user configured as openhands') + + result = subprocess.run( + f'git -C {repo_dir} add .', shell=True, capture_output=True, text=True + ) + if result.returncode != 0: + print(f'Error adding files: {result.stderr}') + raise RuntimeError('Failed to add files to git') + + status_result = subprocess.run( + f'git -C {repo_dir} status --porcelain', + shell=True, + capture_output=True, + text=True, + ) + + if not status_result.stdout.strip(): + print(f'No changes to commit for issue #{issue.number}. Skipping commit.') + raise RuntimeError('ERROR: Openhands failed to make code changes.') + + commit_message = f'Fix {issue_type} #{issue.number}: {issue.title}' + result = subprocess.run( + ['git', '-C', repo_dir, 'commit', '-m', commit_message], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f'Failed to commit changes: {result}') + + +def branch_exists(base_url: str, branch_name: str, headers: dict) -> bool: + print(f'Checking if branch {branch_name} exists...') + response = requests.get(f'{base_url}/branches/{branch_name}', headers=headers) + exists = response.status_code == 200 + print(f'Branch {branch_name} exists: {exists}') + return exists + + +def send_pull_request( + github_issue: GithubIssue, + github_token: str, + github_username: str | None, + patch_dir: str, + llm_config: LLMConfig, + pr_type: str, + fork_owner: str | None = None, + additional_message: str | None = None, +) -> str: + if pr_type not in ['branch', 'draft', 'ready']: + raise ValueError(f'Invalid pr_type: {pr_type}') + + # Set up headers and base URL for GitHub API + headers = { + 'Authorization': f'token {github_token}', + 'Accept': 'application/vnd.github.v3+json', + } + base_url = f'https://api.github.com/repos/{github_issue.owner}/{github_issue.repo}' + + # Create a new branch with a unique name + base_branch_name = f'openhands-fix-issue-{github_issue.number}' + branch_name = base_branch_name + attempt = 1 + + print('Checking if branch exists...') + while branch_exists(base_url, branch_name, headers): + attempt += 1 + branch_name = f'{base_branch_name}-try{attempt}' + + # Get the default branch + print('Getting default branch...') + response = requests.get(f'{base_url}', headers=headers) + response.raise_for_status() + default_branch = response.json()['default_branch'] + print(f'Default branch: {default_branch}') + + # Create and checkout the new branch + print('Creating new branch...') + result = subprocess.run( + ['git', '-C', patch_dir, 'checkout', '-b', branch_name], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f'Error creating new branch: {result.stderr}') + raise RuntimeError( + f'Failed to create a new branch {branch_name} in {patch_dir}:' + ) + + # Determine the repository to push to (original or fork) + push_owner = fork_owner if fork_owner else github_issue.owner + push_repo = github_issue.repo + + print('Pushing changes...') + username_and_token = ( + f'{github_username}:{github_token}' + if github_username + else f'x-auth-token:{github_token}' + ) + push_url = f'https://{username_and_token}@github.com/{push_owner}/{push_repo}.git' + result = subprocess.run( + ['git', '-C', patch_dir, 'push', push_url, branch_name], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f'Error pushing changes: {result.stderr}') + raise RuntimeError('Failed to push changes to the remote repository') + + pr_title = f'Fix issue #{github_issue.number}: {github_issue.title}' + pr_body = f'This pull request fixes #{github_issue.number}.' + if additional_message: + pr_body += f'\n\n{additional_message}' + pr_body += '\n\nAutomatic fix generated by [OpenHands](https://github.com/All-Hands-AI/OpenHands/) 🙌' + + # If we are not sending a PR, we can finish early and return the + # URL for the user to open a PR manually + if pr_type == 'branch': + url = f'https://github.com/{push_owner}/{github_issue.repo}/compare/{branch_name}?expand=1' + else: + data = { + 'title': pr_title, # No need to escape title for GitHub API + 'body': pr_body, + 'head': branch_name, + 'base': default_branch, + 'draft': pr_type == 'draft', + } + response = requests.post(f'{base_url}/pulls', headers=headers, json=data) + if response.status_code == 403: + raise RuntimeError( + 'Failed to create pull request due to missing permissions. ' + 'Make sure that the provided token has push permissions for the repository.' + ) + response.raise_for_status() + pr_data = response.json() + + url = pr_data['html_url'] + + print(f'{pr_type} created: {url}\n\n--- Title: {pr_title}\n\n--- Body:\n{pr_body}') + + return url + + +def reply_to_comment(github_token: str, comment_id: str, reply: str): + # Opting for graphql as REST API doesn't allow reply to replies in comment threads + query = """ + mutation($body: String!, $pullRequestReviewThreadId: ID!) { + addPullRequestReviewThreadReply(input: { body: $body, pullRequestReviewThreadId: $pullRequestReviewThreadId }) { + comment { + id + body + createdAt + } + } + } + """ + + comment_reply = f'Openhands fix success summary\n\n\n{reply}' + variables = {'body': comment_reply, 'pullRequestReviewThreadId': comment_id} + url = 'https://api.github.com/graphql' + headers = { + 'Authorization': f'Bearer {github_token}', + 'Content-Type': 'application/json', + } + + response = requests.post( + url, json={'query': query, 'variables': variables}, headers=headers + ) + response.raise_for_status() + + +def update_existing_pull_request( + github_issue: GithubIssue, + github_token: str, + github_username: str | None, + patch_dir: str, + llm_config: LLMConfig, + comment_message: str | None = None, + additional_message: str | None = None, +) -> str: + """Update an existing pull request with the new patches. + + Args: + github_issue: The issue to update. + github_token: The GitHub token to use for authentication. + github_username: The GitHub username to use for authentication. + patch_dir: The directory containing the patches to apply. + llm_config: The LLM configuration to use for summarizing changes. + comment_message: The main message to post as a comment on the PR. + additional_message: The additional messages to post as a comment on the PR in json list format. + """ + # Set up headers and base URL for GitHub API + headers = { + 'Authorization': f'token {github_token}', + 'Accept': 'application/vnd.github.v3+json', + } + base_url = f'https://api.github.com/repos/{github_issue.owner}/{github_issue.repo}' + branch_name = github_issue.head_branch + + # Push the changes to the existing branch + push_command = ( + f'git -C {patch_dir} push ' + f'https://{github_username}:{github_token}@github.com/' + f'{github_issue.owner}/{github_issue.repo}.git {branch_name}' + ) + + result = subprocess.run(push_command, shell=True, capture_output=True, text=True) + if result.returncode != 0: + print(f'Error pushing changes: {result.stderr}') + raise RuntimeError('Failed to push changes to the remote repository') + + pr_url = f'https://github.com/{github_issue.owner}/{github_issue.repo}/pull/{github_issue.number}' + print(f'Updated pull request {pr_url} with new patches.') + + # Generate a summary of all comment success indicators for PR message + if not comment_message and additional_message: + try: + explanations = json.loads(additional_message) + if explanations: + comment_message = ( + 'OpenHands made the following changes to resolve the issues:\n\n' + ) + for explanation in explanations: + comment_message += f'- {explanation}\n' + + # Summarize with LLM if provided + if llm_config is not None: + with open( + os.path.join( + os.path.dirname(__file__), + 'prompts/resolve/pr-changes-summary.jinja', + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + prompt = template.render(comment_message=comment_message) + response = litellm.completion( + model=llm_config.model, + messages=[{'role': 'user', 'content': prompt}], + api_key=llm_config.api_key, + base_url=llm_config.base_url, + ) + comment_message = response.choices[0].message.content.strip() + + except (json.JSONDecodeError, TypeError): + comment_message = 'New OpenHands update' + + # Post a comment on the PR + if comment_message: + comment_url = f'{base_url}/issues/{github_issue.number}/comments' + comment_data = {'body': comment_message} + comment_response = requests.post( + comment_url, headers=headers, json=comment_data + ) + if comment_response.status_code != 201: + print( + f'Failed to post comment: {comment_response.status_code} {comment_response.text}' + ) + else: + print(f'Comment added to the PR: {comment_message}') + + # Reply to each unresolved comment thread + if additional_message and github_issue.thread_ids: + explanations = json.loads(additional_message) + for count, reply_comment in enumerate(explanations): + comment_id = github_issue.thread_ids[count] + reply_to_comment(github_token, comment_id, reply_comment) + + return pr_url + + +def process_single_issue( + output_dir: str, + resolver_output: ResolverOutput, + github_token: str, + github_username: str, + pr_type: str, + llm_config: LLMConfig, + fork_owner: str | None, + send_on_failure: bool, +) -> 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 + + issue_type = resolver_output.issue_type + + if issue_type == 'issue': + patched_repo_dir = initialize_repo( + output_dir, + resolver_output.issue.number, + issue_type, + resolver_output.base_commit, + ) + elif issue_type == 'pr': + patched_repo_dir = initialize_repo( + output_dir, + resolver_output.issue.number, + issue_type, + resolver_output.issue.head_branch, + ) + else: + raise ValueError(f'Invalid issue type: {issue_type}') + + apply_patch(patched_repo_dir, resolver_output.git_patch) + + make_commit(patched_repo_dir, resolver_output.issue, issue_type) + + 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( + output_dir: str, + github_token: str, + github_username: str, + pr_type: str, + llm_config: LLMConfig, + fork_owner: str | None, +) -> None: + output_path = os.path.join(output_dir, 'output.jsonl') + for resolver_output in load_all_resolver_outputs(output_path): + if resolver_output.success: + print(f'Processing issue {resolver_output.issue.number}') + process_single_issue( + output_dir, + resolver_output, + github_token, + github_username, + pr_type, + llm_config, + fork_owner, + False, + ) + + +def main(): + parser = argparse.ArgumentParser(description='Send a pull request to Github.') + parser.add_argument( + '--github-token', + type=str, + default=None, + help='Github token to access the repository.', + ) + parser.add_argument( + '--github-username', + type=str, + default=None, + help='Github username to access the repository.', + ) + parser.add_argument( + '--output-dir', + type=str, + default='output', + help='Output directory to write the results.', + ) + parser.add_argument( + '--pr-type', + type=str, + default='draft', + choices=['branch', 'draft', 'ready'], + help='Type of the pull request to send [branch, draft, ready]', + ) + parser.add_argument( + '--issue-number', + type=str, + required=True, + help="Issue number to send the pull request for, or 'all_successful' to process all successful issues.", + ) + parser.add_argument( + '--fork-owner', + type=str, + default=None, + help='Owner of the fork to push changes to (if different from the original repo owner).', + ) + parser.add_argument( + '--send-on-failure', + action='store_true', + help='Send a pull request even if the issue was not successfully resolved.', + ) + parser.add_argument( + '--llm-model', + type=str, + default=None, + help='LLM model to use for summarizing changes.', + ) + parser.add_argument( + '--llm-api-key', + type=str, + default=None, + help='API key for the LLM model.', + ) + parser.add_argument( + '--llm-base-url', + type=str, + default=None, + help='Base URL for the LLM model.', + ) + my_args = parser.parse_args() + + github_token = ( + my_args.github_token if my_args.github_token else os.getenv('GITHUB_TOKEN') + ) + if not github_token: + raise ValueError( + 'Github token is not set, set via --github-token or GITHUB_TOKEN environment variable.' + ) + github_username = ( + my_args.github_username + if my_args.github_username + else os.getenv('GITHUB_USERNAME') + ) + + llm_config = LLMConfig( + model=my_args.llm_model or os.environ['LLM_MODEL'], + api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'], + base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None), + ) + + if not os.path.exists(my_args.output_dir): + raise ValueError(f'Output directory {my_args.output_dir} does not exist.') + + if my_args.issue_number == 'all_successful': + if not github_username: + raise ValueError('Github username is required.') + process_all_successful_issues( + my_args.output_dir, + github_token, + github_username, + my_args.pr_type, + llm_config, + my_args.fork_owner, + ) + else: + if not my_args.issue_number.isdigit(): + raise ValueError(f'Issue number {my_args.issue_number} is not a number.') + issue_number = int(my_args.issue_number) + output_path = os.path.join(my_args.output_dir, 'output.jsonl') + resolver_output = load_single_resolver_output(output_path, issue_number) + if not github_username: + raise ValueError('Github username is required.') + process_single_issue( + my_args.output_dir, + resolver_output, + github_token, + github_username, + my_args.pr_type, + llm_config, + my_args.fork_owner, + my_args.send_on_failure, + ) + + +if __name__ == '__main__': + main() diff --git a/openhands/resolver/utils.py b/openhands/resolver/utils.py new file mode 100644 index 000000000000..583026455945 --- /dev/null +++ b/openhands/resolver/utils.py @@ -0,0 +1,139 @@ +import json +import logging +import multiprocessing as mp +import os +from typing import Callable + +import pandas as pd + +from openhands.controller.state.state import State +from openhands.core.logger import get_console_handler +from openhands.core.logger import openhands_logger as logger +from openhands.events.action import Action +from openhands.events.action.message import MessageAction + + +def codeact_user_response( + state: State, + encapsulate_solution: bool = False, + try_parse: Callable[[Action | None], str] | None = None, +) -> str: + encaps_str = ( + ( + 'Please encapsulate your final answer (answer ONLY) within and .\n' + 'For example: The answer to the question is 42 .\n' + ) + if encapsulate_solution + else '' + ) + msg = ( + 'Please continue working on the task on whatever approach you think is suitable.\n' + 'If you think you have solved the task, please first send your answer to user through message and then finish the interaction.\n' + f'{encaps_str}' + 'IMPORTANT: YOU SHOULD NEVER ASK FOR HUMAN HELP.\n' + ) + + if state.history: + # check if the last action has an answer, if so, early exit + if try_parse is not None: + last_action = next( + ( + event + for event in reversed(state.history) + if isinstance(event, Action) + ), + None, + ) + ans = try_parse(last_action) + if ans is not None: + return '/exit' + + # check if the agent has tried to talk to the user 3 times, if so, let the agent know it can give up + user_msgs = [ + event + for event in state.history + if isinstance(event, MessageAction) and event.source == 'user' + ] + if len(user_msgs) >= 2: + # let the agent know that it can give up when it has tried 3 times + return ( + msg + + 'If you want to give up, run: exit .\n' + ) + return msg + + +def cleanup(): + print('Cleaning up child processes...') + for process in mp.active_children(): + print(f'Terminating child process: {process.name}') + process.terminate() + process.join() + + +def prepare_dataset(dataset: pd.DataFrame, output_file: str, eval_n_limit: int): + assert 'instance_id' in dataset.columns, ( + "Expected 'instance_id' column in the dataset. You should define your own " + "unique identifier for each instance and use it as the 'instance_id' column." + ) + id_column = 'instance_id' + logger.info(f'Writing evaluation output to {output_file}') + finished_ids = set() + if os.path.exists(output_file): + with open(output_file, 'r') as f: + for line in f: + data = json.loads(line) + finished_ids.add(data[id_column]) + logger.warning( + f'Output file {output_file} already exists. Loaded ' + f'{len(finished_ids)} finished instances.' + ) + + if eval_n_limit: + dataset = dataset.head(eval_n_limit) + logger.info(f'Limiting evaluation to first {eval_n_limit} instances.') + + new_dataset = [ + instance + for _, instance in dataset.iterrows() + if instance[id_column] not in finished_ids + ] + logger.info( + f'Finished instances: {len(finished_ids)}, ' + f'Remaining instances: {len(new_dataset)}' + ) + + return pd.DataFrame(new_dataset) + + +def reset_logger_for_multiprocessing( + logger: logging.Logger, instance_id: str, log_dir: str +): + """Reset the logger for multiprocessing. + + Save logs to a separate file for each process, instead of trying to write to the + same file/console from multiple processes. + """ + # Set up logger + log_file = os.path.join( + log_dir, + f'instance_{instance_id}.log', + ) + # Remove all existing handlers from logger + for handler in logger.handlers[:]: + logger.removeHandler(handler) + # add back the console handler to print ONE line + logger.addHandler(get_console_handler()) + logger.info( + f'Starting resolver for instance {instance_id}.\n' + f'Hint: run "tail -f {log_file}" to see live logs in a separate shell' + ) + # Remove all existing handlers from logger + for handler in logger.handlers[:]: + logger.removeHandler(handler) + os.makedirs(os.path.dirname(log_file), exist_ok=True) + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter( + logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + ) + logger.addHandler(file_handler) diff --git a/openhands/resolver/visualize_resolver_output.py b/openhands/resolver/visualize_resolver_output.py new file mode 100644 index 000000000000..f7081f6c76f4 --- /dev/null +++ b/openhands/resolver/visualize_resolver_output.py @@ -0,0 +1,43 @@ +import argparse +import os + +from openhands.resolver.io_utils import load_single_resolver_output + + +def visualize_resolver_output(issue_number: int, output_dir: str, vis_method: str): + output_jsonl = os.path.join(output_dir, 'output.jsonl') + resolver_output = load_single_resolver_output(output_jsonl, issue_number) + if vis_method == 'json': + print(resolver_output.model_dump_json(indent=4)) + else: + raise ValueError(f'Invalid visualization method: {vis_method}') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Visualize a patch.') + parser.add_argument( + '--issue-number', + type=int, + required=True, + help='Issue number to send the pull request for.', + ) + parser.add_argument( + '--output-dir', + type=str, + default='output', + help='Output directory to write the results.', + ) + parser.add_argument( + '--vis-method', + type=str, + default='json', + choices=['json'], + help='Method to visualize the patch [json].', + ) + my_args = parser.parse_args() + + visualize_resolver_output( + issue_number=my_args.issue_number, + output_dir=my_args.output_dir, + vis_method=my_args.vis_method, + ) diff --git a/tests/unit/resolver/mock_output/output.jsonl b/tests/unit/resolver/mock_output/output.jsonl new file mode 100644 index 000000000000..ef8285956a59 --- /dev/null +++ b/tests/unit/resolver/mock_output/output.jsonl @@ -0,0 +1,2 @@ +{"issue":{"owner":"neubig","repo":"pr-viewer","number":5,"title":"Add MIT license","body":"We can license this repo under the MIT license."},"issue_type":"issue","instruction":"Please fix the following issue for the repository in /workspace.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\nWe can license this repo under the MIT license.\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please run the following command: exit .","base_commit":"","git_patch":"diff --git a/LICENSE b/LICENSE\r\nnew file mode 100644\r\nindex 0000000..dbf96a3\r\n--- /dev/null\r\n+++ b/LICENSE\r\n@@ -0,0 +1,21 @@\r\n+MIT License\r\n+\r\n+Copyright (c) 2024 [Your Name or Organization Name]\r\n+\r\n+Permission is hereby granted, free of charge, to any person obtaining a copy\r\n+of this software and associated documentation files (the \"Software\"), to deal\r\n+in the Software without restriction, including without limitation the rights\r\n+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\n+copies of the Software, and to permit persons to whom the Software is\r\n+furnished to do so, subject to the following conditions:\r\n+\r\n+The above copyright notice and this permission notice shall be included in all\r\n+copies or substantial portions of the Software.\r\n+\r\n+THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\n+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\n+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\n+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\n+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\n+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\n+SOFTWARE.\r\n\\ No newline at end of file","history":[{"content":"Please fix the following issue for the repository in /workspace.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\nWe can license this repo under the MIT license.\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please run the following command: exit .","images_urls":null,"wait_for_response":false,"action":"message","security_risk":null},{"command":"touch LICENSE","thought":"Certainly! I'll add the MIT license to the repository in /workspace. Let's start by creating the license file.","keep_prompt":true,"action":"run","is_confirmed":"confirmed","security_risk":null},{"content":"\r\nroot@bcd3959393a1:/workspace # ","command_id":-1,"command":"touch LICENSE","exit_code":0,"observation":"run"},{"code":"mit_license = \"\"\"MIT License\n\nCopyright (c) 2024 [Your Name or Organization Name]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\"\"\"\n\nwith open('LICENSE', 'w') as license_file:\n license_file.write(mit_license)\n\nprint(\"MIT License has been added to the LICENSE file.\")","thought":"Thank you for the reminder. Let's proceed with adding the MIT license content to the LICENSE file.","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"MIT License has been added to the LICENSE file.\n[Jupyter current working directory: /workspace]","code":"mit_license = \"\"\"MIT License\n\nCopyright (c) 2024 [Your Name or Organization Name]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\"\"\"\n\nwith open('LICENSE', 'w') as license_file:\n license_file.write(mit_license)\n\nprint(\"MIT License has been added to the LICENSE file.\")","observation":"run_ipython"},{"command":"cat LICENSE","thought":"Great! We've successfully added the MIT License to the repository. Now, let's verify the contents of the LICENSE file to ensure everything is correct.","keep_prompt":true,"action":"run","is_confirmed":"confirmed","security_risk":null},{"content":"MIT License\r\n\r\nCopyright (c) 2024 [Your Name or Organization Name]\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.\r\nroot@bcd3959393a1:/workspace # ","command_id":-1,"command":"cat LICENSE","exit_code":0,"observation":"run"},{"outputs":{},"thought":"The MIT License has been successfully added to the repository. The content of the LICENSE file is correct and follows the standard MIT License format. \n\nTo complete the task, we should update the copyright line with the appropriate name or organization. Since we don't have specific information about the repository owner, we'll leave it as is for now. If needed, the repository owner can update this line later.\n\nNow that we have added the MIT License to the repository, the task is complete. We have successfully licensed the repo under the MIT license.","action":"finish"}],"metrics":{"accumulated_cost":0.070713,"costs":[0.015396,0.019719,0.016742999999999997,0.018855]},"success":true,"comment_success":null,"success_explanation":"The AI agent has successfully added the MIT License to the repository. The LICENSE file has been created with the correct content following the standard MIT License format. While the copyright line could be updated with specific owner information, this is not strictly necessary for the license to be valid and can be done later if needed. The core requirement of licensing the repo under the MIT license has been fulfilled, thus resolving the issue.","error":null} +{"issue":{"owner":"neubig","repo":"pr-viewer","number":1,"title":"Add a toggle for dark mode","body":"It'd be nice if this app could also support dark mode, we can add a toggle that can turn on dark mode.\r\n\r\nThis would require adding a toggle that switches between the different modes to JS, and modifying the css file."},"issue_type": "issue","instruction":"Please fix the following issue for the repository in /workspace.\nEnvironment has been set up for you to start working. You may assume all necessary tools are installed.\n\n# Problem Statement\nIt'd be nice if this app could also support dark mode, we can add a toggle that can turn on dark mode.\r\n\r\nThis would require adding a toggle that switches between the different modes to JS, and modifying the css file.\n\nIMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.\nYou should NOT modify any existing test case files. If needed, you can add new test cases in a NEW file to reproduce the issue.\nYou SHOULD INCLUDE PROPER INDENTATION in your edit commands.\nWhen you think you have fixed the issue through code changes, please run the following command: exit .","base_commit":"","git_patch":"diff --git a/src/App.css b/src/App.css\r\nindex b9d355d..b3c4c63 100644\r\n--- a/src/App.css\r\n+++ b/src/App.css\r\n@@ -5,6 +5,17 @@\r\n text-align: center;\r\n }\r\n \r\n+body {\r\n+ background-color: #ffffff;\r\n+ color: #213547;\r\n+ transition: background-color 0.3s, color 0.3s;\r\n+}\r\n+\r\n+body.dark-mode {\r\n+ background-color: #242424;\r\n+ color: #ffffff;\r\n+}\r\n+\r\n .logo {\r\n height: 6em;\r\n padding: 1.5em;\r\n@@ -40,3 +51,29 @@\r\n .read-the-docs {\r\n color: #888;\r\n }\r\n+\r\n+.dark-mode-toggle {\r\n+ position: fixed;\r\n+ top: 20px;\r\n+ right: 20px;\r\n+ background-color: #646cff;\r\n+ color: white;\r\n+ border: none;\r\n+ padding: 10px 20px;\r\n+ border-radius: 5px;\r\n+ cursor: pointer;\r\n+ transition: background-color 0.3s;\r\n+}\r\n+\r\n+.dark-mode-toggle:hover {\r\n+ background-color: #535bf2;\r\n+}\r\n+\r\n+.dark-mode .dark-mode-toggle {\r\n+ background-color: #ffffff;\r\n+ color: #242424;\r\n+}\r\n+\r\n+.dark-mode .dark-mode-toggle:hover {\r\n+ background-color: #e6e6e6;\r\n+}\r\ndiff --git a/src/PullRequestViewer.tsx b/src/PullRequestViewer.tsx\r\nindex 6a8281f..a7598b3 100644\r\n--- a/src/PullRequestViewer.tsx\r\n+++ b/src/PullRequestViewer.tsx\r\n@@ -24,7 +24,8 @@ interface Repo {\r\n const PullRequestViewer: React.FC = () => {\r\n const [repos, setRepos] = useState([]);\r\n const [selectedRepo, setSelectedRepo] = useState(null);\r\n- const [pullRequests, setPullRequests] = useState([]);\r\n+const [pullRequests, setPullRequests] = useState([]);\r\n+ const [darkMode, setDarkMode] = useState(false);\r\n \r\n useEffect(() => {\r\n const fetchRepos = async () => {\r\n@@ -80,9 +81,17 @@ const PullRequestViewer: React.FC = () => {\r\n fetchPullRequests();\r\n }, [selectedRepo]);\r\n \r\n+ const toggleDarkMode = () => {\r\n+ setDarkMode(!darkMode);\r\n+ document.body.classList.toggle('dark-mode');\r\n+ };\r\n+\r\n return (\r\n-
\r\n+
\r\n

Pull Request Viewer

\r\n+ \r\n exit .","images_urls":null,"wait_for_response":false,"action":"message","security_risk":null},{"command":"ls -R /workspace","thought":"Certainly! I'll add dark mode support to the application by modifying the necessary files. Let's start by examining the current structure of the project.","keep_prompt":true,"action":"run","is_confirmed":"confirmed","security_risk":null},{"content":"/workspace:\r\nREADME.md\t package-lock.json src\t\ttsconfig.node.json\r\neslint.config.js package.json\t tsconfig.app.json\tvite.config.ts\r\nindex.html\t public\t tsconfig.json\r\n\r\n/workspace/public:\r\nvite.svg\r\n\r\n/workspace/src:\r\nApp.css PullRequestViewer.test.tsx assets\tmain.tsx vite-env.d.ts\r\nApp.tsx PullRequestViewer.tsx\t index.css\tsetupTests.ts\r\n\r\n/workspace/src/assets:\r\nreact.svg\r\n\r\nroot@b365937733b2:/workspace # ","command_id":-1,"command":"ls -R /workspace","exit_code":0,"observation":"run"},{"code":"open_file('/workspace/src/App.css')","thought":"Thank you for providing the project structure. Let's start by modifying the necessary files to add dark mode support.\n\nFirst, let's update the CSS file to include dark mode styles:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/App.css (42 lines total)]\n(this is the beginning of the file)\n1|#root {\n2| max-width: 1280px;\n3| margin: 0 auto;\n4| padding: 2rem;\n5| text-align: center;\n6|}\n7|\n8|.logo {\n9| height: 6em;\n10| padding: 1.5em;\n11| will-change: filter;\n12| transition: filter 300ms;\n13|}\n14|.logo:hover {\n15| filter: drop-shadow(0 0 2em #646cffaa);\n16|}\n17|.logo.react:hover {\n18| filter: drop-shadow(0 0 2em #61dafbaa);\n19|}\n20|\n21|@keyframes logo-spin {\n22| from {\n23| transform: rotate(0deg);\n24| }\n25| to {\n26| transform: rotate(360deg);\n27| }\n28|}\n29|\n30|@media (prefers-reduced-motion: no-preference) {\n31| a:nth-of-type(2) .logo {\n32| animation: logo-spin infinite 20s linear;\n33| }\n34|}\n35|\n36|.card {\n37| padding: 2em;\n38|}\n39|\n40|.read-the-docs {\n41| color: #888;\n42|}\n(this is the end of the file)\n[Jupyter current working directory: /workspace]","code":"open_file('/workspace/src/App.css')","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.css',\nto_replace=\"\"\"#root {\n max-width: 1280px;\n margin: 0 auto;\n padding: 2rem;\n text-align: center;\n}\n\n.logo {\n height: 6em;\n padding: 1.5em;\n will-change: filter;\n transition: filter 300ms;\n}\n.logo:hover {\n filter: drop-shadow(0 0 2em #646cffaa);\n}\n.logo.react:hover {\n filter: drop-shadow(0 0 2em #61dafbaa);\n}\n\n@keyframes logo-spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n a:nth-of-type(2) .logo {\n animation: logo-spin infinite 20s linear;\n }\n}\n\n.card {\n padding: 2em;\n}\n\n.read-the-docs {\n color: #888;\n}\"\"\",\nnew_content=\"\"\"#root {\n max-width: 1280px;\n margin: 0 auto;\n padding: 2rem;\n text-align: center;\n}\n\nbody {\n background-color: #ffffff;\n color: #213547;\n transition: background-color 0.3s, color 0.3s;\n}\n\nbody.dark-mode {\n background-color: #242424;\n color: #ffffff;\n}\n\n.logo {\n height: 6em;\n padding: 1.5em;\n will-change: filter;\n transition: filter 300ms;\n}\n.logo:hover {\n filter: drop-shadow(0 0 2em #646cffaa);\n}\n.logo.react:hover {\n filter: drop-shadow(0 0 2em #61dafbaa);\n}\n\n@keyframes logo-spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n a:nth-of-type(2) .logo {\n animation: logo-spin infinite 20s linear;\n }\n}\n\n.card {\n padding: 2em;\n}\n\n.read-the-docs {\n color: #888;\n}\n\n.dark-mode-toggle {\n position: fixed;\n top: 20px;\n right: 20px;\n background-color: #646cff;\n color: white;\n border: none;\n padding: 10px 20px;\n border-radius: 5px;\n cursor: pointer;\n transition: background-color 0.3s;\n}\n\n.dark-mode-toggle:hover {\n background-color: #535bf2;\n}\n\n.dark-mode .dark-mode-toggle {\n background-color: #ffffff;\n color: #242424;\n}\n\n.dark-mode .dark-mode-toggle:hover {\n background-color: #e6e6e6;\n}\"\"\"\n)","thought":"Let's update the CSS file to include dark mode styles. We'll add a new class for dark mode and modify existing styles:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/App.css (79 lines total after edit)]\n(this is the beginning of the file)\n1|#root {\n2| max-width: 1280px;\n3| margin: 0 auto;\n4| padding: 2rem;\n5| text-align: center;\n6|}\n7|\n8|body {\n9| background-color: #ffffff;\n10| color: #213547;\n11| transition: background-color 0.3s, color 0.3s;\n12|}\n13|\n14|body.dark-mode {\n15| background-color: #242424;\n16| color: #ffffff;\n17|}\n18|\n19|.logo {\n20| height: 6em;\n21| padding: 1.5em;\n22| will-change: filter;\n23| transition: filter 300ms;\n24|}\n25|.logo:hover {\n26| filter: drop-shadow(0 0 2em #646cffaa);\n27|}\n28|.logo.react:hover {\n29| filter: drop-shadow(0 0 2em #61dafbaa);\n30|}\n31|\n32|@keyframes logo-spin {\n33| from {\n34| transform: rotate(0deg);\n35| }\n36| to {\n37| transform: rotate(360deg);\n38| }\n39|}\n40|\n41|@media (prefers-reduced-motion: no-preference) {\n42| a:nth-of-type(2) .logo {\n43| animation: logo-spin infinite 20s linear;\n44| }\n45|}\n46|\n47|.card {\n48| padding: 2em;\n49|}\n50|\n51|.read-the-docs {\n52| color: #888;\n53|}\n54|\n55|.dark-mode-toggle {\n56| position: fixed;\n57| top: 20px;\n58| right: 20px;\n59| background-color: #646cff;\n60| color: white;\n61| border: none;\n62| padding: 10px 20px;\n63| border-radius: 5px;\n64| cursor: pointer;\n65| transition: background-color 0.3s;\n66|}\n67|\n68|.dark-mode-toggle:hover {\n69| background-color: #535bf2;\n70|}\n71|\n72|.dark-mode .dark-mode-toggle {\n73| background-color: #ffffff;\n74| color: #242424;\n75|}\n76|\n77|.dark-mode .dark-mode-toggle:hover {\n78| background-color: #e6e6e6;\n79|}\n(this is the end of the file)\n[File updated (edited at line 1). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.css',\nto_replace=\"\"\"#root {\n max-width: 1280px;\n margin: 0 auto;\n padding: 2rem;\n text-align: center;\n}\n\n.logo {\n height: 6em;\n padding: 1.5em;\n will-change: filter;\n transition: filter 300ms;\n}\n.logo:hover {\n filter: drop-shadow(0 0 2em #646cffaa);\n}\n.logo.react:hover {\n filter: drop-shadow(0 0 2em #61dafbaa);\n}\n\n@keyframes logo-spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n a:nth-of-type(2) .logo {\n animation: logo-spin infinite 20s linear;\n }\n}\n\n.card {\n padding: 2em;\n}\n\n.read-the-docs {\n color: #888;\n}\"\"\",\nnew_content=\"\"\"#root {\n max-width: 1280px;\n margin: 0 auto;\n padding: 2rem;\n text-align: center;\n}\n\nbody {\n background-color: #ffffff;\n color: #213547;\n transition: background-color 0.3s, color 0.3s;\n}\n\nbody.dark-mode {\n background-color: #242424;\n color: #ffffff;\n}\n\n.logo {\n height: 6em;\n padding: 1.5em;\n will-change: filter;\n transition: filter 300ms;\n}\n.logo:hover {\n filter: drop-shadow(0 0 2em #646cffaa);\n}\n.logo.react:hover {\n filter: drop-shadow(0 0 2em #61dafbaa);\n}\n\n@keyframes logo-spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n a:nth-of-type(2) .logo {\n animation: logo-spin infinite 20s linear;\n }\n}\n\n.card {\n padding: 2em;\n}\n\n.read-the-docs {\n color: #888;\n}\n\n.dark-mode-toggle {\n position: fixed;\n top: 20px;\n right: 20px;\n background-color: #646cff;\n color: white;\n border: none;\n padding: 10px 20px;\n border-radius: 5px;\n cursor: pointer;\n transition: background-color 0.3s;\n}\n\n.dark-mode-toggle:hover {\n background-color: #535bf2;\n}\n\n.dark-mode .dark-mode-toggle {\n background-color: #ffffff;\n color: #242424;\n}\n\n.dark-mode .dark-mode-toggle:hover {\n background-color: #e6e6e6;\n}\"\"\"\n)","observation":"run_ipython"},{"code":"open_file('/workspace/src/App.tsx')","thought":"The CSS file has been updated successfully. Now, let's modify the App.tsx file to add the dark mode toggle functionality:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/App.tsx (14 lines total)]\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n[Jupyter current working directory: /workspace]","code":"open_file('/workspace/src/App.tsx')","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode');\n } else {\n document.body.classList.remove('dark-mode');\n }\n }, [darkMode]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n };\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","thought":"Thank you for showing me the App.tsx file. Let's modify it to add the dark mode toggle functionality:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n/workspace/src/App.tsx:22\n[This is how your edit would have looked if applied]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React, { useState, useEffect } from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| const [darkMode, setDarkMode] = useState(false);\n8|\n9| useEffect(() => {\n10| if (darkMode) {\n11| document.body.classList.add('dark-mode');\n12| } else {\n13| document.body.classList.remove('dark-mode');\n14| }\n15| }, [darkMode]);\n16|\n17| const toggleDarkMode = () => {\n18| setDarkMode(!darkMode);\n19| };\n20|\n21| return (\n22|
\n23| \n26| \n27|
\n28| )\n29|}\n30|\n31|export default App\n(this is the end of the file)\n-------------------------------------------------\n\n[This is the original code before your edit]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n-------------------------------------------------\nYour changes have NOT been applied. Please fix your edit command and try again.\nYou either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\nDO NOT re-run the same failed edit command. Running it again will lead to the same error.\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode');\n } else {\n document.body.classList.remove('dark-mode');\n }\n }, [darkMode]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n };\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode');\n } else {\n document.body.classList.remove('dark-mode');\n }\n }, [darkMode]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n };\n\n return (\n
\n \n \n
\n );\n}\n\nexport default App\"\"\"\n)","thought":"I apologize for the confusion. It seems the edit was not applied due to a syntax error. Let's try to modify the App.tsx file again with the correct syntax:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n/workspace/src/App.tsx:22\n[This is how your edit would have looked if applied]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React, { useState, useEffect } from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| const [darkMode, setDarkMode] = useState(false);\n8|\n9| useEffect(() => {\n10| if (darkMode) {\n11| document.body.classList.add('dark-mode');\n12| } else {\n13| document.body.classList.remove('dark-mode');\n14| }\n15| }, [darkMode]);\n16|\n17| const toggleDarkMode = () => {\n18| setDarkMode(!darkMode);\n19| };\n20|\n21| return (\n22|
\n23| \n26| \n27|
\n28| );\n29|}\n30|\n31|export default App\n(this is the end of the file)\n-------------------------------------------------\n\n[This is the original code before your edit]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n-------------------------------------------------\nYour changes have NOT been applied. Please fix your edit command and try again.\nYou either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\nDO NOT re-run the same failed edit command. Running it again will lead to the same error.\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode');\n } else {\n document.body.classList.remove('dark-mode');\n }\n }, [darkMode]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n };\n\n return (\n
\n \n \n
\n );\n}\n\nexport default App\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false)\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode')\n } else {\n document.body.classList.remove('dark-mode')\n }\n }, [darkMode])\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode)\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","thought":"I apologize for the confusion. It seems that the file is using a different syntax or formatting. Let's try to modify the App.tsx file again, keeping the original formatting and only adding the necessary changes:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n/workspace/src/App.tsx:22\n[This is how your edit would have looked if applied]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React, { useState, useEffect } from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| const [darkMode, setDarkMode] = useState(false)\n8|\n9| useEffect(() => {\n10| if (darkMode) {\n11| document.body.classList.add('dark-mode')\n12| } else {\n13| document.body.classList.remove('dark-mode')\n14| }\n15| }, [darkMode])\n16|\n17| const toggleDarkMode = () => {\n18| setDarkMode(!darkMode)\n19| }\n20|\n21| return (\n22|
\n23| \n26| \n27|
\n28| )\n29|}\n30|\n31|export default App\n(this is the end of the file)\n-------------------------------------------------\n\n[This is the original code before your edit]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n-------------------------------------------------\nYour changes have NOT been applied. Please fix your edit command and try again.\nYou either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\nDO NOT re-run the same failed edit command. Running it again will lead to the same error.\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false)\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode')\n } else {\n document.body.classList.remove('dark-mode')\n }\n }, [darkMode])\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode)\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App(): JSX.Element {\n const [darkMode, setDarkMode] = useState(false)\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode')\n } else {\n document.body.classList.remove('dark-mode')\n }\n }, [darkMode])\n\n const toggleDarkMode = (): void => {\n setDarkMode(!darkMode)\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","thought":"I apologize for the confusion. It seems that the file is using TypeScript, which requires type annotations. Let's modify the App.tsx file with the correct TypeScript syntax:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n/workspace/src/App.tsx:22\n[This is how your edit would have looked if applied]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React, { useState, useEffect } from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App(): JSX.Element {\n7| const [darkMode, setDarkMode] = useState(false)\n8|\n9| useEffect(() => {\n10| if (darkMode) {\n11| document.body.classList.add('dark-mode')\n12| } else {\n13| document.body.classList.remove('dark-mode')\n14| }\n15| }, [darkMode])\n16|\n17| const toggleDarkMode = (): void => {\n18| setDarkMode(!darkMode)\n19| }\n20|\n21| return (\n22|
\n23| \n26| \n27|
\n28| )\n29|}\n30|\n31|export default App\n(this is the end of the file)\n-------------------------------------------------\n\n[This is the original code before your edit]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n-------------------------------------------------\nYour changes have NOT been applied. Please fix your edit command and try again.\nYou either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\nDO NOT re-run the same failed edit command. Running it again will lead to the same error.\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App(): JSX.Element {\n const [darkMode, setDarkMode] = useState(false)\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode')\n } else {\n document.body.classList.remove('dark-mode')\n }\n }, [darkMode])\n\n const toggleDarkMode = (): void => {\n setDarkMode(!darkMode)\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false)\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode')\n } else {\n document.body.classList.remove('dark-mode')\n }\n }, [darkMode])\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode)\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","thought":"I apologize for the persistent errors. It seems that the TypeScript configuration might be stricter than expected. Let's try a simpler approach without type annotations:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n/workspace/src/App.tsx:22\n[This is how your edit would have looked if applied]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React, { useState, useEffect } from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| const [darkMode, setDarkMode] = useState(false)\n8|\n9| useEffect(() => {\n10| if (darkMode) {\n11| document.body.classList.add('dark-mode')\n12| } else {\n13| document.body.classList.remove('dark-mode')\n14| }\n15| }, [darkMode])\n16|\n17| const toggleDarkMode = () => {\n18| setDarkMode(!darkMode)\n19| }\n20|\n21| return (\n22|
\n23| \n26| \n27|
\n28| )\n29|}\n30|\n31|export default App\n(this is the end of the file)\n-------------------------------------------------\n\n[This is the original code before your edit]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n-------------------------------------------------\nYour changes have NOT been applied. Please fix your edit command and try again.\nYou either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\nDO NOT re-run the same failed edit command. Running it again will lead to the same error.\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false)\n\n useEffect(() => {\n if (darkMode) {\n document.body.classList.add('dark-mode')\n } else {\n document.body.classList.remove('dark-mode')\n }\n }, [darkMode])\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode)\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false)\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode)\n document.body.classList.toggle('dark-mode')\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","thought":"I apologize for the continued errors. It seems that there might be some configuration issues or restrictions in place. Let's try a minimal change to add the dark mode toggle without modifying the existing structure too much:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n/workspace/src/App.tsx:15\n[This is how your edit would have looked if applied]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React, { useState } from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| const [darkMode, setDarkMode] = useState(false)\n8|\n9| const toggleDarkMode = () => {\n10| setDarkMode(!darkMode)\n11| document.body.classList.toggle('dark-mode')\n12| }\n13|\n14| return (\n15|
\n16| \n19| \n20|
\n21| )\n22|}\n23|\n24|export default App\n(this is the end of the file)\n-------------------------------------------------\n\n[This is the original code before your edit]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n-------------------------------------------------\nYour changes have NOT been applied. Please fix your edit command and try again.\nYou either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\nDO NOT re-run the same failed edit command. Running it again will lead to the same error.\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false)\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode)\n document.body.classList.toggle('dark-mode')\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false)\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode)\n document.body.classList.toggle('dark-mode')\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","thought":"I apologize for the persistent errors. It seems that there might be some strict TypeScript configurations or linting rules in place. Let's try a minimal change that should work with most configurations:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n/workspace/src/App.tsx:14\n[This is how your edit would have looked if applied]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React, { useState } from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| const [darkMode, setDarkMode] = useState(false)\n8|\n9| const toggleDarkMode = () => {\n10| setDarkMode(!darkMode)\n11| document.body.classList.toggle('dark-mode')\n12| }\n13|\n14| return (\n15|
\n16| \n19| \n20|
\n21| )\n22|}\n23|\n24|export default App\n(this is the end of the file)\n-------------------------------------------------\n\n[This is the original code before your edit]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n-------------------------------------------------\nYour changes have NOT been applied. Please fix your edit command and try again.\nYou either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\nDO NOT re-run the same failed edit command. Running it again will lead to the same error.\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React, { useState } from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const [darkMode, setDarkMode] = useState(false)\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode)\n document.body.classList.toggle('dark-mode')\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const toggleDarkMode = () => {\n document.body.classList.toggle('dark-mode')\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","thought":"I apologize for the continued errors. It seems that the project might have strict TypeScript or linting configurations that are preventing our changes. Let's try a minimal change that should work with most configurations and avoid potential syntax errors:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n/workspace/src/App.tsx:12\n[This is how your edit would have looked if applied]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| const toggleDarkMode = () => {\n8| document.body.classList.toggle('dark-mode')\n9| }\n10|\n11| return (\n12|
\n13| \n14| \n15|
\n16| )\n17|}\n18|\n19|export default App\n(this is the end of the file)\n-------------------------------------------------\n\n[This is the original code before your edit]\n-------------------------------------------------\n(this is the beginning of the file)\n1|\n2|import React from 'react'\n3|import './App.css'\n4|import PullRequestViewer from './PullRequestViewer'\n5|\n6|function App() {\n7| return (\n8|
\n9| \n10|
\n11| )\n12|}\n13|\n14|export default App\n(this is the end of the file)\n-------------------------------------------------\nYour changes have NOT been applied. Please fix your edit command and try again.\nYou either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\nDO NOT re-run the same failed edit command. Running it again will lead to the same error.\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/App.tsx',\nto_replace=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n return (\n
\n \n
\n )\n}\n\nexport default App\"\"\",\nnew_content=\"\"\"\nimport React from 'react'\nimport './App.css'\nimport PullRequestViewer from './PullRequestViewer'\n\nfunction App() {\n const toggleDarkMode = () => {\n document.body.classList.toggle('dark-mode')\n }\n\n return (\n
\n \n \n
\n )\n}\n\nexport default App\"\"\"\n)","observation":"run_ipython"},{"code":"open_file('/workspace/src/PullRequestViewer.tsx')","thought":"I apologize for the continued errors. It seems that the project has very strict configurations that are preventing even minimal changes. Let's try a different approach by adding the dark mode toggle functionality to the existing PullRequestViewer component instead of modifying the App component.\n\nFirst, let's check the contents of the PullRequestViewer.tsx file:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/PullRequestViewer.tsx (112 lines total)]\n(this is the beginning of the file)\n1|\n2|\n3|\n4|import React, { useState, useEffect } from 'react';\n5|import axios from 'axios';\n6|import { Octokit } from '@octokit/rest';\n7|import Select from 'react-select';\n8|\n9|const octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN });\n10|\n11|interface PullRequest {\n12| title: string;\n13| html_url: string;\n14| user: {\n15| login: string;\n16| };\n17|}\n18|\n19|interface Repo {\n20| value: string;\n21| label: string;\n22|}\n23|\n24|const PullRequestViewer: React.FC = () => {\n25| const [repos, setRepos] = useState([]);\n26| const [selectedRepo, setSelectedRepo] = useState(null);\n27| const [pullRequests, setPullRequests] = useState([]);\n28|\n29| useEffect(() => {\n30| const fetchRepos = async () => {\n31| try {\n32| const response = await octokit.repos.listForOrg({\n33| org: 'OpenDevin',\n34| type: 'all',\n35| });\n36| const repoOptions = response.data.map(repo => ({\n37| value: repo.name,\n38| label: repo.name,\n39| }));\n40| setRepos(repoOptions);\n41| } catch (error) {\n42| console.error('Error fetching repos:', error);\n43| }\n44| };\n45| fetchRepos();\n46| }, []);\n47|\n48| useEffect(() => {\n49| const fetchPullRequests = async () => {\n50| if (selectedRepo) {\n51| try {\n52| let allPullRequests: PullRequest[] = [];\n53| let page = 1;\n54| let hasNextPage = true;\n55|\n56| while (hasNextPage) {\n57| const response = await octokit.pulls.list({\n58| owner: 'OpenDevin',\n59| repo: selectedRepo.value,\n60| state: 'open',\n61| per_page: 100,\n62| page: page,\n63| });\n64|\n65| allPullRequests = [...allPullRequests, ...response.data];\n66|\n67| if (response.data.length < 100) {\n68| hasNextPage = false;\n69| } else {\n70| page++;\n71| }\n72| }\n73|\n74| setPullRequests(allPullRequests);\n75| } catch (error) {\n76| console.error('Error fetching pull requests:', error);\n77| }\n78| }\n79| };\n80| fetchPullRequests();\n81| }, [selectedRepo]);\n82|\n83| return (\n84|
\n85|

Pull Request Viewer

\n86| setSelectedRepo(option as Repo)}\n90| placeholder=\"Select a repository\"\n91| aria-label=\"Select a repository\"\n92| />\n93| {pullRequests.length > 0 ? (\n94|
    \n95| {pullRequests.map((pr) => (\n96|
  • \n97| \n98| {pr.title}\n99| \n100| {' by '}\n(12 more lines below)\n[Jupyter current working directory: /workspace]","code":"open_file('/workspace/src/PullRequestViewer.tsx')","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"\"\"\nimport React, { useState, useEffect } from 'react';\nimport axios from 'axios';\nimport { Octokit } from '@octokit/rest';\nimport Select from 'react-select';\n\nconst octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN });\n\ninterface PullRequest {\n title: string;\n html_url: string;\n user: {\n login: string;\n };\n}\n\ninterface Repo {\n value: string;\n label: string;\n}\n\nconst PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
    \n

    Pull Request Viewer

    \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
      \n {pullRequests.map((pr) => (\n
    • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
    • \n ))}\n
    \n ) : (\n

    No pull requests found.

    \n )}\n
    \n );\n};\n\nexport default PullRequestViewer;\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react';\nimport axios from 'axios';\nimport { Octokit } from '@octokit/rest';\nimport Select from 'react-select';\n\nconst octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN });\n\ninterface PullRequest {\n title: string;\n html_url: string;\n user: {\n login: string;\n };\n}\n\ninterface Repo {\n value: string;\n label: string;\n}\n\nconst PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n document.body.classList.toggle('dark-mode');\n };\n\n return (\n
    \n

    Pull Request Viewer

    \n \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
      \n {pullRequests.map((pr) => (\n
    • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
    • \n ))}\n
    \n ) : (\n

    No pull requests found.

    \n )}\n
    \n );\n};\n\nexport default PullRequestViewer;\"\"\"\n)","thought":"Thank you for showing me the PullRequestViewer.tsx file. Let's modify this file to add the dark mode toggle functionality. We'll add a dark mode toggle button and update the component's styling based on the dark mode state.","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[No exact match found in /workspace/src/PullRequestViewer.tsx for\n```\n\nimport React, { useState, useEffect } from 'react';\nimport axios from 'axios';\nimport { Octokit } from '@octokit/rest';\nimport Select from 'react-select';\n\nconst octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN });\n\ninterface PullRequest {\n title: string;\n html_url: string;\n user: {\n login: string;\n };\n}\n\ninterface Repo {\n value: string;\n label: string;\n}\n\nconst PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
    \n

    Pull Request Viewer

    \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
      \n {pullRequests.map((pr) => (\n
    • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
    • \n ))}\n
    \n ) : (\n

    No pull requests found.

    \n )}\n
    \n );\n};\n\nexport default PullRequestViewer;\n```\n]\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"\"\"\nimport React, { useState, useEffect } from 'react';\nimport axios from 'axios';\nimport { Octokit } from '@octokit/rest';\nimport Select from 'react-select';\n\nconst octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN });\n\ninterface PullRequest {\n title: string;\n html_url: string;\n user: {\n login: string;\n };\n}\n\ninterface Repo {\n value: string;\n label: string;\n}\n\nconst PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
    \n

    Pull Request Viewer

    \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
      \n {pullRequests.map((pr) => (\n
    • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
    • \n ))}\n
    \n ) : (\n

    No pull requests found.

    \n )}\n
    \n );\n};\n\nexport default PullRequestViewer;\"\"\",\nnew_content=\"\"\"\nimport React, { useState, useEffect } from 'react';\nimport axios from 'axios';\nimport { Octokit } from '@octokit/rest';\nimport Select from 'react-select';\n\nconst octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN });\n\ninterface PullRequest {\n title: string;\n html_url: string;\n user: {\n login: string;\n };\n}\n\ninterface Repo {\n value: string;\n label: string;\n}\n\nconst PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n document.body.classList.toggle('dark-mode');\n };\n\n return (\n
    \n

    Pull Request Viewer

    \n \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
      \n {pullRequests.map((pr) => (\n
    • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
    • \n ))}\n
    \n ) : (\n

    No pull requests found.

    \n )}\n
    \n );\n};\n\nexport default PullRequestViewer;\"\"\"\n)","observation":"run_ipython"},{"code":"open_file('/workspace/src/PullRequestViewer.tsx')","thought":"I apologize for the confusion. It seems that the content of the PullRequestViewer.tsx file has changed since we last viewed it. Let's try to add the dark mode toggle functionality to the existing file content. First, let's view the current content of the file:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/PullRequestViewer.tsx (112 lines total)]\n(this is the beginning of the file)\n1|\n2|\n3|\n4|import React, { useState, useEffect } from 'react';\n5|import axios from 'axios';\n6|import { Octokit } from '@octokit/rest';\n7|import Select from 'react-select';\n8|\n9|const octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN });\n10|\n11|interface PullRequest {\n12| title: string;\n13| html_url: string;\n14| user: {\n15| login: string;\n16| };\n17|}\n18|\n19|interface Repo {\n20| value: string;\n21| label: string;\n22|}\n23|\n24|const PullRequestViewer: React.FC = () => {\n25| const [repos, setRepos] = useState([]);\n26| const [selectedRepo, setSelectedRepo] = useState(null);\n27| const [pullRequests, setPullRequests] = useState([]);\n28|\n29| useEffect(() => {\n30| const fetchRepos = async () => {\n31| try {\n32| const response = await octokit.repos.listForOrg({\n33| org: 'OpenDevin',\n34| type: 'all',\n35| });\n36| const repoOptions = response.data.map(repo => ({\n37| value: repo.name,\n38| label: repo.name,\n39| }));\n40| setRepos(repoOptions);\n41| } catch (error) {\n42| console.error('Error fetching repos:', error);\n43| }\n44| };\n45| fetchRepos();\n46| }, []);\n47|\n48| useEffect(() => {\n49| const fetchPullRequests = async () => {\n50| if (selectedRepo) {\n51| try {\n52| let allPullRequests: PullRequest[] = [];\n53| let page = 1;\n54| let hasNextPage = true;\n55|\n56| while (hasNextPage) {\n57| const response = await octokit.pulls.list({\n58| owner: 'OpenDevin',\n59| repo: selectedRepo.value,\n60| state: 'open',\n61| per_page: 100,\n62| page: page,\n63| });\n64|\n65| allPullRequests = [...allPullRequests, ...response.data];\n66|\n67| if (response.data.length < 100) {\n68| hasNextPage = false;\n69| } else {\n70| page++;\n71| }\n72| }\n73|\n74| setPullRequests(allPullRequests);\n75| } catch (error) {\n76| console.error('Error fetching pull requests:', error);\n77| }\n78| }\n79| };\n80| fetchPullRequests();\n81| }, [selectedRepo]);\n82|\n83| return (\n84|
    \n85|

    Pull Request Viewer

    \n86| setSelectedRepo(option as Repo)}\n90| placeholder=\"Select a repository\"\n91| aria-label=\"Select a repository\"\n92| />\n93| {pullRequests.length > 0 ? (\n94|
      \n95| {pullRequests.map((pr) => (\n96|
    • \n97| \n98| {pr.title}\n99| \n100| {' by '}\n(12 more lines below)\n[Jupyter current working directory: /workspace]","code":"open_file('/workspace/src/PullRequestViewer.tsx')","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"\"\"const PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
      \n

      Pull Request Viewer

      \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\"\"\",\nnew_content=\"\"\"const PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n document.body.classList.toggle('dark-mode');\n };\n\n return (\n
      \n

      Pull Request Viewer

      \n \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\"\"\"\n)","thought":"Thank you for showing me the current content of the file. Now, let's add the dark mode toggle functionality to the PullRequestViewer component. We'll make the following changes:\n\n1. Add a new state variable for dark mode\n2. Create a function to toggle dark mode\n3. Add a button to toggle dark mode\n4. Update the component's className based on the dark mode state\n\nHere's the updated code:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[No exact match found in /workspace/src/PullRequestViewer.tsx for\n```\nconst PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
      \n

      Pull Request Viewer

      \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\n```\n]\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"\"\"const PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
      \n

      Pull Request Viewer

      \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\"\"\",\nnew_content=\"\"\"const PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n document.body.classList.toggle('dark-mode');\n };\n\n return (\n
      \n

      Pull Request Viewer

      \n \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"\"\"const PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
      \n

      Pull Request Viewer

      \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\"\"\",\nnew_content=\"\"\"const PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n document.body.classList.toggle('dark-mode');\n };\n\n return (\n
      \n

      Pull Request Viewer

      \n \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\"\"\"\n)","thought":"I apologize for the confusion. It seems that the content of the file has changed since we last viewed it. Let's try to add the dark mode toggle functionality by modifying the existing content. We'll add the dark mode state and toggle function, and update the component's return statement.","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[No exact match found in /workspace/src/PullRequestViewer.tsx for\n```\nconst PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
      \n

      Pull Request Viewer

      \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\n```\n]\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"\"\"const PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n return (\n
      \n

      Pull Request Viewer

      \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\"\"\",\nnew_content=\"\"\"const PullRequestViewer: React.FC = () => {\n const [repos, setRepos] = useState([]);\n const [selectedRepo, setSelectedRepo] = useState(null);\n const [pullRequests, setPullRequests] = useState([]);\n const [darkMode, setDarkMode] = useState(false);\n\n useEffect(() => {\n const fetchRepos = async () => {\n try {\n const response = await octokit.repos.listForOrg({\n org: 'OpenDevin',\n type: 'all',\n });\n const repoOptions = response.data.map(repo => ({\n value: repo.name,\n label: repo.name,\n }));\n setRepos(repoOptions);\n } catch (error) {\n console.error('Error fetching repos:', error);\n }\n };\n fetchRepos();\n }, []);\n\n useEffect(() => {\n const fetchPullRequests = async () => {\n if (selectedRepo) {\n try {\n let allPullRequests: PullRequest[] = [];\n let page = 1;\n let hasNextPage = true;\n\n while (hasNextPage) {\n const response = await octokit.pulls.list({\n owner: 'OpenDevin',\n repo: selectedRepo.value,\n state: 'open',\n per_page: 100,\n page: page,\n });\n\n allPullRequests = [...allPullRequests, ...response.data];\n\n if (response.data.length < 100) {\n hasNextPage = false;\n } else {\n page++;\n }\n }\n\n setPullRequests(allPullRequests);\n } catch (error) {\n console.error('Error fetching pull requests:', error);\n }\n }\n };\n fetchPullRequests();\n }, [selectedRepo]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n document.body.classList.toggle('dark-mode');\n };\n\n return (\n
      \n

      Pull Request Viewer

      \n \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\n {pullRequests.length > 0 ? (\n
        \n {pullRequests.map((pr) => (\n
      • \n \n {pr.title}\n \n {' by '}\n {pr.user.login}\n
      • \n ))}\n
      \n ) : (\n

      No pull requests found.

      \n )}\n
      \n );\n};\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"const [pullRequests, setPullRequests] = useState([]);\",\nnew_content=\"\"\"const [pullRequests, setPullRequests] = useState([]);\n const [darkMode, setDarkMode] = useState(false);\"\"\"\n)","thought":"I apologize for the confusion. It seems that the content of the file has changed significantly since we last viewed it. Let's try a different approach by adding the dark mode functionality to the existing code without replacing large portions of it. We'll modify the file incrementally:\n\n1. First, let's add the dark mode state:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/PullRequestViewer.tsx (113 lines total after edit)]\n(this is the beginning of the file)\n1|\n2|\n3|\n4|import React, { useState, useEffect } from 'react';\n5|import axios from 'axios';\n6|import { Octokit } from '@octokit/rest';\n7|import Select from 'react-select';\n8|\n9|const octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN });\n10|\n11|interface PullRequest {\n12| title: string;\n13| html_url: string;\n14| user: {\n15| login: string;\n16| };\n17|}\n18|\n19|interface Repo {\n20| value: string;\n21| label: string;\n22|}\n23|\n24|const PullRequestViewer: React.FC = () => {\n25| const [repos, setRepos] = useState([]);\n26| const [selectedRepo, setSelectedRepo] = useState(null);\n27|const [pullRequests, setPullRequests] = useState([]);\n28| const [darkMode, setDarkMode] = useState(false);\n29|\n30| useEffect(() => {\n31| const fetchRepos = async () => {\n32| try {\n33| const response = await octokit.repos.listForOrg({\n34| org: 'OpenDevin',\n35| type: 'all',\n36| });\n37| const repoOptions = response.data.map(repo => ({\n38| value: repo.name,\n39| label: repo.name,\n40| }));\n41| setRepos(repoOptions);\n42| } catch (error) {\n43| console.error('Error fetching repos:', error);\n44| }\n45| };\n46| fetchRepos();\n47| }, []);\n48|\n49| useEffect(() => {\n50| const fetchPullRequests = async () => {\n51| if (selectedRepo) {\n52| try {\n53| let allPullRequests: PullRequest[] = [];\n54| let page = 1;\n55| let hasNextPage = true;\n56|\n57| while (hasNextPage) {\n58| const response = await octokit.pulls.list({\n59| owner: 'OpenDevin',\n60| repo: selectedRepo.value,\n61| state: 'open',\n62| per_page: 100,\n63| page: page,\n64| });\n65|\n66| allPullRequests = [...allPullRequests, ...response.data];\n67|\n68| if (response.data.length < 100) {\n69| hasNextPage = false;\n70| } else {\n71| page++;\n72| }\n73| }\n74|\n75| setPullRequests(allPullRequests);\n76| } catch (error) {\n77| console.error('Error fetching pull requests:', error);\n78| }\n79| }\n80| };\n81| fetchPullRequests();\n82| }, [selectedRepo]);\n83|\n84| return (\n85|
      \n86|

      Pull Request Viewer

      \n87| setSelectedRepo(option as Repo)}\n91| placeholder=\"Select a repository\"\n92| aria-label=\"Select a repository\"\n93| />\n94| {pullRequests.length > 0 ? (\n95|
        \n96| {pullRequests.map((pr) => (\n97|
      • \n98| \n99| {pr.title}\n100| \n(13 more lines below)\n[File updated (edited at line 27). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"const [pullRequests, setPullRequests] = useState([]);\",\nnew_content=\"\"\"const [pullRequests, setPullRequests] = useState([]);\n const [darkMode, setDarkMode] = useState(false);\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\" }, [selectedRepo]);\",\nnew_content=\"\"\" }, [selectedRepo]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n document.body.classList.toggle('dark-mode');\n };\"\"\"\n)","thought":"Thank you for the update. Now let's add the toggle function for dark mode:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/PullRequestViewer.tsx (118 lines total after edit)]\n(18 more lines above)\n19|interface Repo {\n20| value: string;\n21| label: string;\n22|}\n23|\n24|const PullRequestViewer: React.FC = () => {\n25| const [repos, setRepos] = useState([]);\n26| const [selectedRepo, setSelectedRepo] = useState(null);\n27|const [pullRequests, setPullRequests] = useState([]);\n28| const [darkMode, setDarkMode] = useState(false);\n29|\n30| useEffect(() => {\n31| const fetchRepos = async () => {\n32| try {\n33| const response = await octokit.repos.listForOrg({\n34| org: 'OpenDevin',\n35| type: 'all',\n36| });\n37| const repoOptions = response.data.map(repo => ({\n38| value: repo.name,\n39| label: repo.name,\n40| }));\n41| setRepos(repoOptions);\n42| } catch (error) {\n43| console.error('Error fetching repos:', error);\n44| }\n45| };\n46| fetchRepos();\n47| }, []);\n48|\n49| useEffect(() => {\n50| const fetchPullRequests = async () => {\n51| if (selectedRepo) {\n52| try {\n53| let allPullRequests: PullRequest[] = [];\n54| let page = 1;\n55| let hasNextPage = true;\n56|\n57| while (hasNextPage) {\n58| const response = await octokit.pulls.list({\n59| owner: 'OpenDevin',\n60| repo: selectedRepo.value,\n61| state: 'open',\n62| per_page: 100,\n63| page: page,\n64| });\n65|\n66| allPullRequests = [...allPullRequests, ...response.data];\n67|\n68| if (response.data.length < 100) {\n69| hasNextPage = false;\n70| } else {\n71| page++;\n72| }\n73| }\n74|\n75| setPullRequests(allPullRequests);\n76| } catch (error) {\n77| console.error('Error fetching pull requests:', error);\n78| }\n79| }\n80| };\n81| fetchPullRequests();\n82| }, [selectedRepo]);\n83|\n84| const toggleDarkMode = () => {\n85| setDarkMode(!darkMode);\n86| document.body.classList.toggle('dark-mode');\n87| };\n88|\n89| return (\n90|
        \n91|

        Pull Request Viewer

        \n92| setSelectedRepo(option as Repo)}\n96| placeholder=\"Select a repository\"\n97| aria-label=\"Select a repository\"\n98| />\n99| {pullRequests.length > 0 ? (\n100|
          \n101| {pullRequests.map((pr) => (\n102|
        • \n103| \n104| {pr.title}\n105| \n106| {' by '}\n107| {pr.user.login}\n108|
        • \n109| ))}\n110|
        \n111| ) : (\n112|

        No open pull requests found.

        \n113| )}\n114|
        \n115| );\n116|};\n117|\n118|export default PullRequestViewer;\n(this is the end of the file)\n[File updated (edited at line 82). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\" }, [selectedRepo]);\",\nnew_content=\"\"\" }, [selectedRepo]);\n\n const toggleDarkMode = () => {\n setDarkMode(!darkMode);\n document.body.classList.toggle('dark-mode');\n };\"\"\"\n)","observation":"run_ipython"},{"code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"\"\" return (\n
        \n

        Pull Request Viewer

        \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\"\"\",\nnew_content=\"\"\" return (\n
        \n

        Pull Request Viewer

        \n \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\"\"\"\n)","thought":"Thank you for the update. Now let's add the dark mode toggle button and update the main div to include the dark mode class:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/PullRequestViewer.tsx (121 lines total after edit)]\n(21 more lines above)\n22|}\n23|\n24|const PullRequestViewer: React.FC = () => {\n25| const [repos, setRepos] = useState([]);\n26| const [selectedRepo, setSelectedRepo] = useState(null);\n27|const [pullRequests, setPullRequests] = useState([]);\n28| const [darkMode, setDarkMode] = useState(false);\n29|\n30| useEffect(() => {\n31| const fetchRepos = async () => {\n32| try {\n33| const response = await octokit.repos.listForOrg({\n34| org: 'OpenDevin',\n35| type: 'all',\n36| });\n37| const repoOptions = response.data.map(repo => ({\n38| value: repo.name,\n39| label: repo.name,\n40| }));\n41| setRepos(repoOptions);\n42| } catch (error) {\n43| console.error('Error fetching repos:', error);\n44| }\n45| };\n46| fetchRepos();\n47| }, []);\n48|\n49| useEffect(() => {\n50| const fetchPullRequests = async () => {\n51| if (selectedRepo) {\n52| try {\n53| let allPullRequests: PullRequest[] = [];\n54| let page = 1;\n55| let hasNextPage = true;\n56|\n57| while (hasNextPage) {\n58| const response = await octokit.pulls.list({\n59| owner: 'OpenDevin',\n60| repo: selectedRepo.value,\n61| state: 'open',\n62| per_page: 100,\n63| page: page,\n64| });\n65|\n66| allPullRequests = [...allPullRequests, ...response.data];\n67|\n68| if (response.data.length < 100) {\n69| hasNextPage = false;\n70| } else {\n71| page++;\n72| }\n73| }\n74|\n75| setPullRequests(allPullRequests);\n76| } catch (error) {\n77| console.error('Error fetching pull requests:', error);\n78| }\n79| }\n80| };\n81| fetchPullRequests();\n82| }, [selectedRepo]);\n83|\n84| const toggleDarkMode = () => {\n85| setDarkMode(!darkMode);\n86| document.body.classList.toggle('dark-mode');\n87| };\n88|\n89| return (\n90|
        \n91|

        Pull Request Viewer

        \n92| \n95| setSelectedRepo(option as Repo)}\n99| placeholder=\"Select a repository\"\n100| aria-label=\"Select a repository\"\n101| />\n102| {pullRequests.length > 0 ? (\n103|
          \n104| {pullRequests.map((pr) => (\n105|
        • \n106| \n107| {pr.title}\n108| \n109| {' by '}\n110| {pr.user.login}\n111|
        • \n112| ))}\n113|
        \n114| ) : (\n115|

        No open pull requests found.

        \n116| )}\n117|
        \n118| );\n119|};\n120|\n121|export default PullRequestViewer;\n(this is the end of the file)\n[File updated (edited at line 89). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]\n[Jupyter current working directory: /workspace]","code":"edit_file_by_replace(\n'/workspace/src/PullRequestViewer.tsx',\nto_replace=\"\"\" return (\n
        \n

        Pull Request Viewer

        \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\"\"\",\nnew_content=\"\"\" return (\n
        \n

        Pull Request Viewer

        \n \n setSelectedRepo(option as Repo)}\n placeholder=\"Select a repository\"\n aria-label=\"Select a repository\"\n />\"\"\"\n)","observation":"run_ipython"},{"code":"open_file('/workspace/src/App.css')","thought":"The changes have been successfully applied to the PullRequestViewer.tsx file. We have added the dark mode functionality as requested. Now, let's update the App.css file to include the dark mode styles:","action":"run_ipython","is_confirmed":"confirmed","security_risk":null,"kernel_init_code":"from agentskills import *"},{"content":"[File: /workspace/src/App.css (79 lines total)]\n(this is the beginning of the file)\n1|#root {\n2| max-width: 1280px;\n3| margin: 0 auto;\n4| padding: 2rem;\n5| text-align: center;\n6|}\n7|\n8|body {\n9| background-color: #ffffff;\n10| color: #213547;\n11| transition: background-color 0.3s, color 0.3s;\n12|}\n13|\n14|body.dark-mode {\n15| background-color: #242424;\n16| color: #ffffff;\n17|}\n18|\n19|.logo {\n20| height: 6em;\n21| padding: 1.5em;\n22| will-change: filter;\n23| transition: filter 300ms;\n24|}\n25|.logo:hover {\n26| filter: drop-shadow(0 0 2em #646cffaa);\n27|}\n28|.logo.react:hover {\n29| filter: drop-shadow(0 0 2em #61dafbaa);\n30|}\n31|\n32|@keyframes logo-spin {\n33| from {\n34| transform: rotate(0deg);\n35| }\n36| to {\n37| transform: rotate(360deg);\n38| }\n39|}\n40|\n41|@media (prefers-reduced-motion: no-preference) {\n42| a:nth-of-type(2) .logo {\n43| animation: logo-spin infinite 20s linear;\n44| }\n45|}\n46|\n47|.card {\n48| padding: 2em;\n49|}\n50|\n51|.read-the-docs {\n52| color: #888;\n53|}\n54|\n55|.dark-mode-toggle {\n56| position: fixed;\n57| top: 20px;\n58| right: 20px;\n59| background-color: #646cff;\n60| color: white;\n61| border: none;\n62| padding: 10px 20px;\n63| border-radius: 5px;\n64| cursor: pointer;\n65| transition: background-color 0.3s;\n66|}\n67|\n68|.dark-mode-toggle:hover {\n69| background-color: #535bf2;\n70|}\n71|\n72|.dark-mode .dark-mode-toggle {\n73| background-color: #ffffff;\n74| color: #242424;\n75|}\n76|\n77|.dark-mode .dark-mode-toggle:hover {\n78| background-color: #e6e6e6;\n79|}\n(this is the end of the file)\n[Jupyter current working directory: /workspace]","code":"open_file('/workspace/src/App.css')","observation":"run_ipython"},{"outputs":{},"thought":"The App.css file already contains the necessary styles for dark mode. We don't need to make any changes to this file. The dark mode functionality has been successfully implemented in the PullRequestViewer component.\n\nTo summarize the changes we've made:\n\n1. We added a dark mode state to the PullRequestViewer component.\n2. We implemented a toggleDarkMode function to switch between light and dark modes.\n3. We added a button to toggle dark mode in the component's render method.\n4. We updated the main div to include the dark-mode class when dark mode is active.\n\nThe existing CSS styles in App.css will handle the appearance changes for dark mode.\n\nThe task of adding dark mode support to the application has been completed. The toggle can now turn on and off dark mode as requested.","action":"finish"}],"metrics":{"accumulated_cost":1.152933,"costs":[0.015645,0.016581,0.029745,0.022647,0.027885,0.0309,0.033864000000000005,0.036774,0.039504,0.041799,0.044382000000000005,0.046148999999999996,0.045693,0.07552500000000001,0.057120000000000004,0.083457,0.09005099999999999,0.075882,0.078789,0.084789,0.085836,0.089916]},"success":true,"comment_success":null,"success_explanation":"The AI agent has successfully implemented the dark mode functionality as requested. They added a toggle to switch between light and dark modes in the PullRequestViewer component, created the necessary state and function to handle the mode switching, and ensured that the existing CSS in App.css will apply the appropriate styles. The agent confirms that the dark mode can be turned on and off using the newly added toggle, which directly addresses the requirements specified in the issue description.","error":null} diff --git a/tests/unit/resolver/mock_output/repo/src/App.css b/tests/unit/resolver/mock_output/repo/src/App.css new file mode 100644 index 000000000000..b9d355df2a59 --- /dev/null +++ b/tests/unit/resolver/mock_output/repo/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/tests/unit/resolver/mock_output/repo/src/App.tsx b/tests/unit/resolver/mock_output/repo/src/App.tsx new file mode 100644 index 000000000000..c3dd99ca1b44 --- /dev/null +++ b/tests/unit/resolver/mock_output/repo/src/App.tsx @@ -0,0 +1,14 @@ + +import React from 'react' +import './App.css' +import PullRequestViewer from './PullRequestViewer' + +function App() { + return ( +
        + +
        + ) +} + +export default App diff --git a/tests/unit/resolver/mock_output/repo/src/PullRequestViewer.test.tsx b/tests/unit/resolver/mock_output/repo/src/PullRequestViewer.test.tsx new file mode 100644 index 000000000000..10f29598b56f --- /dev/null +++ b/tests/unit/resolver/mock_output/repo/src/PullRequestViewer.test.tsx @@ -0,0 +1,19 @@ + + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import PullRequestViewer from './PullRequestViewer'; + +describe('PullRequestViewer', () => { + it('renders the component title', () => { + render(); + const titleElement = screen.getByText(/Pull Request Viewer/i); + expect(titleElement).toBeInTheDocument(); + }); + + it('renders the repository select dropdown', () => { + render(); + const selectElement = screen.getByRole('combobox', { name: /select a repository/i }); + expect(selectElement).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/resolver/mock_output/repo/src/PullRequestViewer.tsx b/tests/unit/resolver/mock_output/repo/src/PullRequestViewer.tsx new file mode 100644 index 000000000000..6a8281f7af49 --- /dev/null +++ b/tests/unit/resolver/mock_output/repo/src/PullRequestViewer.tsx @@ -0,0 +1,112 @@ + + + +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Octokit } from '@octokit/rest'; +import Select from 'react-select'; + +const octokit = new Octokit({ auth: import.meta.env.VITE_GITHUB_TOKEN }); + +interface PullRequest { + title: string; + html_url: string; + user: { + login: string; + }; +} + +interface Repo { + value: string; + label: string; +} + +const PullRequestViewer: React.FC = () => { + const [repos, setRepos] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(null); + const [pullRequests, setPullRequests] = useState([]); + + useEffect(() => { + const fetchRepos = async () => { + try { + const response = await octokit.repos.listForOrg({ + org: 'OpenDevin', + type: 'all', + }); + const repoOptions = response.data.map(repo => ({ + value: repo.name, + label: repo.name, + })); + setRepos(repoOptions); + } catch (error) { + console.error('Error fetching repos:', error); + } + }; + fetchRepos(); + }, []); + + useEffect(() => { + const fetchPullRequests = async () => { + if (selectedRepo) { + try { + let allPullRequests: PullRequest[] = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const response = await octokit.pulls.list({ + owner: 'OpenDevin', + repo: selectedRepo.value, + state: 'open', + per_page: 100, + page: page, + }); + + allPullRequests = [...allPullRequests, ...response.data]; + + if (response.data.length < 100) { + hasNextPage = false; + } else { + page++; + } + } + + setPullRequests(allPullRequests); + } catch (error) { + console.error('Error fetching pull requests:', error); + } + } + }; + fetchPullRequests(); + }, [selectedRepo]); + + return ( +
        +

        Pull Request Viewer

        +