Backup-bdg's picture
Upload 964 files
51ff9e5 verified
raw
history blame
17.6 kB
import os
from typing import Any
import httpx
from pydantic import SecretStr
from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
ProviderType,
Repository,
RequestMethod,
SuggestedTask,
TaskType,
UnknownException,
User,
)
from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitLabService(BaseGitService, GitService):
"""Default implementation of GitService for GitLab integration.
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
This is an extension point in OpenHands that allows applications to customize GitLab
integration behavior. Applications can substitute their own implementation by:
1. Creating a class that inherits from GitService
2. Implementing all required methods
3. Setting server_config.gitlab_service_class to the fully qualified name of the class
The class is instantiated via get_impl() in openhands.server.shared.py.
"""
BASE_URL = 'https://gitlab.com/api/v4'
GRAPHQL_URL = 'https://gitlab.com/api/graphql'
token: SecretStr = SecretStr('')
refresh = False
def __init__(
self,
user_id: str | None = None,
external_auth_id: str | None = None,
external_auth_token: SecretStr | None = None,
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
self.user_id = user_id
self.external_token_manager = external_token_manager
if token:
self.token = token
if base_domain:
self.BASE_URL = f'https://{base_domain}/api/v4'
self.GRAPHQL_URL = f'https://{base_domain}/api/graphql'
@property
def provider(self) -> str:
return ProviderType.GITLAB.value
async def _get_gitlab_headers(self) -> dict[str, Any]:
"""
Retrieve the GitLab Token to construct the headers
"""
if not self.token:
self.token = await self.get_latest_token()
return {
'Authorization': f'Bearer {self.token.get_secret_value()}',
}
def _has_token_expired(self, status_code: int) -> bool:
return status_code == 401
async def get_latest_token(self) -> SecretStr | None:
return self.token
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]:
try:
async with httpx.AsyncClient() as client:
gitlab_headers = await self._get_gitlab_headers()
# Make initial request
response = await self.execute_request(
client=client,
url=url,
headers=gitlab_headers,
params=params,
method=method,
)
# Handle token refresh if needed
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
gitlab_headers = await self._get_gitlab_headers()
response = await self.execute_request(
client=client,
url=url,
headers=gitlab_headers,
params=params,
method=method,
)
response.raise_for_status()
headers = {}
if 'Link' in response.headers:
headers['Link'] = response.headers['Link']
return response.json(), headers
except httpx.HTTPStatusError as e:
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def execute_graphql_query(
self, query: str, variables: dict[str, Any] | None = None
) -> Any:
"""
Execute a GraphQL query against the GitLab GraphQL API
Args:
query: The GraphQL query string
variables: Optional variables for the GraphQL query
Returns:
The data portion of the GraphQL response
"""
if variables is None:
variables = {}
try:
async with httpx.AsyncClient() as client:
gitlab_headers = await self._get_gitlab_headers()
# Add content type header for GraphQL
gitlab_headers['Content-Type'] = 'application/json'
payload = {
'query': query,
'variables': variables if variables is not None else {},
}
response = await client.post(
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
)
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
gitlab_headers = await self._get_gitlab_headers()
gitlab_headers['Content-Type'] = 'application/json'
response = await client.post(
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
)
response.raise_for_status()
result = response.json()
# Check for GraphQL errors
if 'errors' in result:
error_message = result['errors'][0].get(
'message', 'Unknown GraphQL error'
)
raise UnknownException(f'GraphQL error: {error_message}')
return result.get('data')
except httpx.HTTPStatusError as e:
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def get_user(self) -> User:
url = f'{self.BASE_URL}/user'
response, _ = await self._make_request(url)
# Use a default avatar URL if not provided
# In some self-hosted GitLab instances, the avatar_url field may be returned as None.
avatar_url = response.get('avatar_url') or ''
return User(
id=response.get('id'),
username=response.get('username'),
avatar_url=avatar_url,
name=response.get('name'),
email=response.get('email'),
company=response.get('organization'),
login=response.get('username'),
)
async def search_repositories(
self, query: str, per_page: int = 30, sort: str = 'updated', order: str = 'desc'
) -> list[Repository]:
url = f'{self.BASE_URL}/projects'
params = {
'search': query,
'per_page': per_page,
'order_by': 'last_activity_at',
'sort': order,
'visibility': 'public',
}
response, _ = await self._make_request(url, params)
repos = [
Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=True,
)
for repo in response
]
return repos
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []
page = 1
url = f'{self.BASE_URL}/projects'
# Map GitHub's sort values to GitLab's order_by values
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
while len(all_repos) < MAX_REPOS:
params = {
'page': str(page),
'per_page': str(PER_PAGE),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'membership': 1, # Use 1 instead of True
}
response, headers = await self._make_request(url, params)
if not response: # No more repositories
break
all_repos.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return [
Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
)
for repo in all_repos
]
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories.
Returns:
- Merge requests authored by the user.
- Issues assigned to the user.
"""
# Get user info to use in queries
user = await self.get_user()
username = user.login
# GraphQL query to get merge requests
query = """
query GetUserTasks {
currentUser {
authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) {
nodes {
id
iid
title
project {
fullPath
}
conflicts
mergeStatus
pipelines(first: 1) {
nodes {
status
}
}
discussions(first: 100) {
nodes {
notes {
nodes {
resolvable
resolved
}
}
}
}
}
}
}
}
"""
try:
tasks: list[SuggestedTask] = []
# Get merge requests using GraphQL
response = await self.execute_graphql_query(query)
data = response.get('currentUser', {})
# Process merge requests
merge_requests = data.get('authoredMergeRequests', {}).get('nodes', [])
for mr in merge_requests:
repo_name = mr.get('project', {}).get('fullPath', '')
mr_number = mr.get('iid')
title = mr.get('title', '')
# Start with default task type
task_type = TaskType.OPEN_PR
# Check for specific states
if mr.get('conflicts'):
task_type = TaskType.MERGE_CONFLICTS
elif (
mr.get('pipelines', {}).get('nodes', [])
and mr.get('pipelines', {}).get('nodes', [])[0].get('status')
== 'FAILED'
):
task_type = TaskType.FAILING_CHECKS
else:
# Check for unresolved comments
has_unresolved_comments = False
for discussion in mr.get('discussions', {}).get('nodes', []):
for note in discussion.get('notes', {}).get('nodes', []):
if note.get('resolvable') and not note.get('resolved'):
has_unresolved_comments = True
break
if has_unresolved_comments:
break
if has_unresolved_comments:
task_type = TaskType.UNRESOLVED_COMMENTS
# Only add the task if it's not OPEN_PR
if task_type != TaskType.OPEN_PR:
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITLAB,
task_type=task_type,
repo=repo_name,
issue_number=mr_number,
title=title,
)
)
# Get assigned issues using REST API
url = f'{self.BASE_URL}/issues'
params = {
'assignee_username': username,
'state': 'opened',
'scope': 'assigned_to_me',
}
issues_response, _ = await self._make_request(
method=RequestMethod.GET, url=url, params=params
)
# Process issues
for issue in issues_response:
repo_name = (
issue.get('references', {}).get('full', '').split('#')[0].strip()
)
issue_number = issue.get('iid')
title = issue.get('title', '')
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITLAB,
task_type=TaskType.OPEN_ISSUE,
repo=repo_name,
issue_number=issue_number,
title=title,
)
)
return tasks
except Exception:
return []
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}'
repo, _ = await self._make_request(url)
return Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
# Set maximum branches to fetch (10 pages with 100 per page)
MAX_BRANCHES = 1000
PER_PAGE = 100
all_branches: list[Branch] = []
page = 1
# Fetch up to 10 pages of branches
while page <= 10 and len(all_branches) < MAX_BRANCHES:
params = {'per_page': str(PER_PAGE), 'page': str(page)}
response, headers = await self._make_request(url, params)
if not response: # No more branches
break
for branch_data in response:
branch = Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('id', ''),
protected=branch_data.get('protected', False),
last_push_date=branch_data.get('commit', {}).get('committed_date'),
)
all_branches.append(branch)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
return all_branches
async def create_mr(
self,
id: int | str,
source_branch: str,
target_branch: str,
title: str,
description: str | None = None,
) -> str:
"""
Creates a merge request in GitLab
Args:
id: The ID or URL-encoded path of the project
source_branch: The name of the branch where your changes are implemented
target_branch: The name of the branch you want the changes merged into
title: The title of the merge request (optional, defaults to a generic title)
description: The description of the merge request (optional)
draft: Whether to create the MR as a draft (optional, defaults to False)
Returns:
- MR URL when successful
- Error message when unsuccessful
"""
# Convert string ID to URL-encoded path if needed
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
# Set default description if none provided
if not description:
description = (
f'Merging changes from {source_branch} into {target_branch}'
)
# Prepare the request payload
payload = {
'source_branch': source_branch,
'target_branch': target_branch,
'title': title,
'description': description,
}
# Make the POST request to create the MR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
return response['web_url']
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',
'openhands.integrations.gitlab.gitlab_service.GitLabService',
)
GitLabServiceImpl = get_impl(GitLabService, gitlab_service_cls)