Add Bitbucket microagent and backend implementation (#9021)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
Graham Neubig
2025-06-18 00:04:29 -04:00
committed by GitHub
parent b7efeb11d9
commit e074b2d36f
47 changed files with 2174 additions and 115 deletions

View File

@@ -1,9 +1,9 @@
# OpenHands Github & Gitlab Issue Resolver 🙌
# OpenHands GitHub, GitLab & Bitbucket 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!
Need help resolving a GitHub, GitLab, or Bitbucket issue but don't have the time to do it yourself? Let an AI agent help you out!
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 attempt to resolve GitHub, GitLab, and Bitbucket issues automatically. While it can handle multiple issues, it's primarily designed
to help you resolve one issue at a time with high quality.
Getting started is simple - just follow the instructions below.
@@ -74,8 +74,8 @@ If you prefer to run the resolver programmatically instead of using GitHub Actio
pip install openhands-ai
```
2. Create a GitHub or GitLab access token:
- Create a GitHub acces token
2. Create a GitHub, GitLab, or Bitbucket access token:
- 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"
@@ -84,7 +84,7 @@ pip install openhands-ai
- "Workflows"
- If you don't have push access to the target repo, you can fork it first
- Create a GitLab acces token
- Create a GitLab access token
- Visit [GitLab's token settings](https://gitlab.com/-/user_settings/personal_access_tokens)
- Create a fine-grained token with these scopes:
- 'api'
@@ -93,6 +93,16 @@ pip install openhands-ai
- 'read_repository'
- 'write_repository'
- Create a Bitbucket access token
- Visit [Bitbucket's app passwords settings](https://bitbucket.org/account/settings/app-passwords/)
- Create an app password with these scopes:
- 'Repositories: Read'
- 'Repositories: Write'
- 'Pull requests: Read'
- 'Pull requests: Write'
- 'Issues: Read'
- 'Issues: Write'
3. Set up environment variables:
```bash
@@ -107,6 +117,11 @@ export GIT_USERNAME="your-github-username" # Optional, defaults to token owner
export GITLAB_TOKEN="your-gitlab-token"
export GIT_USERNAME="your-gitlab-username" # Optional, defaults to token owner
# Bitbucket credentials if you're using Bitbucket repo
export BITBUCKET_TOKEN="your-bitbucket-token"
export GIT_USERNAME="your-bitbucket-username" # Optional, defaults to token owner
# LLM configuration
export LLM_MODEL="anthropic/claude-sonnet-4-20250514" # Recommended
@@ -172,13 +187,13 @@ There are three ways you can upload:
3. `ready` - create a non-draft PR that's ready for review
```bash
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GIT_USERNAME --pr-type draft
```
If you want to upload to a fork, you can do so by specifying the `fork-owner`:
```bash
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft --fork-owner YOUR_GITHUB_OR_GITLAB_USERNAME
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GIT_USERNAME --pr-type draft --fork-owner YOUR_GIT_USERNAME
```
## Providing Custom Instructions
@@ -187,5 +202,5 @@ You can customize how the AI agent approaches issue resolution by adding a repos
## Troubleshooting
If you have any issues, please open an issue on this github or gitlab repo, we're happy to help!
If you have any issues, please open an issue on this GitHub, GitLab, or Bitbucket repo, we're happy to help!
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the OpenHands Slack workspace (see [the README](/README.md) for an invite link).

View File

@@ -0,0 +1,519 @@
import base64
from typing import Any
import httpx
from openhands.core.logger import openhands_logger as logger
from openhands.resolver.interfaces.issue import (
Issue,
IssueHandlerInterface,
ReviewThread,
)
from openhands.resolver.utils import extract_issue_references
class BitbucketIssueHandler(IssueHandlerInterface):
def __init__(
self,
owner: str,
repo: str,
token: str,
username: str | None = None,
base_domain: str = 'bitbucket.org',
):
"""Initialize a Bitbucket issue handler.
Args:
owner: The workspace of the repository
repo: The name of the repository
token: The Bitbucket API token
username: Optional Bitbucket username
base_domain: The domain for Bitbucket Server (default: "bitbucket.org")
"""
self.owner = owner
self.repo = repo
self.token = token
self.username = username
self.base_domain = base_domain
self.base_url = self.get_base_url()
self.download_url = self.get_download_url()
self.clone_url = self.get_clone_url()
self.headers = self.get_headers()
def set_owner(self, owner: str) -> None:
self.owner = owner
def get_headers(self) -> dict[str, str]:
# Check if the token contains a colon, which indicates it's in username:password format
if ':' in self.token:
auth_str = base64.b64encode(self.token.encode()).decode()
return {
'Authorization': f'Basic {auth_str}',
'Accept': 'application/json',
}
else:
return {
'Authorization': f'Bearer {self.token}',
'Accept': 'application/json',
}
def get_base_url(self) -> str:
"""Get the base URL for the Bitbucket API."""
return f'https://api.{self.base_domain}/2.0'
def get_download_url(self) -> str:
"""Get the download URL for the repository."""
return f'https://{self.base_domain}/{self.owner}/{self.repo}/get/master.zip'
def get_clone_url(self) -> str:
"""Get the clone URL for the repository."""
return f'https://{self.base_domain}/{self.owner}/{self.repo}.git'
def get_repo_url(self) -> str:
"""Get the URL for the repository."""
return f'https://{self.base_domain}/{self.owner}/{self.repo}'
def get_issue_url(self, issue_number: int) -> str:
"""Get the URL for an issue."""
return f'{self.get_repo_url()}/issues/{issue_number}'
def get_pr_url(self, pr_number: int) -> str:
"""Get the URL for a pull request."""
return f'{self.get_repo_url()}/pull-requests/{pr_number}'
async def get_issue(self, issue_number: int) -> Issue:
"""Get an issue from Bitbucket.
Args:
issue_number: The issue number
Returns:
An Issue object
"""
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/issues/{issue_number}'
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers)
response.raise_for_status()
data = response.json()
# Create a basic Issue object with required fields
issue = Issue(
owner=self.owner,
repo=self.repo,
number=data.get('id'),
title=data.get('title', ''),
body=data.get('content', {}).get('raw', ''),
)
return issue
def create_pr(
self,
title: str,
body: str,
head: str,
base: str,
) -> str:
"""Create a pull request.
Args:
title: The title of the pull request
body: The body of the pull request
head: The head branch
base: The base branch
Returns:
The URL of the created pull request
"""
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests'
payload = {
'title': title,
'description': body,
'source': {'branch': {'name': head}},
'destination': {'branch': {'name': base}},
'close_source_branch': False,
}
response = httpx.post(url, headers=self.headers, json=payload)
response.raise_for_status()
data = response.json()
return data.get('links', {}).get('html', {}).get('href', '')
def download_issues(self) -> list[Any]:
"""Download all issues from the repository.
Returns:
A list of issues
"""
logger.warning('BitbucketIssueHandler.download_issues not implemented')
return []
def get_issue_comments(
self, issue_number: int, comment_id: int | None = None
) -> list[str] | None:
"""Get comments for an issue.
Args:
issue_number: The issue number
comment_id: The comment ID (optional)
Returns:
A list of comments
"""
logger.warning('BitbucketIssueHandler.get_issue_comments not implemented')
return []
def get_branch_url(self, branch_name: str) -> str:
"""Get the URL for a branch.
Args:
branch_name: The branch name
Returns:
The URL for the branch
"""
return (
f'https://{self.base_domain}/{self.owner}/{self.repo}/branch/{branch_name}'
)
def get_compare_url(self, branch_name: str) -> str:
"""Get the URL for comparing branches.
Args:
branch_name: The branch name
Returns:
The URL for comparing branches
"""
return f'https://{self.base_domain}/{self.owner}/{self.repo}/compare/master...{branch_name}'
def get_authorize_url(self) -> str:
"""Get the URL for authorization.
Returns:
The URL for authorization
"""
return f'https://oauth2:{self.token}@{self.base_domain}/'
def get_pull_url(self, pr_number: int) -> str:
"""Get the URL for a pull request.
Args:
pr_number: The pull request number
Returns:
The URL for the pull request
"""
return f'https://{self.base_domain}/{self.owner}/{self.repo}/pull-requests/{pr_number}'
def get_branch_name(self, base_branch_name: str) -> str:
"""Get a unique branch name.
Args:
base_branch_name: The base branch name
Returns:
A unique branch name
"""
return f'{base_branch_name}-{self.owner}'
def branch_exists(self, branch_name: str) -> bool:
"""Check if a branch exists.
Args:
branch_name: The branch name
Returns:
True if the branch exists, False otherwise
"""
logger.warning('BitbucketIssueHandler.branch_exists not implemented')
return False
def get_default_branch_name(self) -> str:
"""Get the default branch name.
Returns:
The default branch name
"""
return 'master'
def create_pull_request(self, data: dict[str, Any] | None = None) -> dict[str, Any]:
"""Create a pull request.
Args:
data: The pull request data
Returns:
The created pull request
"""
if data is None:
data = {}
title = data.get('title', '')
description = data.get('description', '')
source_branch = data.get('source_branch', '')
target_branch = data.get('target_branch', '')
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests'
payload = {
'title': title,
'description': description,
'source': {'branch': {'name': source_branch}},
'destination': {'branch': {'name': target_branch}},
'close_source_branch': False,
}
response = httpx.post(url, headers=self.headers, json=payload)
response.raise_for_status()
data = response.json()
# Ensure data is not None before accessing it
if data is None:
data = {}
return {
'html_url': data.get('links', {}).get('html', {}).get('href', ''),
'number': data.get('id', 0),
}
def request_reviewers(self, reviewer: str, pr_number: int) -> None:
"""Request reviewers for a pull request.
Args:
reviewer: The reviewer
pr_number: The pull request number
"""
logger.warning('BitbucketIssueHandler.request_reviewers not implemented')
def send_comment_msg(self, issue_number: int, msg: str) -> None:
"""Send a comment to an issue.
Args:
issue_number: The issue number
msg: The message
"""
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests/{issue_number}/comments'
payload = {'content': {'raw': msg}}
response = httpx.post(url, headers=self.headers, json=payload)
response.raise_for_status()
def get_issue_thread_comments(self, issue_number: int) -> list[str]:
"""Get thread comments for an issue.
Args:
issue_number: The issue number
Returns:
A list of thread comments
"""
logger.warning(
'BitbucketIssueHandler.get_issue_thread_comments not implemented'
)
return []
def get_issue_review_comments(self, issue_number: int) -> list[str]:
"""Get review comments for an issue.
Args:
issue_number: The issue number
Returns:
A list of review comments
"""
logger.warning(
'BitbucketIssueHandler.get_issue_review_comments not implemented'
)
return []
def get_issue_review_threads(self, issue_number: int) -> list[ReviewThread]:
"""Get review threads for an issue.
Args:
issue_number: The issue number
Returns:
A list of review threads
"""
logger.warning('BitbucketIssueHandler.get_issue_review_threads not implemented')
return []
def get_context_from_external_issues_references(
self,
closing_issues: list[str],
closing_issue_numbers: list[int],
issue_body: str,
review_comments: list[str] | None,
review_threads: list[ReviewThread],
thread_comments: list[str] | None,
) -> list[str]:
"""Get context from external issue references.
Args:
closing_issues: List of closing issue references
closing_issue_numbers: List of closing issue numbers
issue_body: The issue body
review_comments: List of review comments
review_threads: List of review threads
thread_comments: List of thread comments
Returns:
Context from external issue references
"""
new_issue_references = []
if issue_body:
new_issue_references.extend(extract_issue_references(issue_body))
if review_comments:
for comment in review_comments:
new_issue_references.extend(extract_issue_references(comment))
if review_threads:
for review_thread in review_threads:
new_issue_references.extend(
extract_issue_references(review_thread.comment)
)
if thread_comments:
for thread_comment in thread_comments:
new_issue_references.extend(extract_issue_references(thread_comment))
non_duplicate_references = set(new_issue_references)
unique_issue_references = non_duplicate_references.difference(
closing_issue_numbers
)
for issue_number in unique_issue_references:
try:
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/issues/{issue_number}'
response = httpx.get(url, headers=self.headers)
response.raise_for_status()
issue_data = response.json()
issue_body = issue_data.get('content', {}).get('raw', '')
if issue_body:
closing_issues.append(issue_body)
except httpx.HTTPError as e:
logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}')
return closing_issues
def get_converted_issues(
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
) -> list[Issue]:
"""Get converted issues.
Args:
issue_numbers: List of issue numbers
comment_id: The comment ID
Returns:
A list of converted issues
"""
if not issue_numbers:
raise ValueError('Unspecified issue numbers')
all_issues = self.download_issues()
logger.info(f'Limiting resolving to issues {issue_numbers}.')
all_issues = [issue for issue in all_issues if issue.get('id') in issue_numbers]
converted_issues = []
for issue in all_issues:
# For PRs, body can be None
if any([issue.get(key) is None for key in ['id', 'title']]):
logger.warning(f'Skipping #{issue} as it is missing id or title.')
continue
# Handle None body for PRs
body = (
issue.get('content', {}).get('raw', '')
if issue.get('content') is not None
else ''
)
# Placeholder for PR metadata
closing_issues: list[str] = []
review_comments: list[str] = []
review_threads: list[ReviewThread] = []
thread_ids: list[str] = []
head_branch = issue.get('source', {}).get('branch', {}).get('name', '')
thread_comments: list[str] = []
issue_details = Issue(
owner=self.owner,
repo=self.repo,
number=issue['id'],
title=issue['title'],
body=body,
closing_issues=closing_issues,
review_comments=review_comments,
review_threads=review_threads,
thread_ids=thread_ids,
head_branch=head_branch,
thread_comments=thread_comments,
)
converted_issues.append(issue_details)
return converted_issues
def get_graphql_url(self) -> str:
"""Get the GraphQL URL.
Returns:
The GraphQL URL
"""
return f'https://api.{self.base_domain}/graphql'
def reply_to_comment(self, pr_number: int, comment_id: str, reply: str) -> None:
"""Reply to a comment.
Args:
pr_number: The pull request number
comment_id: The comment ID
reply: The reply message
"""
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests/{pr_number}/comments/{comment_id}'
payload = {'content': {'raw': reply}}
response = httpx.post(url, headers=self.headers, json=payload)
response.raise_for_status()
def get_issue_references(self, body: str) -> list[int]:
"""Extract issue references from a string.
Args:
body: The string to extract issue references from
Returns:
A list of issue numbers
"""
return extract_issue_references(body)
class BitbucketPRHandler(BitbucketIssueHandler):
"""Handler for Bitbucket pull requests, extending the issue handler."""
def __init__(
self,
owner: str,
repo: str,
token: str,
username: str | None = None,
base_domain: str = 'bitbucket.org',
):
"""Initialize a Bitbucket PR handler.
Args:
owner: The workspace of the repository
repo: The name of the repository
token: The Bitbucket API token
username: Optional Bitbucket username
base_domain: The domain for Bitbucket Server (default: "bitbucket.org")
"""
super().__init__(owner, repo, token, username, base_domain)

View File

@@ -121,5 +121,5 @@ class IssueHandlerInterface(ABC):
def get_converted_issues(
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
) -> list[Issue]:
"""Download issues from Gitlab."""
"""Download issues from the git provider (GitHub, GitLab, or Bitbucket)."""
pass

View File

@@ -1,5 +1,9 @@
from openhands.core.config import LLMConfig
from openhands.integrations.provider import ProviderType
from openhands.resolver.interfaces.bitbucket import (
BitbucketIssueHandler,
BitbucketPRHandler,
)
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler
from openhands.resolver.interfaces.issue_definitions import (
@@ -42,7 +46,7 @@ class IssueHandlerFactory:
),
self.llm_config,
)
else: # platform == Platform.GITLAB
elif self.platform == ProviderType.GITLAB:
return ServiceContextIssue(
GitlabIssueHandler(
self.owner,
@@ -53,6 +57,19 @@ class IssueHandlerFactory:
),
self.llm_config,
)
elif self.platform == ProviderType.BITBUCKET:
return ServiceContextIssue(
BitbucketIssueHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else:
raise ValueError(f'Unsupported platform: {self.platform}')
elif self.issue_type == 'pr':
if self.platform == ProviderType.GITHUB:
return ServiceContextPR(
@@ -65,7 +82,7 @@ class IssueHandlerFactory:
),
self.llm_config,
)
else: # platform == Platform.GITLAB
elif self.platform == ProviderType.GITLAB:
return ServiceContextPR(
GitlabPRHandler(
self.owner,
@@ -76,5 +93,18 @@ class IssueHandlerFactory:
),
self.llm_config,
)
elif self.platform == ProviderType.BITBUCKET:
return ServiceContextPR(
BitbucketPRHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else:
raise ValueError(f'Unsupported platform: {self.platform}')
else:
raise ValueError(f'Invalid issue type: {self.issue_type}')

View File

@@ -76,7 +76,12 @@ class IssueResolver:
raise ValueError('Invalid repository format. Expected owner/repo')
owner, repo = parts
token = args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')
token = (
args.token
or os.getenv('GITHUB_TOKEN')
or os.getenv('GITLAB_TOKEN')
or os.getenv('BITBUCKET_TOKEN')
)
username = args.username if args.username else os.getenv('GIT_USERNAME')
if not username:
raise ValueError('Username is required.')
@@ -120,7 +125,11 @@ class IssueResolver:
base_domain = args.base_domain
if base_domain is None:
base_domain = (
'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
'github.com'
if platform == ProviderType.GITHUB
else 'gitlab.com'
if platform == ProviderType.GITLAB
else 'bitbucket.org'
)
self.output_dir = args.output_dir

View File

@@ -116,7 +116,7 @@ def main() -> None:
'--base-domain',
type=str,
default=None,
help='Base domain for the git server (defaults to "github.com" for GitHub and "gitlab.com" for GitLab)',
help='Base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "bitbucket.org" for Bitbucket)',
)
my_args = parser.parse_args()

View File

@@ -11,6 +11,7 @@ from openhands.core.config import LLMConfig
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import ProviderType
from openhands.llm.llm import LLM
from openhands.resolver.interfaces.bitbucket import BitbucketIssueHandler
from openhands.resolver.interfaces.github import GithubIssueHandler
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler
from openhands.resolver.interfaces.issue import Issue
@@ -235,40 +236,55 @@ def send_pull_request(
pr_title: str | None = None,
base_domain: str | None = None,
) -> str:
"""Send a pull request to a GitHub or Gitlab repository.
"""Send a pull request to a GitHub, GitLab, or Bitbucket repository.
Args:
issue: The issue to send the pull request for
token: The GitHub or Gitlab token to use for authentication
username: The GitHub or Gitlab username, if provided
token: The token to use for authentication
username: The username, if provided
platform: The platform of the repository.
patch_dir: The directory containing the patches to apply
pr_type: The type: branch (no PR created), draft or ready (regular PR created)
fork_owner: The owner of the fork to push changes to (if different from the original repo owner)
additional_message: The additional messages to post as a comment on the PR in json list format
target_branch: The target branch to create the pull request against (defaults to repository default branch)
reviewer: The GitHub or Gitlab username of the reviewer to assign
reviewer: The username of the reviewer to assign
pr_title: Custom title for the pull request (optional)
base_domain: The base domain for the git server (defaults to "github.com" for GitHub and "gitlab.com" for GitLab)
base_domain: The base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "bitbucket.org" for Bitbucket)
"""
if pr_type not in ['branch', 'draft', 'ready']:
raise ValueError(f'Invalid pr_type: {pr_type}')
# Determine default base_domain based on platform
if base_domain is None:
base_domain = 'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
if platform == ProviderType.GITHUB:
base_domain = 'github.com'
elif platform == ProviderType.GITLAB:
base_domain = 'gitlab.com'
else: # platform == ProviderType.BITBUCKET
base_domain = 'bitbucket.org'
# Create the appropriate handler based on platform
handler = None
if platform == ProviderType.GITHUB:
handler = ServiceContextIssue(
GithubIssueHandler(issue.owner, issue.repo, token, username, base_domain),
None,
)
else: # platform == Platform.GITLAB
elif platform == ProviderType.GITLAB:
handler = ServiceContextIssue(
GitlabIssueHandler(issue.owner, issue.repo, token, username, base_domain),
None,
)
elif platform == ProviderType.BITBUCKET:
handler = ServiceContextIssue(
BitbucketIssueHandler(
issue.owner, issue.repo, token, username, base_domain
),
None,
)
else:
raise ValueError(f'Unsupported platform: {platform}')
# Create a new branch with a unique name
base_branch_name = f'openhands-fix-issue-{issue.number}'

View File

@@ -17,7 +17,7 @@ from openhands.integrations.utils import validate_provider_token
async def identify_token(token: str, base_domain: str | None) -> ProviderType:
"""
Identifies whether a token belongs to GitHub or GitLab.
Identifies whether a token belongs to GitHub, GitLab, or Bitbucket.
Parameters:
token (str): The personal access token to check.
base_domain (str): Custom base domain for provider (e.g GitHub Enterprise)