Skip to content

Commit c4a5247

Browse files
committed
feat(bitbucket): implement Bitbucket API service and ETL handler for repository management
1 parent 26f4adc commit c4a5247

File tree

6 files changed

+319
-0
lines changed

6 files changed

+319
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import requests
2+
from typing import Optional, Dict, Any
3+
4+
from mhq.utils.log import LOG
5+
from mhq.exapi.models.bitbucket import BitbucketRepo
6+
7+
class BitbucketApiService:
8+
def __init__(self, access_token: str):
9+
self._token = access_token
10+
self.base_url = "https://api.bitbucket.org/2.0"
11+
self.headers = {"Authorization": f"Basic {self._token}"}
12+
self.session = requests.Session()
13+
self.session.headers.update(self.headers)
14+
15+
def check_pat(self) -> bool:
16+
"""
17+
Checks if Personal Access Token is valid.
18+
19+
Returns:
20+
bool: True if PAT is valid, False otherwise
21+
22+
Raises:
23+
requests.RequestException: If the request fails
24+
"""
25+
url = f"{self.base_url}/user"
26+
try:
27+
response = self.session.get(url, timeout=30)
28+
return response.status_code == 200
29+
except requests.RequestException as e:
30+
LOG.error(f"PAT validation failed: {e}")
31+
raise requests.RequestException(f"PAT validation failed: {e}")
32+
33+
def _handle_error(self, response: requests.Response) -> None:
34+
"""
35+
Handle HTTP error responses from Bitbucket API.
36+
37+
Args:
38+
response: The HTTP response object
39+
40+
Raises:
41+
requests.HTTPError: If response status code is not 200
42+
"""
43+
if response.status_code != 200:
44+
try:
45+
error_data = response.json()
46+
error = error_data.get("error", "Unknown error")
47+
message = error_data.get("message", "No message provided")
48+
except ValueError:
49+
error = "Invalid response format"
50+
message = response.text or "No error details available"
51+
52+
error_msg = f"Request failed with status {response.status_code}: {error} - {message}"
53+
LOG.error(error_msg)
54+
raise requests.HTTPError(error_msg)
55+
56+
def get_workspace_repos(self, workspace: str, repo_slug: str) -> BitbucketRepo:
57+
"""
58+
Get repository information for a specific workspace and repository.
59+
60+
Args:
61+
workspace: The workspace name
62+
repo_slug: The repository slug
63+
64+
Returns:
65+
BitbucketRepo: Repository information object
66+
67+
Raises:
68+
requests.HTTPError: If the request fails
69+
requests.RequestException: If the request encounters an error
70+
"""
71+
url = f"{self.base_url}/repositories/{workspace}/{repo_slug}"
72+
try:
73+
response = self.session.get(url, timeout=30)
74+
self._handle_error(response)
75+
repo = response.json()
76+
return BitbucketRepo(repo)
77+
except requests.RequestException as e:
78+
LOG.error(f"Failed to get repository {workspace}/{repo_slug}: {e}")
79+
raise
80+
81+
def get_repo_contributors(self, workspace: str, repo_slug: str) -> Dict[str,int]:
82+
"""
83+
Get all contributors for a repository with their contribution counts.
84+
85+
Args:
86+
workspace: The workspace name
87+
repo_slug: The repository slug
88+
89+
Returns:
90+
dict: Dictionary with contributor names as keys and contribution counts as values
91+
92+
Raises:
93+
requests.HTTPError: If the request fails
94+
requests.RequestException: If the request encounters an error
95+
"""
96+
url = f"{self.base_url}/repositories/{workspace}/{repo_slug}/commits"
97+
contributors = {}
98+
99+
try:
100+
while url:
101+
response = self.session.get(url, timeout=30)
102+
self._handle_error(response)
103+
104+
data = response.json()
105+
commits = data.get('values', [])
106+
107+
for commit in commits:
108+
author = commit.get('author', {})
109+
user = author.get('user', {})
110+
display_name = user.get('display_name', 'Unknown')
111+
112+
if display_name in contributors:
113+
contributors[display_name] += 1
114+
else:
115+
contributors[display_name] = 1
116+
117+
url = data.get('next')
118+
119+
return contributors
120+
121+
except requests.RequestException as e:
122+
LOG.error(f"Failed to get contributors for {workspace}/{repo_slug}: {e}")
123+
raise
124+
125+
126+
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from dataclasses import dataclass
2+
from datetime import datetime
3+
from enum import Enum
4+
from typing import Dict, List, Optional
5+
6+
from mhq.utils.time import dt_from_iso_time_string
7+
8+
9+
@dataclass
10+
class BitbucketRepo:
11+
name: str
12+
org_name: str
13+
default_branch: str
14+
idempotency_key: str
15+
slug: str
16+
description: str
17+
web_url: str
18+
languages: Optional[Dict] = None
19+
contributors: Optional[List] = None
20+
21+
def __init__(self, repo: Dict):
22+
self.name = repo.get("name", "")
23+
self.org_name = repo.get("workspace", {}).get("name", "")
24+
self.default_branch = repo.get("mainbranch", {}).get("name", "")
25+
self.idempotency_key = str(repo.get("uuid", ""))
26+
self.slug = repo.get("slug", "")
27+
self.description = repo.get("description", "")
28+
self.web_url = repo.get("links", {}).get("html", {}).get("href", "")
29+
self.languages = repo.get("language")
30+
31+
def __hash__(self):
32+
return hash(self.idempotency_key)

backend/analytics_server/mhq/service/code/integration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
CODE_INTEGRATION_BUCKET = [
77
UserIdentityProvider.GITHUB.value,
88
UserIdentityProvider.GITLAB.value,
9+
UserIdentityProvider.BITBUCKET.value
910
]
1011

1112

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import uuid
2+
from datetime import datetime
3+
from typing import List, Dict, Optional, Tuple, Set
4+
5+
import pytz
6+
7+
from mhq.exapi.models.bitbucket import BitbucketRepo
8+
from mhq.exapi.bitbucket import BitbucketApiService
9+
from mhq.exapi.github import GithubApiService
10+
from mhq.service.code.sync.etl_code_analytics import CodeETLAnalyticsService
11+
from mhq.service.code.sync.etl_provider_handler import CodeProviderETLHandler
12+
from mhq.store.models import UserIdentityProvider
13+
from mhq.store.models.code import (
14+
OrgRepo,
15+
PullRequestState,
16+
PullRequest,
17+
PullRequestCommit,
18+
PullRequestEvent,
19+
PullRequestEventType,
20+
PullRequestRevertPRMapping,
21+
CodeProvider,
22+
)
23+
from mhq.store.repos.code import CodeRepoService
24+
from mhq.store.repos.core import CoreRepoService
25+
from mhq.utils.log import LOG
26+
from mhq.utils.time import time_now, ISO_8601_DATE_FORMAT
27+
28+
PR_PROCESSING_CHUNK_SIZE = 100
29+
30+
31+
class BitbucketETLHandler(CodeProviderETLHandler):
32+
"""Handler for Bitbucket ETL operations."""
33+
34+
def __init__(
35+
self,
36+
org_id: str,
37+
bitbucket_api_service: BitbucketApiService,
38+
code_repo_service: CodeRepoService,
39+
code_etl_analytics_service: CodeETLAnalyticsService,
40+
# bitbucket_revert_pr_sync_handler: RevertPRsGitHubSyncHandler,
41+
):
42+
self.org_id = org_id
43+
self._api = bitbucket_api_service
44+
self.code_repo_service = code_repo_service
45+
self.code_etl_analytics_service : CodeETLAnalyticsService = (
46+
code_etl_analytics_service
47+
)
48+
self.provider = CodeProvider.BITBUCKET.value
49+
# self.bitbucket_revert_pr_sync_handler: RevertPRsGitHubSyncHandler = (
50+
# github_revert_pr_sync_handler
51+
# )
52+
53+
def check_pat_validity(self) -> bool:
54+
"""Check if the Bitbucket Personal Access Token is valid.
55+
56+
Returns:
57+
bool: True if the PAT is valid.
58+
59+
Raises:
60+
Exception: If the Bitbucket credentials are invalid.
61+
"""
62+
is_valid = self._api.check_pat()
63+
if not is_valid:
64+
raise Exception("Bitbucket credentials are invalid. Please check username or password.")
65+
return is_valid
66+
67+
def get_org_repos(self, org_repos: List[OrgRepo]) -> List[OrgRepo]:
68+
"""Get organization repositories from Bitbucket API.
69+
70+
Args:
71+
org_repos: List of organization repositories to fetch.
72+
73+
Returns:
74+
List of processed OrgRepo objects.
75+
"""
76+
bitbucket_repos: List[BitbucketRepo] = []
77+
for org_repo in org_repos:
78+
workspace = org_repo.org_name
79+
repo_slug = org_repo.name
80+
try:
81+
bitbucket_repo = self._api.get_workspace_repos(workspace, repo_slug)
82+
bitbucket_repos.append(bitbucket_repo)
83+
except Exception as e:
84+
LOG.error(f"Error getting Bitbucket repository {workspace}/{repo_slug}: {e}")
85+
continue
86+
repo_idempotency_key_org_repo_map = {
87+
org_repo.idempotency_key: org_repo for org_repo in org_repos
88+
}
89+
90+
return [
91+
self._process_bitbucket_repo(
92+
repo_idempotency_key_org_repo_map.get(str(bitbucket_repo.idempotency_key)),
93+
bitbucket_repo
94+
)
95+
for bitbucket_repo in bitbucket_repos
96+
if repo_idempotency_key_org_repo_map.get(str(bitbucket_repo.idempotency_key))
97+
]
98+
99+
def _process_bitbucket_repo(
100+
self, org_repo: OrgRepo, bitbucket_repo: BitbucketRepo
101+
) -> OrgRepo:
102+
"""Process a Bitbucket repository into an OrgRepo object.
103+
104+
Args:
105+
org_repo: Original organization repository.
106+
bitbucket_repo: Bitbucket repository data.
107+
108+
Returns:
109+
Processed OrgRepo object.
110+
"""
111+
return OrgRepo(
112+
id=org_repo.id,
113+
org_id=self.org_id,
114+
name=bitbucket_repo.name,
115+
provider=self.provider,
116+
org_name=bitbucket_repo.org_name,
117+
default_branch=bitbucket_repo.default_branch,
118+
language=bitbucket_repo.languages,
119+
contributors=self._api.get_repo_contributors(
120+
bitbucket_repo.org_name, bitbucket_repo.name
121+
),
122+
idempotency_key=str(bitbucket_repo.idempotency_key),
123+
slug=bitbucket_repo.name,
124+
updated_at=time_now(),
125+
)
126+
127+
128+
129+
def _get_access_token(org_id: str) -> Optional[str]:
130+
"""Retrieve access token for the given organization."""
131+
core_repo_service = CoreRepoService()
132+
access_token = core_repo_service.get_access_token(
133+
org_id, UserIdentityProvider.BITBUCKET
134+
)
135+
136+
if not access_token:
137+
LOG.error(
138+
f"Access token not found for org {org_id} and provider "
139+
f"{UserIdentityProvider.BITBUCKET.value}"
140+
)
141+
142+
return access_token
143+
144+
145+
def get_bitbucket_etl_handler(org_id: str) -> BitbucketETLHandler:
146+
"""Factory function to create a BitbucketETLHandler instance."""
147+
access_token = _get_access_token(org_id)
148+
149+
return BitbucketETLHandler(
150+
org_id=org_id,
151+
bitbucket_api_service=BitbucketApiService(access_token),
152+
code_repo_service=CodeRepoService(),
153+
code_etl_analytics_service=CodeETLAnalyticsService(),
154+
)

backend/analytics_server/mhq/service/code/sync/etl_code_factory.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
from mhq.utils.log import LOG
12
from mhq.service.code.sync.etl_gitlab_handler import get_gitlab_etl_handler
23
from mhq.service.code.sync.etl_github_handler import get_github_etl_handler
4+
from mhq.service.code.sync.etl_bitbucket_handler import get_bitbucket_etl_handler
35
from mhq.service.code.sync.etl_provider_handler import CodeProviderETLHandler
46
from mhq.store.models.code import CodeProvider
57

@@ -15,4 +17,7 @@ def __call__(self, provider: str) -> CodeProviderETLHandler:
1517
if provider == CodeProvider.GITLAB.value:
1618
return get_gitlab_etl_handler(self.org_id)
1719

20+
if provider == CodeProvider.BITBUCKET.value:
21+
return get_bitbucket_etl_handler(self.org_id)
22+
1823
raise NotImplementedError(f"Unknown provider - {provider}")

backend/analytics_server/mhq/store/models/integrations/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
class UserIdentityProvider(Enum):
55
GITHUB = "github"
66
GITLAB = "gitlab"
7+
BITBUCKET = "bitbucket"
78

89
@classmethod
910
def get_enum(self, provider: str):

0 commit comments

Comments
 (0)