Wan Arif 3504ca7752
feat: add Azure DevOps integration support (#11243)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-11-22 14:00:24 -05:00

433 lines
15 KiB
Python

from types import MappingProxyType
from typing import cast
from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
)
from openhands.integrations.service_types import (
AuthenticationError,
Branch,
PaginatedBranchesResponse,
ProviderType,
Repository,
SuggestedTask,
UnknownException,
User,
)
from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import server_config
from openhands.server.types import AppMode
from openhands.server.user_auth import (
get_access_token,
get_provider_tokens,
get_user_id,
)
from openhands.utils.posthog_tracker import alias_user_identities
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
@app.get('/installations', response_model=list[str])
async def get_user_installations(
provider: ProviderType,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
if provider == ProviderType.GITHUB:
return await client.get_github_installations()
elif provider == ProviderType.BITBUCKET:
return await client.get_bitbucket_workspaces()
elif provider == ProviderType.AZURE_DEVOPS:
return await client.get_azure_devops_organizations()
else:
return JSONResponse(
content=f"Provider {provider} doesn't support installations",
status_code=status.HTTP_400_BAD_REQUEST,
)
raise AuthenticationError('Git provider token required. (such as GitHub).')
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
page: int | None = None,
per_page: int | None = None,
installation_id: str | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> list[Repository] | JSONResponse:
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
try:
return await client.get_repositories(
sort,
server_config.app_mode,
selected_provider,
page,
per_page,
installation_id,
)
except UnknownException as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
logger.info(
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
)
raise AuthenticationError('Git provider token required. (such as GitHub).')
@app.get('/info', response_model=User)
async def get_user(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> User | JSONResponse:
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens, external_auth_token=access_token
)
try:
user: User = await client.get_user()
# Alias git provider login with Keycloak user ID in PostHog (SaaS mode only)
if user_id and user.login and server_config.app_mode == AppMode.SAAS:
alias_user_identities(
keycloak_user_id=user_id,
git_login=user.login,
)
return user
except UnknownException as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
logger.info(
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
)
raise AuthenticationError('Git provider token required. (such as GitHub).')
@app.get('/search/repositories', response_model=list[Repository])
async def search_repositories(
query: str,
per_page: int = 5,
sort: str = 'stars',
order: str = 'desc',
selected_provider: ProviderType | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> list[Repository] | JSONResponse:
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
try:
repos: list[Repository] = await client.search_repositories(
selected_provider, query, per_page, sort, order, server_config.app_mode
)
return repos
except UnknownException as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
logger.info(
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
)
raise AuthenticationError('Git provider token required.')
@app.get('/search/branches', response_model=list[Branch])
async def search_branches(
repository: str,
query: str,
per_page: int = 30,
selected_provider: ProviderType | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> list[Branch] | JSONResponse:
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
try:
branches: list[Branch] = await client.search_branches(
selected_provider, repository, query, per_page
)
return branches
except AuthenticationError as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_401_UNAUTHORIZED,
)
except UnknownException as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
logger.info(
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
)
return JSONResponse(
content='Git provider token required.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/suggested-tasks', response_model=list[SuggestedTask])
async def get_suggested_tasks(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> list[SuggestedTask] | JSONResponse:
"""Get suggested tasks for the authenticated user across their most recently pushed repositories.
Returns:
- PRs owned by the user
- Issues assigned to the user.
"""
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens, external_auth_token=access_token
)
try:
tasks: list[SuggestedTask] = await client.get_suggested_tasks()
return tasks
except UnknownException as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
logger.info(f'Returning 401 Unauthorized - No providers set for user_id: {user_id}')
raise AuthenticationError('No providers set.')
@app.get('/repository/branches', response_model=PaginatedBranchesResponse)
async def get_repository_branches(
repository: str,
page: int = 1,
per_page: int = 30,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> PaginatedBranchesResponse | JSONResponse:
"""Get branches for a repository.
Args:
repository: The repository name in the format 'owner/repo'
page: Page number for pagination (default: 1)
per_page: Number of branches per page (default: 30)
Returns:
A paginated response with branches for the repository
"""
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens, external_auth_token=access_token
)
try:
branches_response: PaginatedBranchesResponse = await client.get_branches(
repository, page=page, per_page=per_page
)
return branches_response
except UnknownException as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
logger.info(
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
)
raise AuthenticationError('Git provider token required. (such as GitHub).')
def _extract_repo_name(repository_name: str) -> str:
"""Extract the actual repository name from the full repository path.
Args:
repository_name: Repository name in format 'owner/repo' or 'domain/owner/repo'
Returns:
The actual repository name (last part after the last '/')
"""
return repository_name.split('/')[-1]
@app.get(
'/repository/{repository_name:path}/microagents',
response_model=list[MicroagentResponse],
)
async def get_repository_microagents(
repository_name: str,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> list[MicroagentResponse] | JSONResponse:
"""Scan the microagents directory of a repository and return the list of microagents.
The microagents directory location depends on the git provider and actual repository name:
- If git provider is not GitLab and actual repository name is ".openhands": scans "microagents" folder
- If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder
- Otherwise: scans ".openhands/microagents" folder
Note: This API returns microagent metadata without content for performance.
Use the separate content API to fetch individual microagent content.
Args:
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
provider_tokens: Provider tokens for authentication
access_token: Access token for external authentication
user_id: User ID for authentication
Returns:
List of microagents found in the repository's microagents directory (without content)
"""
try:
# Create provider handler for API authentication
provider_handler = ProviderHandler(
provider_tokens=provider_tokens
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
external_auth_token=access_token,
external_auth_id=user_id,
)
# Fetch microagents using the provider handler
microagents = await provider_handler.get_microagents(repository_name)
logger.info(f'Found {len(microagents)} microagents in {repository_name}')
return microagents
except AuthenticationError:
raise
except RuntimeError as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except Exception as e:
logger.error(
f'Error scanning repository {repository_name}: {str(e)}', exc_info=True
)
return JSONResponse(
content=f'Error scanning repository: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@app.get(
'/repository/{repository_name:path}/microagents/content',
response_model=MicroagentContentResponse,
)
async def get_repository_microagent_content(
repository_name: str,
file_path: str = Query(
..., description='Path to the microagent file within the repository'
),
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> MicroagentContentResponse | JSONResponse:
"""Fetch the content of a specific microagent file from a repository.
Args:
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
file_path: Query parameter - Path to the microagent file within the repository
provider_tokens: Provider tokens for authentication
access_token: Access token for external authentication
user_id: User ID for authentication
Returns:
Microagent file content and metadata
Example:
GET /api/user/repository/owner/repo/microagents/content?file_path=.openhands/microagents/my-agent.md
"""
try:
# Create provider handler for API authentication
provider_handler = ProviderHandler(
provider_tokens=provider_tokens
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
external_auth_token=access_token,
external_auth_id=user_id,
)
# Fetch file content using the provider handler
response = await provider_handler.get_microagent_content(
repository_name, file_path
)
logger.info(
f'Retrieved content for microagent {file_path} from {repository_name}'
)
return response
except AuthenticationError:
raise
except RuntimeError as e:
return JSONResponse(
content=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except Exception as e:
logger.error(
f'Error fetching microagent content from {repository_name}/{file_path}: {str(e)}',
exc_info=True,
)
return JSONResponse(
content=f'Error fetching microagent content: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)