Skip to content

Commit

Permalink
added bitbucket merge requests collector
Browse files Browse the repository at this point in the history
  • Loading branch information
TunahanGuler committed Jan 20, 2025
1 parent b7dbe63 commit 1e4e878
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 8 deletions.
1 change: 1 addition & 0 deletions components/collector/src/source_collectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)])
4 changes: 2 additions & 2 deletions components/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion components/shared_code/src/shared_data_model/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
50 changes: 45 additions & 5 deletions components/shared_code/src/shared_data_model/sources/bitbucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(),
Expand All @@ -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(
Expand All @@ -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),
],
),
},
)

0 comments on commit 1e4e878

Please sign in to comment.