diff --git a/components/collector/src/source_collectors/__init__.py b/components/collector/src/source_collectors/__init__.py index d24e1b99d8..940cb83c84 100644 --- a/components/collector/src/source_collectors/__init__.py +++ b/components/collector/src/source_collectors/__init__.py @@ -23,6 +23,7 @@ from .bandit.security_warnings import BanditSecurityWarnings from .bandit.source_up_to_dateness import BanditSourceUpToDateness from .bitbucket.inactive_branches import BitbucketInactiveBranches +from .bitbucket.merge_requests import BitbucketMergeRequests 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/merge_requests.py b/components/collector/src/source_collectors/bitbucket/merge_requests.py new file mode 100644 index 0000000000..6374ea4f01 --- /dev/null +++ b/components/collector/src/source_collectors/bitbucket/merge_requests.py @@ -0,0 +1,82 @@ +"""Bitbucket merge requests collector.""" + +from typing import cast + +from collector_utilities.functions import match_string_or_regular_expression +from collector_utilities.type import URL, Value +from model import Entities, Entity, SourceResponses + +from .base import BitbucketProjectBase + +class BitbucketMergeRequests(BitbucketProjectBase): + """Collector for pull requests in Bitbucket.""" + + PAGE_SIZE = 100 # Page size for Bitbucket pagination + + async def _api_url(self) -> URL: + """Override to return the pull requests API.""" + return await self._bitbucket_api_url("pull-requests") + + async def _landing_url(self, responses: SourceResponses) -> URL: + """Extend to add the project pull requests.""" + project = f"projects/{self._parameter('owner')}/repos/{self._parameter('repository')}" + return URL(f"{await super()._landing_url(responses)}/{project}/pull-requests") + + async def _get_source_responses(self, *urls: URL) -> SourceResponses: + """Extend to use Bitbucket pagination, if necessary.""" + nr_merge_requests_to_skip = 0 + responses = await super()._get_source_responses(*urls) + while len((await responses[-1].json())["values"]) == self.PAGE_SIZE: + nr_merge_requests_to_skip += self.PAGE_SIZE + responses.extend(await super()._get_source_responses(URL(f"{urls[0]}&start={nr_merge_requests_to_skip}"))) + return responses + + async def _parse_entities(self, responses: SourceResponses) -> Entities: + """Override to parse the pull requests from the responses.""" + merge_requests = [] + for response in responses: + merge_requests.extend((await response.json())["values"]) + landing_url = (await self._landing_url(responses)) + return Entities([self._create_entity(mr, landing_url) for mr in merge_requests]) + + async def _parse_total(self, responses: SourceResponses) -> Value: + """Override to parse the total number of merge requests from the responses.""" + merge_requests = [len((await response.json())["values"]) for response in responses] + return str(sum(merge_requests)) + + def _create_entity(self, merge_request, landing_url: str) -> Entity: + """Create an entity from a Bitbucket JSON result.""" + return Entity( + key=merge_request["id"], + title=merge_request["title"], + target_branch=merge_request["toRef"], + url=f"{landing_url}/{merge_request['id']}", + state=merge_request["state"], + created=merge_request.get("createdDate"), + closed=merge_request.get("closedDate"), + downvotes=str(self._downvotes(merge_request)), + upvotes=str(self._upvotes(merge_request)), + ) + + def _include_entity(self, entity: Entity) -> bool: + """Return whether the merge request should be counted.""" + request_matches_state = entity["state"] in self._parameter("merge_request_state") + branches = self._parameter("target_branches_to_include") + target_branch = entity["target_branch"] + request_matches_branches = match_string_or_regular_expression(target_branch, branches) if branches else True + # If the required number of upvotes is zero, merge requests are included regardless of how many upvotes they + # actually have. If the required number of upvotes is more than zero then only merge requests that have fewer + # than the minimum number of upvotes are included in the count: + required_upvotes = int(cast(str, self._parameter("upvotes"))) + request_has_fewer_than_min_upvotes = required_upvotes == 0 or int(entity["upvotes"]) < required_upvotes + return request_matches_state and request_matches_branches and request_has_fewer_than_min_upvotes + + @staticmethod + def _downvotes(merge_request) -> int: + """Return the number of downvotes the merge request has.""" + return len([r for r in merge_request.get("reviewers", []) if not r.get("approved", True)]) + + @staticmethod + def _upvotes(merge_request) -> int: + """Return the number of upvotes the merge request has.""" + return len([r for r in merge_request.get("reviewers", []) if r.get("vote", True)]) diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index 89724c3240..db614d5fe2 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "quality-time-app", - "version": "5.22.0-rc.1", + "version": "5.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "quality-time-app", - "version": "5.22.0-rc.1", + "version": "5.22.0", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", diff --git a/components/shared_code/src/shared_data_model/metrics.py b/components/shared_code/src/shared_data_model/metrics.py index 21523e69ef..54ccca729c 100644 --- a/components/shared_code/src/shared_data_model/metrics.py +++ b/components/shared_code/src/shared_data_model/metrics.py @@ -245,7 +245,7 @@ requests that target specific branches, for example the "develop" branch.""", scales=["count", "percentage"], unit=Unit.MERGE_REQUESTS, - sources=["azure_devops", "github", "gitlab", "manual_number"], + sources=["azure_devops", "bitbucket", "github", "gitlab", "manual_number"], tags=[Tag.CI], ), "metrics": Metric( 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 38ec02f139..7229bed299 100644 --- a/components/shared_code/src/shared_data_model/sources/bitbucket.py +++ b/components/shared_code/src/shared_data_model/sources/bitbucket.py @@ -11,8 +11,16 @@ Days, PrivateToken, StringParameter, + MergeRequestState, + MultipleChoiceParameter, + TargetBranchesToInclude ) +ALL_GITLAB_METRICS = [ + "inactive_branches", + "merge_requests", +] + BITBUCKET_BRANCH_HELP_URL = HttpUrl("https://confluence.atlassian.com/bitbucketserver/branches-776639968.html") BITBUCKET = Source( @@ -34,26 +42,26 @@ 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"], + metrics=ALL_GITLAB_METRICS, ), "owner": StringParameter( name="Owner (name of owner of the repository)", short_name="owner", mandatory=True, help_url=HttpUrl("https://support.atlassian.com/bitbucket-cloud/docs/create-a-project/"), - metrics=["inactive_branches"], + metrics=ALL_GITLAB_METRICS, ), "repository": StringParameter( name="Repository (name of the repository)", short_name="repository", help_url=HttpUrl("https://support.atlassian.com/bitbucket-cloud/docs/create-a-git-repository/"), mandatory=True, - metrics=["inactive_branches"], + metrics=ALL_GITLAB_METRICS, ), "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"], + metrics=ALL_GITLAB_METRICS, ), "branches_to_ignore": BranchesToIgnore(help_url=BITBUCKET_BRANCH_HELP_URL), "branch_merge_status": BranchMergeStatus(), @@ -63,6 +71,24 @@ default_value="7", metrics=["inactive_branches"], ), + "merge_request_state": MergeRequestState( + name="Pull request state", + values=["Open", "Merged", "Closed"], + api_values={"Open": "OPEN", "Merged": "MERGED", "Closed": "CLOSED"}, + ), + "review_decision": MultipleChoiceParameter( + name="Review decision", + values=["Approved", "Changes requested", "Review required", "Unknown"], + api_values={ + "Approved": "APPROVED", + "Changes requested": "CHANGES_REQUESTED", + "Review required": "REVIEW_REQUIRED", + "Unknown": "?", + }, + placeholder="all review decisions", + metrics=["merge_requests"], + ), + "target_branches_to_include": TargetBranchesToInclude(help_url=BITBUCKET_BRANCH_HELP_URL), }, entities={ "inactive_branches": Entity( @@ -77,6 +103,20 @@ ), EntityAttribute(name="Merge status"), ], - ) + ), + "merge_requests": Entity( + name="merge request", + attributes=[ + EntityAttribute(name="Merge request", key="title", url="url"), + EntityAttribute(name="Target branch"), + EntityAttribute(name="State"), + EntityAttribute(name="ReviewDecision"), + EntityAttribute(name="Created", type=EntityAttributeType.DATETIME), + EntityAttribute(name="Updated", type=EntityAttributeType.DATETIME), + EntityAttribute(name="Merged", type=EntityAttributeType.DATETIME), + EntityAttribute(name="Comments", type=EntityAttributeType.INTEGER), + EntityAttribute(name="Thumbs up", type=EntityAttributeType.INTEGER), + ], + ), }, )