Skip to content

(WIP) Add Bitbucket integration and enhancements #681

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 19 additions & 14 deletions backend/analytics_server/mhq/api/request_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from stringcase import snakecase
from voluptuous import Invalid
from werkzeug.exceptions import BadRequest
from mhq.utils.log import LOG
from mhq.store.models.code.repository import TeamRepos
from mhq.service.code.models.org_repo import RawTeamOrgRepo
from mhq.store.models.code import WorkflowFilter, CodeProvider
Expand Down Expand Up @@ -82,20 +83,24 @@ def coerce_workflow_filter(filter_data: str) -> WorkflowFilter:


def coerce_org_repo(repo: Dict[str, str]) -> RawTeamOrgRepo:
return RawTeamOrgRepo(
team_id=repo.get("team_id"),
provider=CodeProvider(repo.get("provider")),
name=repo.get("name"),
org_name=repo.get("org"),
slug=repo.get("slug"),
idempotency_key=repo.get("idempotency_key"),
default_branch=repo.get("default_branch"),
deployment_type=(
TeamReposDeploymentType(repo.get("deployment_type"))
if repo.get("deployment_type")
else TeamReposDeploymentType.PR_MERGE
),
)
try:
return RawTeamOrgRepo(
team_id=repo.get("team_id"),
provider=CodeProvider(repo.get("provider")),
name=repo.get("name"),
org_name=repo.get("org"),
slug=repo.get("slug"),
idempotency_key=repo.get("idempotency_key"),
default_branch=repo.get("default_branch"),
deployment_type=(
TeamReposDeploymentType(repo.get("deployment_type"))
if repo.get("deployment_type")
else TeamReposDeploymentType.PR_MERGE
),
)
except Exception as e:
LOG.error(f"Error creating RawTeamOrgRepo with data: {repo}. Error: {str(e)}")
raise


def coerce_org_repos(repos: List[Dict[str, str]]) -> List[RawTeamOrgRepo]:
Expand Down
126 changes: 126 additions & 0 deletions backend/analytics_server/mhq/exapi/bitbucket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import requests
from typing import Optional, Dict, Any

from mhq.utils.log import LOG
from mhq.exapi.models.bitbucket import BitbucketRepo

class BitbucketApiService:
def __init__(self, access_token: str):
self._token = access_token
self.base_url = "https://api.bitbucket.org/2.0"
self.headers = {"Authorization": f"Basic {self._token}"}
self.session = requests.Session()
self.session.headers.update(self.headers)

def check_pat(self) -> bool:
"""
Checks if Personal Access Token is valid.

Returns:
bool: True if PAT is valid, False otherwise

Raises:
requests.RequestException: If the request fails
"""
url = f"{self.base_url}/user"
try:
response = self.session.get(url, timeout=30)
return response.status_code == 200
except requests.RequestException as e:
LOG.error(f"PAT validation failed: {e}")
raise requests.RequestException(f"PAT validation failed: {e}")

def _handle_error(self, response: requests.Response) -> None:
"""
Handle HTTP error responses from Bitbucket API.

Args:
response: The HTTP response object

Raises:
requests.HTTPError: If response status code is not 200
"""
if response.status_code != 200:
try:
error_data = response.json()
error = error_data.get("error", "Unknown error")
message = error_data.get("message", "No message provided")
except ValueError:
error = "Invalid response format"
message = response.text or "No error details available"

error_msg = f"Request failed with status {response.status_code}: {error} - {message}"
LOG.error(error_msg)
raise requests.HTTPError(error_msg)

def get_workspace_repos(self, workspace: str, repo_slug: str) -> BitbucketRepo:
"""
Get repository information for a specific workspace and repository.

Args:
workspace: The workspace name
repo_slug: The repository slug

Returns:
BitbucketRepo: Repository information object

Raises:
requests.HTTPError: If the request fails
requests.RequestException: If the request encounters an error
"""
url = f"{self.base_url}/repositories/{workspace}/{repo_slug}"
try:
response = self.session.get(url, timeout=30)
self._handle_error(response)
repo = response.json()
return BitbucketRepo(repo)
except requests.RequestException as e:
LOG.error(f"Failed to get repository {workspace}/{repo_slug}: {e}")
raise

def get_repo_contributors(self, workspace: str, repo_slug: str) -> Dict[str,int]:
"""
Get all contributors for a repository with their contribution counts.

Args:
workspace: The workspace name
repo_slug: The repository slug

Returns:
dict: Dictionary with contributor names as keys and contribution counts as values

Raises:
requests.HTTPError: If the request fails
requests.RequestException: If the request encounters an error
"""
url = f"{self.base_url}/repositories/{workspace}/{repo_slug}/commits"
contributors = {}

try:
while url:
response = self.session.get(url, timeout=30)
self._handle_error(response)

data = response.json()
commits = data.get('values', [])

for commit in commits:
author = commit.get('author', {})
user = author.get('user', {})
display_name = user.get('display_name', 'Unknown')

if display_name in contributors:
contributors[display_name] += 1
else:
contributors[display_name] = 1

url = data.get('next')

return contributors

except requests.RequestException as e:
LOG.error(f"Failed to get contributors for {workspace}/{repo_slug}: {e}")
raise



129 changes: 129 additions & 0 deletions backend/analytics_server/mhq/exapi/models/bitbucket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional

from mhq.utils.time import dt_from_iso_time_string


@dataclass
class BitbucketRepo:
name: str
org_name: str
default_branch: str
idempotency_key: str
slug: str
description: str
web_url: str
languages: Optional[Dict] = None
contributors: Optional[List] = None

def __init__(self, repo: Dict):
self.name = repo.get("name", "")
workspace = repo.get("workspace", {})
self.org_name = workspace.get("slug", workspace.get("name", ""))
self.default_branch = repo.get("mainbranch", {}).get("name", "main")
self.idempotency_key = str(repo.get("uuid", ""))
self.slug = repo.get("slug", "")
self.description = repo.get("description", "")
self.web_url = repo.get("links", {}).get("html", {}).get("href", "")
self.languages = repo.get("language")

def __hash__(self):
return hash(self.idempotency_key)


class BitbucketPRState(Enum):
OPEN = "OPEN"
MERGED = "MERGED"
SUPERSEDED = "SUPERSEDED"
DECLINED = "DECLINED"


@dataclass
class BitbucketPR:
number: int
title: str
url: str
author: str
reviewers: List[str]
state: BitbucketPRState
base_branch: str
head_branch: str
data: Dict
created_at: datetime
updated_at: datetime
merged_at: Optional[datetime] = None
closed_at: Optional[datetime] = None
merge_commit_sha: Optional[str] = None

def __init__(self, pr: Dict):
self.number = pr.get("id", 0)
self.title = pr.get("title", "")
self.url = pr.get("links", {}).get("html", {}).get("href", "")
self.author = pr.get("author", {}).get("display_name", "")
self.reviewers = [
reviewer.get("display_name", "")
for reviewer in pr.get("reviewers", [])
]
state_str = pr.get("state", "OPEN").upper()
try:
self.state = BitbucketPRState(state_str)
except ValueError:

self.state = BitbucketPRState.OPEN
self.base_branch = pr.get("destination", {}).get("branch", {}).get("name", "")
self.head_branch = pr.get("source", {}).get("branch", {}).get("name", "")
self.data = pr
self.created_at = dt_from_iso_time_string(pr.get("created_on", "")) or datetime.now()
self.updated_at = dt_from_iso_time_string(pr.get("updated_on", "")) or datetime.now()

# Parse merge/close dates
if pr.get("merge_commit"):
self.merged_at = self.updated_at
self.merge_commit_sha = pr.get("merge_commit", {}).get("hash", "")

if self.state in [BitbucketPRState.DECLINED, BitbucketPRState.SUPERSEDED]:
self.closed_at = self.updated_at


@dataclass
class BitbucketCommit:
hash: str
message: str
url: str
data: Dict
author_email: str
created_at: datetime

def __init__(self, commit: Dict):
self.hash = commit.get("hash", "")
self.message = commit.get("message", "")
self.url = commit.get("links", {}).get("html", {}).get("href", "")
self.data = commit
self.author_email = commit.get("author", {}).get("raw", "").split("<")[-1].replace(">", "").strip()
self.created_at = dt_from_iso_time_string(commit.get("date", "")) or datetime.now()


class BitbucketReviewState(Enum):
APPROVED = "approved"
CHANGES_REQUESTED = "changes_requested"
COMMENTED = "commented"


@dataclass
class BitbucketReview:
id: str
state: BitbucketReviewState
created_at: datetime
actor_username: str
data: Dict
idempotency_key: str

def __init__(self, review: Dict):
self.id = str(review.get("uuid", ""))
self.state = BitbucketReviewState(review.get("state", "commented"))
self.created_at = dt_from_iso_time_string(review.get("date", "")) or datetime.now()
self.actor_username = review.get("user", {}).get("display_name", "")
self.data = review
self.idempotency_key = self.id
1 change: 1 addition & 0 deletions backend/analytics_server/mhq/service/code/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CODE_INTEGRATION_BUCKET = [
UserIdentityProvider.GITHUB.value,
UserIdentityProvider.GITLAB.value,
UserIdentityProvider.BITBUCKET.value
]


Expand Down
Loading