From f1bf2d496826c58e5e135db657b6e667314e796b Mon Sep 17 00:00:00 2001 From: tguler Date: Tue, 10 Dec 2024 14:34:06 +0100 Subject: [PATCH 1/7] wip bitbucket collector --- .../source_collectors/bitbucket/__init__.py | 0 .../src/source_collectors/bitbucket/base.py | 58 ++++++++++++++ .../bitbucket/inactive_branches.py | 55 +++++++++++++ .../source_collectors/bitbucket/__init__.py | 0 .../tests/source_collectors/bitbucket/base.py | 22 ++++++ .../bitbucket/test_inactive_branches.py | 79 +++++++++++++++++++ 6 files changed, 214 insertions(+) create mode 100644 components/collector/src/source_collectors/bitbucket/__init__.py create mode 100644 components/collector/src/source_collectors/bitbucket/base.py create mode 100644 components/collector/src/source_collectors/bitbucket/inactive_branches.py create mode 100644 components/collector/tests/source_collectors/bitbucket/__init__.py create mode 100644 components/collector/tests/source_collectors/bitbucket/base.py create mode 100644 components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py diff --git a/components/collector/src/source_collectors/bitbucket/__init__.py b/components/collector/src/source_collectors/bitbucket/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/components/collector/src/source_collectors/bitbucket/base.py b/components/collector/src/source_collectors/bitbucket/base.py new file mode 100644 index 0000000000..be6dcfc9da --- /dev/null +++ b/components/collector/src/source_collectors/bitbucket/base.py @@ -0,0 +1,58 @@ +"""Bitbucket collector base classes.""" + +from abc import ABC +from dataclasses import dataclass, fields +from datetime import datetime, timedelta +from typing import cast + +from dateutil.tz import tzutc + +from shared.utils.date_time import now + +from base_collectors import SourceCollector +from collector_utilities.date_time import parse_datetime +from collector_utilities.exceptions import CollectorError +from collector_utilities.functions import add_query, match_string_or_regular_expression +from collector_utilities.type import URL, Job +from model import Entities, Entity, SourceResponses + + +class BitbucketBase(SourceCollector, ABC): + """Base class for Bitbucket collectors.""" + + async def _get_source_responses(self, *urls: URL) -> SourceResponses: + """Extend to follow Bitbucket pagination links, if necessary.""" + all_responses = responses = await super()._get_source_responses(*urls) + while next_urls := await self._next_urls(responses): + # Retrieving consecutive big responses without reading the response hangs the client, see + # https://github.com/aio-libs/aiohttp/issues/2217 + for response in responses: + await response.read() + all_responses.extend(responses := await super()._get_source_responses(*next_urls)) + return all_responses + + def _basic_auth_credentials(self) -> tuple[str, str] | None: + """Override to return None, as the private token is passed as header.""" + return None + + def _headers(self) -> dict[str, str]: + """Extend to add the private token, if any, to the headers.""" + headers = super()._headers() + if private_token := self._parameter("private_token"): + headers["Private-Token"] = str(private_token) + return headers + + async def _next_urls(self, responses: SourceResponses) -> list[URL]: + """Return the next (pagination) links from the responses.""" + return [URL(next_url) for response in responses if (next_url := response.links.get("next", {}).get("url"))] + + +class BitbucketProjectBase(BitbucketBase, ABC): + """Base class for Bitbucket collectors for a specific project.""" + + async def _bitbucket_api_url(self, api: str) -> URL: + """Return a Bitbucket API url for a project, if present in the parameters.""" + url = await super()._api_url() + project = self._parameter("project", quote=True) + api_url = URL(f"{url}/api/v4/projects/{project}" + (f"/{api}" if api else "")) + return add_query(api_url, "per_page=100") \ No newline at end of file diff --git a/components/collector/src/source_collectors/bitbucket/inactive_branches.py b/components/collector/src/source_collectors/bitbucket/inactive_branches.py new file mode 100644 index 0000000000..7ecd5a0707 --- /dev/null +++ b/components/collector/src/source_collectors/bitbucket/inactive_branches.py @@ -0,0 +1,55 @@ +"""Bitbucket inactive branches collector.""" + +from datetime import datetime +from typing import cast + +from base_collectors import BranchType, InactiveBranchesSourceCollector +from collector_utilities.date_time import parse_datetime +from collector_utilities.type import URL +from model import SourceResponses + +from .base import BitbucketProjectBase + + +class BitbucketBranchType(BranchType): + """Bitbucket branch information as returned by the API.""" + + commit: dict[str, str] + default: bool + merged: bool + web_url: str + + +class BitbucketInactiveBranches[Branch: BitbucketBranchType](BitbucketProjectBase, InactiveBranchesSourceCollector): + """Collector for inactive branches.""" + + async def _api_url(self) -> URL: + """Override to return the branches API.""" + return await self._bitbucket_api_url("repository/branches") + + async def _landing_url(self, responses: SourceResponses) -> URL: + """Extend to add the project branches.""" + return URL(f"{await super()._landing_url(responses)}/{self._parameter('project')}/-/branches") + + async def _branches(self, responses: SourceResponses) -> list[Branch]: + """Return a list of branches from the responses.""" + branches = [] + for response in responses: + branches.extend(await response.json()) + return branches + + def _is_default_branch(self, branch: Branch) -> bool: + """Return whether the branch is the default branch.""" + return branch["default"] + + def _is_branch_merged(self, branch: Branch) -> bool: + """Return whether the branch has been merged with the default branch.""" + return branch["merged"] + + def _commit_datetime(self, branch: Branch) -> datetime: + """Override to parse the commit date from the branch.""" + return parse_datetime(branch["commit"]["committed_date"]) + + def _branch_landing_url(self, branch: Branch) -> URL: + """Override to get the landing URL from the branch.""" + return cast(URL, branch.get("web_url") or "") diff --git a/components/collector/tests/source_collectors/bitbucket/__init__.py b/components/collector/tests/source_collectors/bitbucket/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/components/collector/tests/source_collectors/bitbucket/base.py b/components/collector/tests/source_collectors/bitbucket/base.py new file mode 100644 index 0000000000..bb1848c0e1 --- /dev/null +++ b/components/collector/tests/source_collectors/bitbucket/base.py @@ -0,0 +1,22 @@ +"""Bitbucket unit test base classes.""" + +from datetime import datetime + +from dateutil.tz import tzutc + +from tests.source_collectors.source_collector_test_case import SourceCollectorTestCase + + +class BitbucketTestCase(SourceCollectorTestCase): + """Base class for testing Bitbucket collectors.""" + + SOURCE_TYPE = "bitbucket" + LOOKBACK_DAYS = "100000" + + def setUp(self): + """Extend to add generic test fixtures.""" + super().setUp() + self.set_source_parameter("branch", "branch") + self.set_source_parameter("file_path", "file") + self.set_source_parameter("lookback_days", self.LOOKBACK_DAYS) + self.set_source_parameter("project", "namespace/project") \ No newline at end of file diff --git a/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py b/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py new file mode 100644 index 0000000000..25608eee87 --- /dev/null +++ b/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py @@ -0,0 +1,79 @@ +"""Unit tests for the Bitbucket inactive branches collector.""" + +from datetime import datetime + +from dateutil.tz import tzutc + +from .base import BitbucketTestCase + + +class BitbucketInactiveBranchesTest(BitbucketTestCase): + """Unit tests for the inactive branches metric.""" + + METRIC_TYPE = "inactive_branches" + WEB_URL = "https://bitbucket/namespace/project/-/tree/branch" + + def setUp(self): + """Extend to setup fixtures.""" + super().setUp() + self.set_source_parameter("branches_to_ignore", ["ignored_.*"]) + main = self.create_branch("main", default=True) + unmerged = self.create_branch("unmerged_branch") + ignored = self.create_branch("ignored_branch") + active_unmerged = self.create_branch("active_unmerged_branch", active=True) + recently_merged = self.create_branch("merged_branch", merged=True, active=True) + inactive_merged = self.create_branch("merged_branch", merged=True) + self.branches = [main, unmerged, ignored, active_unmerged, recently_merged, inactive_merged] + self.unmerged_branch_entity = self.create_entity("unmerged_branch", merged=False) + self.merged_branch_entity = self.create_entity("merged_branch", merged=True) + self.entities = [self.unmerged_branch_entity, self.merged_branch_entity] + self.landing_url = "https://bitbucket/namespace/project/-/branches" + + def create_branch( + self, name: str, *, default: bool = False, merged: bool = False, active: bool = False + ) -> dict[str, str | bool | dict[str, str]]: + """Create a Bitbucket branch.""" + commit_date = datetime.now(tz=tzutc()).isoformat() if active else "2019-04-02T11:33:04.000+02:00" + return { + "name": name, + "default": default, + "merged": merged, + "web_url": self.WEB_URL, + "commit": {"committed_date": commit_date}, + } + + def create_entity(self, name: str, *, merged: bool) -> dict[str, str]: + """Create an entity.""" + return { + "key": name, + "name": name, + "commit_date": "2019-04-02", + "merge_status": "merged" if merged else "unmerged", + "url": self.WEB_URL, + } + + async def test_inactive_branches(self): + """Test that the number of inactive branches can be measured.""" + response = await self.collect(get_request_json_return_value=self.branches) + self.assert_measurement(response, value="2", entities=self.entities, landing_url=self.landing_url) + + async def test_unmerged_inactive_branches(self): + """Test that the number of unmerged inactive branches can be measured.""" + self.set_source_parameter("branch_merge_status", ["unmerged"]) + response = await self.collect(get_request_json_return_value=self.branches) + self.assert_measurement( + response, value="1", entities=[self.unmerged_branch_entity], landing_url=self.landing_url + ) + + async def test_merged_inactive_branches(self): + """Test that the number of merged inactive branches can be measured.""" + self.set_source_parameter("branch_merge_status", ["merged"]) + response = await self.collect(get_request_json_return_value=self.branches) + self.assert_measurement(response, value="1", entities=[self.merged_branch_entity], landing_url=self.landing_url) + + async def test_pagination(self): + """Test that pagination works.""" + branches = [self.branches[:3], self.branches[3:]] + links = {"next": {"url": "https://bitbucket/next_page"}} + response = await self.collect(get_request_json_side_effect=branches, get_request_links=links) + self.assert_measurement(response, value="2", entities=self.entities, landing_url=self.landing_url) From 41ef1bad3b26f09b3e97a1b8085cf9274238dcf2 Mon Sep 17 00:00:00 2001 From: tguler Date: Fri, 13 Dec 2024 13:23:59 +0100 Subject: [PATCH 2/7] unit tests work --- .../src/source_collectors/__init__.py | 1 + .../src/source_collectors/bitbucket/base.py | 9 --- .../tests/source_collectors/bitbucket/base.py | 4 - .../src/shared_data_model/metrics.py | 2 +- .../src/shared_data_model/sources/__init__.py | 2 + .../shared_data_model/sources/bitbucket.py | 78 +++++++++++++++++++ .../shared_data_model/sources/quality_time.py | 2 + 7 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 components/shared_code/src/shared_data_model/sources/bitbucket.py diff --git a/components/collector/src/source_collectors/__init__.py b/components/collector/src/source_collectors/__init__.py index a766f47858..49eba92bee 100644 --- a/components/collector/src/source_collectors/__init__.py +++ b/components/collector/src/source_collectors/__init__.py @@ -22,6 +22,7 @@ from .azure_devops.user_story_points import AzureDevopsUserStoryPoints from .bandit.security_warnings import BanditSecurityWarnings from .bandit.source_up_to_dateness import BanditSourceUpToDateness +from .bitbucket.inactive_branches import BitbucketInactiveBranches from .calendar.source_up_to_dateness import CalendarSourceUpToDateness from .calendar.time_remaining import CalendarTimeRemaining from .cargo_audit.security_warnings import CargoAuditSecurityWarnings diff --git a/components/collector/src/source_collectors/bitbucket/base.py b/components/collector/src/source_collectors/bitbucket/base.py index be6dcfc9da..9fc15bc522 100644 --- a/components/collector/src/source_collectors/bitbucket/base.py +++ b/components/collector/src/source_collectors/bitbucket/base.py @@ -1,17 +1,8 @@ """Bitbucket collector base classes.""" from abc import ABC -from dataclasses import dataclass, fields -from datetime import datetime, timedelta -from typing import cast - -from dateutil.tz import tzutc - -from shared.utils.date_time import now from base_collectors import SourceCollector -from collector_utilities.date_time import parse_datetime -from collector_utilities.exceptions import CollectorError from collector_utilities.functions import add_query, match_string_or_regular_expression from collector_utilities.type import URL, Job from model import Entities, Entity, SourceResponses diff --git a/components/collector/tests/source_collectors/bitbucket/base.py b/components/collector/tests/source_collectors/bitbucket/base.py index bb1848c0e1..84ef2cc0dd 100644 --- a/components/collector/tests/source_collectors/bitbucket/base.py +++ b/components/collector/tests/source_collectors/bitbucket/base.py @@ -1,9 +1,5 @@ """Bitbucket unit test base classes.""" -from datetime import datetime - -from dateutil.tz import tzutc - from tests.source_collectors.source_collector_test_case import SourceCollectorTestCase diff --git a/components/shared_code/src/shared_data_model/metrics.py b/components/shared_code/src/shared_data_model/metrics.py index 7ad61d4cae..312cd5fc35 100644 --- a/components/shared_code/src/shared_data_model/metrics.py +++ b/components/shared_code/src/shared_data_model/metrics.py @@ -135,7 +135,7 @@ change-your-default-branch).""", unit=Unit.BRANCHES, near_target="5", - sources=["azure_devops", "gitlab", "manual_number"], + sources=["azure_devops", "bitbucket", "gitlab", "manual_number"], tags=[Tag.CI], ), "issues": Metric( diff --git a/components/shared_code/src/shared_data_model/sources/__init__.py b/components/shared_code/src/shared_data_model/sources/__init__.py index 62dc5d5da1..a1a9a61aba 100644 --- a/components/shared_code/src/shared_data_model/sources/__init__.py +++ b/components/shared_code/src/shared_data_model/sources/__init__.py @@ -4,6 +4,7 @@ from .axe import AXE_CORE, AXE_CSV, AXE_HTML_REPORTER from .azure_devops import AZURE_DEVOPS from .bandit import BANDIT +from .bitbucket import BITBUCKET from .calendar import CALENDAR from .cargo_audit import CARGO_AUDIT from .cloc import CLOC @@ -49,6 +50,7 @@ "axecsv": AXE_CSV, "azure_devops": AZURE_DEVOPS, "bandit": BANDIT, + "bitbucket": BITBUCKET, "calendar": CALENDAR, "cargo_audit": CARGO_AUDIT, "cloc": CLOC, diff --git a/components/shared_code/src/shared_data_model/sources/bitbucket.py b/components/shared_code/src/shared_data_model/sources/bitbucket.py new file mode 100644 index 0000000000..023d4b75cb --- /dev/null +++ b/components/shared_code/src/shared_data_model/sources/bitbucket.py @@ -0,0 +1,78 @@ +"""Bitbucket source.""" + +from pydantic import HttpUrl + +from shared_data_model.meta.entity import Color, Entity, EntityAttribute, EntityAttributeType +from shared_data_model.meta.source import Source +from shared_data_model.parameters import ( + URL, + Branch, + Branches, + BranchesToIgnore, + BranchMergeStatus, + Days, + FailureType, + MergeRequestState, + MultipleChoiceParameter, + MultipleChoiceWithAdditionParameter, + PrivateToken, + ResultType, + StringParameter, + TargetBranchesToInclude, +) + +BITBUCKET_BRANCH_HELP_URL = HttpUrl("https://confluence.atlassian.com/bitbucketserver/branches-776639968.html") + +BITBUCKET = Source( + name="Bitbucket", + description="Bitbucket Cloud is a Git based code hosting and collaboration tool, built for teams." + "Bitbucket's best-in-class Jira and Trello integrations are designed to bring the entire software team together to execute on a project.", + url=HttpUrl("https://bitbucket.org/product/guides/getting-started/overview#a-brief-overview-of-bitbucket/"), + documentation={}, + parameters={ + "url": URL( + name="Bitbucket instance URL", + help="URL of the Bitbucket instance, with port if necessary, but without path. For example, " + "'https://bitbucket.org'.", + validate_on=["private_token"], + metrics=["inactive_branches"], + ), + "project": StringParameter( + name="Project (name with namespace or id)", + short_name="project", + mandatory=True, + help_url=HttpUrl("https://support.atlassian.com/bitbucket-cloud/docs/create-a-project/"), + metrics=[ + "inactive_branches", + ], + ), + "private_token": PrivateToken( + name="Private token (with read_api scope)", + help_url=HttpUrl("https://support.atlassian.com/bitbucket-cloud/docs/create-a-repository-access-token/"), + metrics=["inactive_branches"], + ), + "branches_to_ignore": BranchesToIgnore(help_url=BITBUCKET_BRANCH_HELP_URL), + "branch_merge_status": BranchMergeStatus(), + "inactive_days": Days( + name="Number of days since last commit after which to consider branches inactive", + short_name="number of days since last commit", + default_value="7", + metrics=["inactive_branches"], + ), + }, + entities={ + "inactive_branches": Entity( + name="branch", + name_plural="branches", + attributes=[ + EntityAttribute(name="Branch name", key="name", url="url"), + EntityAttribute( + name="Date of most recent commit", + key="commit_date", + type=EntityAttributeType.DATE, + ), + EntityAttribute(name="Merge status"), + ], + ) + }, +) diff --git a/components/shared_code/src/shared_data_model/sources/quality_time.py b/components/shared_code/src/shared_data_model/sources/quality_time.py index b392258fd9..278d09cdd5 100644 --- a/components/shared_code/src/shared_data_model/sources/quality_time.py +++ b/components/shared_code/src/shared_data_model/sources/quality_time.py @@ -172,6 +172,7 @@ "Axe-core", "Azure DevOps Server", "Bandit", + "Bitbucket", "Calendar date", "Cargo Audit", "Checkmarx CxSAST", @@ -223,6 +224,7 @@ "Axe-core": "axe_core", "Azure DevOps Server": "azure_devops", "Bandit": "bandit", + "Bitbucket": "bitbucket", "Calendar date": "calendar", "Cargo Audit": "cargo_audit", "Checkmarx CxSAST": "cxsast", From 0b46544f4ba3e0d79c9184fed434d2032a88d5fc Mon Sep 17 00:00:00 2001 From: tguler Date: Mon, 16 Dec 2024 11:53:22 +0100 Subject: [PATCH 3/7] 100% coverage --- .../collector/src/source_collectors/bitbucket/base.py | 7 ------- .../source_collectors/bitbucket/test_inactive_branches.py | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/components/collector/src/source_collectors/bitbucket/base.py b/components/collector/src/source_collectors/bitbucket/base.py index 9fc15bc522..395dfc0574 100644 --- a/components/collector/src/source_collectors/bitbucket/base.py +++ b/components/collector/src/source_collectors/bitbucket/base.py @@ -26,13 +26,6 @@ def _basic_auth_credentials(self) -> tuple[str, str] | None: """Override to return None, as the private token is passed as header.""" return None - def _headers(self) -> dict[str, str]: - """Extend to add the private token, if any, to the headers.""" - headers = super()._headers() - if private_token := self._parameter("private_token"): - headers["Private-Token"] = str(private_token) - return headers - async def _next_urls(self, responses: SourceResponses) -> list[URL]: """Return the next (pagination) links from the responses.""" return [URL(next_url) for response in responses if (next_url := response.links.get("next", {}).get("url"))] diff --git a/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py b/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py index 25608eee87..c2a9c05f54 100644 --- a/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py +++ b/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py @@ -3,6 +3,7 @@ from datetime import datetime from dateutil.tz import tzutc +from unittest.mock import AsyncMock from .base import BitbucketTestCase @@ -76,4 +77,4 @@ async def test_pagination(self): branches = [self.branches[:3], self.branches[3:]] links = {"next": {"url": "https://bitbucket/next_page"}} response = await self.collect(get_request_json_side_effect=branches, get_request_links=links) - self.assert_measurement(response, value="2", entities=self.entities, landing_url=self.landing_url) + self.assert_measurement(response, value="2", entities=self.entities, landing_url=self.landing_url) \ No newline at end of file From bc79835b3fef0e2ac097c41bd16d8aa86abd479c Mon Sep 17 00:00:00 2001 From: tguler Date: Tue, 17 Dec 2024 13:59:19 +0100 Subject: [PATCH 4/7] added bitbucket icon --- .../src/shared_data_model/logos/bitbucket.png | Bin 0 -> 34809 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 components/shared_code/src/shared_data_model/logos/bitbucket.png diff --git a/components/shared_code/src/shared_data_model/logos/bitbucket.png b/components/shared_code/src/shared_data_model/logos/bitbucket.png new file mode 100644 index 0000000000000000000000000000000000000000..ef5cdb0847f9398851bc9a3f14660d978419deea GIT binary patch literal 34809 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�a+-u7dU-bY>FTiG%0 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 G!v6uUfB;JX literal 0 HcmV?d00001 From d41db8c48c32e272eed4409a680861a610b52971 Mon Sep 17 00:00:00 2001 From: tguler Date: Wed, 18 Dec 2024 15:47:02 +0100 Subject: [PATCH 5/7] feedback resolved --- .../src/source_collectors/bitbucket/inactive_branches.py | 1 + .../shared_code/src/shared_data_model/sources/bitbucket.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/collector/src/source_collectors/bitbucket/inactive_branches.py b/components/collector/src/source_collectors/bitbucket/inactive_branches.py index 7ecd5a0707..d452e106bf 100644 --- a/components/collector/src/source_collectors/bitbucket/inactive_branches.py +++ b/components/collector/src/source_collectors/bitbucket/inactive_branches.py @@ -14,6 +14,7 @@ class BitbucketBranchType(BranchType): """Bitbucket branch information as returned by the API.""" + "Contains commit(ed)-date" commit: dict[str, str] default: bool merged: bool diff --git a/components/shared_code/src/shared_data_model/sources/bitbucket.py b/components/shared_code/src/shared_data_model/sources/bitbucket.py index 023d4b75cb..073d0efd2d 100644 --- a/components/shared_code/src/shared_data_model/sources/bitbucket.py +++ b/components/shared_code/src/shared_data_model/sources/bitbucket.py @@ -25,8 +25,7 @@ BITBUCKET = Source( name="Bitbucket", - description="Bitbucket Cloud is a Git based code hosting and collaboration tool, built for teams." - "Bitbucket's best-in-class Jira and Trello integrations are designed to bring the entire software team together to execute on a project.", + description="Bitbucket is a Git-based source code hosting and collaboration tool, built for teams.", url=HttpUrl("https://bitbucket.org/product/guides/getting-started/overview#a-brief-overview-of-bitbucket/"), documentation={}, parameters={ From 1679c0cc7e4c33d6d036b2fc312a28297ecccb9a Mon Sep 17 00:00:00 2001 From: tguler Date: Thu, 19 Dec 2024 15:08:46 +0100 Subject: [PATCH 6/7] cleanup --- .../collector/src/source_collectors/bitbucket/base.py | 8 ++++---- .../tests/source_collectors/bitbucket/base.py | 2 +- .../bitbucket/test_inactive_branches.py | 3 +-- .../src/shared_data_model/sources/bitbucket.py | 10 +--------- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/components/collector/src/source_collectors/bitbucket/base.py b/components/collector/src/source_collectors/bitbucket/base.py index 395dfc0574..fd4bd3634f 100644 --- a/components/collector/src/source_collectors/bitbucket/base.py +++ b/components/collector/src/source_collectors/bitbucket/base.py @@ -3,9 +3,9 @@ from abc import ABC from base_collectors import SourceCollector -from collector_utilities.functions import add_query, match_string_or_regular_expression -from collector_utilities.type import URL, Job -from model import Entities, Entity, SourceResponses +from collector_utilities.functions import add_query +from collector_utilities.type import URL +from model import SourceResponses class BitbucketBase(SourceCollector, ABC): @@ -39,4 +39,4 @@ async def _bitbucket_api_url(self, api: str) -> URL: url = await super()._api_url() project = self._parameter("project", quote=True) api_url = URL(f"{url}/api/v4/projects/{project}" + (f"/{api}" if api else "")) - return add_query(api_url, "per_page=100") \ No newline at end of file + return add_query(api_url, "per_page=100") diff --git a/components/collector/tests/source_collectors/bitbucket/base.py b/components/collector/tests/source_collectors/bitbucket/base.py index 84ef2cc0dd..3ae483241e 100644 --- a/components/collector/tests/source_collectors/bitbucket/base.py +++ b/components/collector/tests/source_collectors/bitbucket/base.py @@ -15,4 +15,4 @@ def setUp(self): self.set_source_parameter("branch", "branch") self.set_source_parameter("file_path", "file") self.set_source_parameter("lookback_days", self.LOOKBACK_DAYS) - self.set_source_parameter("project", "namespace/project") \ No newline at end of file + self.set_source_parameter("project", "namespace/project") diff --git a/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py b/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py index c2a9c05f54..25608eee87 100644 --- a/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py +++ b/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py @@ -3,7 +3,6 @@ from datetime import datetime from dateutil.tz import tzutc -from unittest.mock import AsyncMock from .base import BitbucketTestCase @@ -77,4 +76,4 @@ async def test_pagination(self): branches = [self.branches[:3], self.branches[3:]] links = {"next": {"url": "https://bitbucket/next_page"}} response = await self.collect(get_request_json_side_effect=branches, get_request_links=links) - self.assert_measurement(response, value="2", entities=self.entities, landing_url=self.landing_url) \ No newline at end of file + self.assert_measurement(response, value="2", entities=self.entities, landing_url=self.landing_url) diff --git a/components/shared_code/src/shared_data_model/sources/bitbucket.py b/components/shared_code/src/shared_data_model/sources/bitbucket.py index 073d0efd2d..16daac499d 100644 --- a/components/shared_code/src/shared_data_model/sources/bitbucket.py +++ b/components/shared_code/src/shared_data_model/sources/bitbucket.py @@ -2,23 +2,15 @@ from pydantic import HttpUrl -from shared_data_model.meta.entity import Color, Entity, EntityAttribute, EntityAttributeType +from shared_data_model.meta.entity import Entity, EntityAttribute, EntityAttributeType from shared_data_model.meta.source import Source from shared_data_model.parameters import ( URL, - Branch, - Branches, BranchesToIgnore, BranchMergeStatus, Days, - FailureType, - MergeRequestState, - MultipleChoiceParameter, - MultipleChoiceWithAdditionParameter, PrivateToken, - ResultType, StringParameter, - TargetBranchesToInclude, ) BITBUCKET_BRANCH_HELP_URL = HttpUrl("https://confluence.atlassian.com/bitbucketserver/branches-776639968.html") From 487ae2ea80b978d7bfceda17602f4da01cdfd58b Mon Sep 17 00:00:00 2001 From: tguler Date: Mon, 23 Dec 2024 15:09:45 +0100 Subject: [PATCH 7/7] added authorization and changed api url --- .../src/source_collectors/bitbucket/base.py | 13 ++++++++++--- .../bitbucket/inactive_branches.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/components/collector/src/source_collectors/bitbucket/base.py b/components/collector/src/source_collectors/bitbucket/base.py index fd4bd3634f..e4aac43e8a 100644 --- a/components/collector/src/source_collectors/bitbucket/base.py +++ b/components/collector/src/source_collectors/bitbucket/base.py @@ -26,6 +26,13 @@ def _basic_auth_credentials(self) -> tuple[str, str] | None: """Override to return None, as the private token is passed as header.""" return None + def _headers(self) -> dict[str, str]: + """Extend to add the private token, if any, to the headers.""" + headers = super()._headers() + if private_token := self._parameter("private_token"): + headers["Authorization"] = "Bearer " + str(private_token) + return headers + async def _next_urls(self, responses: SourceResponses) -> list[URL]: """Return the next (pagination) links from the responses.""" return [URL(next_url) for response in responses if (next_url := response.links.get("next", {}).get("url"))] @@ -37,6 +44,6 @@ class BitbucketProjectBase(BitbucketBase, ABC): async def _bitbucket_api_url(self, api: str) -> URL: """Return a Bitbucket API url for a project, if present in the parameters.""" url = await super()._api_url() - project = self._parameter("project", quote=True) - api_url = URL(f"{url}/api/v4/projects/{project}" + (f"/{api}" if api else "")) - return add_query(api_url, "per_page=100") + project = self._parameter("project") + api_url = URL(f"{url}/rest/api/1.0/projects/{project}" + (f"/{api}" if api else "")) + return add_query(api_url, "pagelen=100") diff --git a/components/collector/src/source_collectors/bitbucket/inactive_branches.py b/components/collector/src/source_collectors/bitbucket/inactive_branches.py index d452e106bf..8b139370c2 100644 --- a/components/collector/src/source_collectors/bitbucket/inactive_branches.py +++ b/components/collector/src/source_collectors/bitbucket/inactive_branches.py @@ -26,7 +26,7 @@ class BitbucketInactiveBranches[Branch: BitbucketBranchType](BitbucketProjectBas async def _api_url(self) -> URL: """Override to return the branches API.""" - return await self._bitbucket_api_url("repository/branches") + return await self._bitbucket_api_url("branches") async def _landing_url(self, responses: SourceResponses) -> URL: """Extend to add the project branches."""