From 5593f9a1509362a9d773540945846d9bb9e3361f Mon Sep 17 00:00:00 2001 From: tunahanguler1 Date: Wed, 15 Jan 2025 17:50:40 +0100 Subject: [PATCH] 10083 bitbucket collector (#10522) Add support for measuring inactive branches using Bitbucket as source. --------- Co-authored-by: tguler --- components/collector/.vulture_ignore_list.py | 2 + .../src/source_collectors/__init__.py | 1 + .../source_collectors/bitbucket/__init__.py | 0 .../src/source_collectors/bitbucket/base.py | 33 +++++++ .../bitbucket/inactive_branches.py | 85 +++++++++++++++++ .../source_collectors/bitbucket/__init__.py | 0 .../tests/source_collectors/bitbucket/base.py | 16 ++++ .../bitbucket/test_inactive_branches.py | 90 ++++++++++++++++++ .../src/shared_data_model/logos/bitbucket.png | Bin 0 -> 34809 bytes .../src/shared_data_model/metrics.py | 2 +- .../src/shared_data_model/sources/__init__.py | 2 + .../shared_data_model/sources/bitbucket.py | 82 ++++++++++++++++ .../shared_data_model/sources/quality_time.py | 2 + docs/src/changelog.md | 1 + 14 files changed, 315 insertions(+), 1 deletion(-) 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 create mode 100644 components/shared_code/src/shared_data_model/logos/bitbucket.png create mode 100644 components/shared_code/src/shared_data_model/sources/bitbucket.py diff --git a/components/collector/.vulture_ignore_list.py b/components/collector/.vulture_ignore_list.py index d264b68aeb..a14f4aa291 100644 --- a/components/collector/.vulture_ignore_list.py +++ b/components/collector/.vulture_ignore_list.py @@ -21,6 +21,8 @@ AzureDevopsUserStoryPoints # unused class (src/source_collectors/azure_devops/user_story_points.py:12) BanditSecurityWarnings # unused class (src/source_collectors/bandit/security_warnings.py:10) BanditSourceUpToDateness # unused class (src/source_collectors/bandit/source_up_to_dateness.py:10) +last_commit # unused variable (src/source_collectors/bitbucket/inactive_branches.py:32) +BitbucketInactiveBranches # unused class (src/source_collectors/bitbucket/inactive_branches.py:35) CalendarSourceUpToDateness # unused class (src/source_collectors/calendar/source_up_to_dateness.py:13) CalendarTimeRemaining # unused class (src/source_collectors/calendar/time_remaining.py:13) kind # unused variable (src/source_collectors/cargo_audit/security_warnings.py:42) diff --git a/components/collector/src/source_collectors/__init__.py b/components/collector/src/source_collectors/__init__.py index d270dfafb0..d24e1b99d8 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/__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..4ce76284d7 --- /dev/null +++ b/components/collector/src/source_collectors/bitbucket/base.py @@ -0,0 +1,33 @@ +"""Bitbucket collector base classes.""" + +from abc import ABC + +from base_collectors import SourceCollector +from collector_utilities.functions import add_query +from collector_utilities.type import URL + + +class BitbucketBase(SourceCollector, ABC): + """Base class for Bitbucket collectors.""" + + 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 + + +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 = f"{self._parameter('owner')}/repos/{self._parameter('repository')}" + api_url = URL(f"{url}/rest/api/1.0/projects/{project}" + (f"/{api}" if api else "")) + return add_query(api_url, "limit=100&details=true") 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..57fca64837 --- /dev/null +++ b/components/collector/src/source_collectors/bitbucket/inactive_branches.py @@ -0,0 +1,85 @@ +"""Bitbucket inactive branches collector.""" + +from datetime import datetime +from typing import cast + +from base_collectors import BranchType, InactiveBranchesSourceCollector +from collector_utilities.date_time import datetime_from_timestamp +from collector_utilities.exceptions import NotFoundError +from collector_utilities.type import URL +from model import SourceResponses + +from .base import BitbucketProjectBase + + +class BitbucketBranchInfoError(NotFoundError): + """Bitbucket branch info is missing.""" + + def __init__(self, project: str) -> None: + tip = ( + "Please check if the repository (name with owner) and access token (with repo scope) are " + "configured correctly." + ) + super().__init__("Branch info for repository", project, extra=tip) + + +class BitbucketBranchType(BranchType): + """Bitbucket branch information as returned by the API.""" + + id: str + default: bool + last_commit: datetime + + +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("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')}/browse") + + async def _branches(self, responses: SourceResponses) -> list[BitbucketBranchType]: + """Return a list of branches from the responses.""" + branches = [] + metadata = "com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata" + for response in responses: + json = await response.json() + branches.extend( + [ + BitbucketBranchType( + name=branch["displayId"], + default=branch["isDefault"], + last_commit=datetime_from_timestamp(branch["metadata"][metadata]["committerTimestamp"]), + id=branch["id"], + ) + for branch in json["values"] + ] + ) + if len(branches) == 0: + project = f"projects/{self._parameter('owner')}/repos/{self._parameter('repository')}" + raise BitbucketBranchInfoError(project) + 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.""" + """The merged value is always set to false because the Bitbucket API does not include a merged field.""" + return False + + def _commit_datetime(self, branch: Branch) -> datetime: + """Override to parse the commit date from the branch.""" + return branch["last_commit"] + + def _branch_landing_url(self, branch: Branch) -> URL: + """Override to get the landing URL from the branch.""" + instance_url = super()._parameter("url") + project = f"projects/{self._parameter('owner')}/repos/{self._parameter('repository')}/browse?at=" + branch_id = str(branch.get("id")).lstrip("/") + return cast(URL, f"{instance_url}/{project}{branch_id 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..4032ddf636 --- /dev/null +++ b/components/collector/tests/source_collectors/bitbucket/base.py @@ -0,0 +1,16 @@ +"""Bitbucket unit test base classes.""" + +from tests.source_collectors.source_collector_test_case import SourceCollectorTestCase + + +class BitbucketTestCase(SourceCollectorTestCase): + """Base class for testing Bitbucket collectors.""" + + SOURCE_TYPE = "bitbucket" + + def setUp(self): + """Extend to add generic test fixtures.""" + super().setUp() + self.set_source_parameter("branch", "branch") + self.set_source_parameter("owner", "owner") + self.set_source_parameter("repository", "repository") 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..12f97d4fe0 --- /dev/null +++ b/components/collector/tests/source_collectors/bitbucket/test_inactive_branches.py @@ -0,0 +1,90 @@ +"""Unit tests for the Bitbucket inactive branches collector.""" + +from datetime import datetime +from unittest.mock import AsyncMock + +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/projects/owner/repos/repository/browse?at=" + + 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) + self.branches = self.create_branches_json([main, unmerged, ignored, active_unmerged]) + self.unmerged_branch_entity = self.create_entity("unmerged_branch") + self.entities = [self.unmerged_branch_entity] + self.landing_url = ( + "https://bitbucket/rest/api/1.0/projects/owner/repos/repository/branches?limit=100&details=true" + ) + + def create_branch( + self, name: str, *, default: bool = False, active: bool = False + ) -> dict[str, str | bool | dict[str, dict[str, float | int]]]: + """Create a Bitbucket branch.""" + commit_date = (datetime.now(tz=tzutc()).timestamp() if active else 1554197584) * 1000 + return { + "id": "refs/heads/" + name, + "displayId": name, + "type": "BRANCH", + "latestCommit": "ef6a9d214d509461f62f5f79b6444db55aaecc78", + "latestChangeset": "ef6a9d214d509461f62f5f79b6444db55aaecc78", + "isDefault": default, + "metadata": { + "com.atlassian.bitbucket.server.bitbucket-branch:latest-commit-metadata": { + "committerTimestamp": commit_date + } + }, + } + + def create_branches_json(self, branches): + """Create an entity.""" + return {"size": len(branches), "limit": 25, "isLastPage": True, "start": 0, "values": branches} + + def create_entity(self, name: str) -> dict[str, str]: + """Create an entity.""" + return { + "key": name, + "name": name, + "commit_date": "2019-04-02", + "merge_status": "unmerged", + "url": self.WEB_URL + "refs/heads/" + name, + } + + 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="1", 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_private_token(self): + """Test that the private token is used.""" + self.set_source_parameter("private_token", "token") + inactive_branches_json = {"values": None} + inactive_branches_response = AsyncMock() + execute = AsyncMock(side_effect=[inactive_branches_response]) + inactive_branches_response.json = AsyncMock(return_value=inactive_branches_json) + response = await self.collect(get_request_json_return_value=execute) + self.assert_measurement( + response, + landing_url=self.landing_url, + parse_error="Branch info for repository", + ) 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 zd!Y#{JgCDTVzE{Nxku3s(GwFp={rgr927sABY6TfYYrHEB(~TM&K&u@qqN$gbQ=33 zp>{nLd>A%2PVpa_3JFlW*GQzrgx@|z>T5NmURXbjb6gpJHWP&2Pwe{sb#I?%rS{+Z zFA-xeP5$mzvoxIsxhbTi9g~T^sJbM@8ENQ@yKSAiT_Q8D&AmasYD}&WB1~(TgEvxH~==+ zY%F^BzXW(fS;Udi5WiPXJ26v4yk@H-QwMv@R2UG^kBezt~mwpxy(B#ign62XBnu6 zyIZ+NUx0)0*rJqmuGps4`_j0_oAS29S-;X%oC9Ss29}=VZw`08-W+7MG1})5_3X5_ z$JpTe=gr%`tkRI5rHOYd!A&mBLpn4?!EHRuMYT~OtsHogIXNArD5 ze6+vt=4$Ev#oSDT?ursT_>G>|t)=dqiwiutL$uDFT)r2#+=Hqq!)`d~%Q~;izVqo` z(<+7&*~6Df@9e;~!$bs08>=TI`~{{$Sc*&FS4C9_W{ZpX@q7;dQvH6$zV78IYmBX2K{J9Euf(Yf~KV%m$`Wli{HMn7PpW@d_8{Vly4VI0;{^tz3 zr952tQ1Zw*e;>Et2KlLo!Zmx4>xM5?Y@t&Sr)!Uk~8WUrRrORvw#AynRdVYlP=<>y6t!NEAOUb5Du z#-dlmNh69!D%!4&p4)0&?|VC_JG_1LG%Dy0RQ1>cE`C}EBhM0qb z_B^c?V{B6?M6xjNB6_e!M}((m?!uq?+4 zO7M}PzUVHfAE{szr{8?(;2<2uK#wJG z9eaKrCK7N(+YH>Wwc(Y5A~e^56~)p^(9lrjgPzaDR2nm0QHG(pv4+_ubX_DrPkrD88+)PVqcq+{&TtjG zOZ?qRNn&$aPo=-L*=CCXQ)M+Dkd*ZB{UiAc-MhhZSaaVz{z^f~*ZnoLvYcSZJUdextxA#uTweYpk52 z(*W9ErnRcc39LJvFe?DqbEI|D@B%AZ>$IB-EAjokqi`uSS+iToCbg75C4XshGjs+f z_Gj~(eDt$u<$ELnQp2?NMEDJGHQPTZHO`tC#EJ7F3%JYRnK~0`%(yN z|4PbLxQgTMu2m=vy8A8mDG^_hpUZy|Rcvt^fXzG~T*&txyWNAg8|y)O4Y}u5D12Q7 zSNAAsYbCxP7TSTNX|)CO_`q^-Hi!Cks;p21 z1WV*o)P_4hVIZE|-E7FJnkD{5b^A!jVPn&Ev2mxRk&x2SX(}sOOO>ogio5tDfT@+` zLwBTQLF)uL-vDyZCHgevk{Pd~hHo#$Fb}x^nJl$M_k-#w0_3MG$#s&*pA3^9w3M2M)#@dGK@1`SRjiCOQ?7;M;KAAgRAFBo`C2Dj)D} z_FFW^h`93A30&cKTYu}mlGEz10Ja(2q!Kk0&7@RX(TJQ_%$9L1_}WCP7(DnO04cL? z2m3FeK+76VQj5tj@c-=3h;QeD(8Wl`%a~Wo0q?SyB>MhoBJ^TnK+3|rH_lS%*dg22 zjPhp}ybxev(FD}PC8=M+H@0cZq;;E?;jaRaVGTVQBtZsCSm!rMSnK|l@PBkrnPR8) zWMVdXNiLW)08~^Qk?%@~S9t=$Qs{tY7TGyY#s{WqUP=qDp@_3+g)=Pgrm~rnCHS0r z{<1aD3V=eV^<54v{+*`+avAPlfU#Z3EzAGWe8|P}El4nSaJbc>cQVpxwG|!;D`9x- zS%bDNtkxqL*0w?fy^OJhb-A3H(SP=+F2c-ld5utY61d8kW|TKd``?&G)!~ zn1o;;>!Ga33DYHGfbhbD`r-|K_I{}sbc&DVjO!qNsbl=5R<52xc>ARfbKYRTw6qR7 zYu1OxMJ1^Hemc)B$crOlvODs>tk6`~oGI|P zuH3R1N@hSs-vUM?;j9KRF61o{Su-%OLHb%o$wVbn1R>^@VApxd;aNm;Ba5F0jwxk$NgpNZ7rY0WJ8cuDFA^X z;QosP<>h72dtQ#elaVdq-@>rJsgD}C%Cwl=LV};o?ZgZ39%f)NjGU!*EhK(_;oD_` zBZ+fxb$Ys=2;I8aG6^diAi_q?relXPGE!-ln=R^-0eX#A@qn0&f*q_?WE+Pv1*NR} zMOz*s=&ko<%!Q__eix{@0$z4v&y)s*{`2-Z!5O7V&8S$fYe^bN+asCi$o8@9JGfAz zA}0tG@g6&Uq`eMz_f#LIAdqcE8hvd(2@;K~&Pns2NUech3s=XjHgg3FM;m=cS!pQw zLMhQJ%L8syT+^hDbRREcS3~st>aF&WPXy@pDx~}5c+XLY5vm|Is{ogt=WwFcvnYIHDt7(`nR!zYq=9z;R>Q2`EtEKTql zckiT=yfn;dcPj$Tc7|a&tMU-ApB>w5F?>BR+N#>WEmPfOM}!cn?G^Z-_4=t^$iPs7 z|E+7)^Uhn>9p&p4*|n`P(Pnvpehl1Aqa3{+U{eNT(xR18ALa~drSD$7hi3FD_Mh`E&wFDC+G^!N z&pnRHD8JR&3l%nGrHEd)nV0vPnRrrnP7sL@r}7l3GiL45@Vid`d_^z_w&ms4tRQ-y zK_U9T=!5kl%12YowLtOad+QYz_@nB)AYIJZJPaJSStR7okrf-ZI80-;tjV(yG^UIT z5LV=+_h4V=>Gmy(!dB)JPC+IaG)l^NVPNNymgAaEhZ96w83WtX-3+eXgu3ueBSqgy}14!L`$J~ zi*q99l)?#xY5CDSelV~~^+zJ|(n@y$qTk7i60R^InoQqWsY=vYMhLi0f7en;-^fG5 zRRusrlw88!ZRZItz!7-ssj1krUz4kg)!=X|W;5AFWJ-)HSz;~WA87~gboYQBll?7I z;XJDTjY7EdkRkuxGYtU+)ET?tlrh_mnIW2q9#|EZB#(S#Eia>faw7FV;xh1xm>icj z0)b21l(xBC9=-Cc=8XICzzi43WbcFmv7C6?hdJB7ZTns&$rEdwt#%LI(^(P01Ia3` zH@D{OQ&L&k_|3#Mx~KHF#m4W(m*J}U68$}*4qY%o*;DiFSj#cxJLV_PKEsuIf;CUQ z{JaN}8RujUs61jqBVW0NnO91@8Rv9jo|M-xO4BHlt^m6tbQd3d(K0J9%z2i$`Aner z)D7}GSUJX7I^yrt(JpiU2R^vtt3{2WPg=fT@(I388P!xq5PMkR5rmzCg2iXJJ|J;C zBnBgLOshCx4q*0iUpJ{#1Y*HPUV?Rk+X47_ojp6>z$Cyr{44{I2_9YPZ&Tsz^CY3~ zDv}W7uls_rPB8mex{CKdc=pY4&H1O1`tsL%x*iVbx$Ora6ONHGYYsOBG7mmv&@4F? z!;69W6w)$6Ad-lGB|t-Fyq?;}y$CFXy{jyPmI2G=BVqi-@W__*g}HB?rR(LTr~7B| zco<+XEnLV){W+r7feKxwr5E*Mh(HIFV(l^osiSvg0p{cmsj7mvv?NpR?p`;`qnP-v zJ(|PnL*vgIxaFUQw7Ua~uI`LmkE}eJzaq_%n`K~9fLabECuQ|V!BGB*+!$6T3RefM zB1AlB4q+f8=k9t%;u&r~vYW1{XhhN)yUpWY_nDheh%v42ozl2>;Pmi93aaRz!C;%l ztt5$NrqjP3E>_uKm45*eq*ZG>T*j4hmf&E5FU{mb!fycqa9f*nwtn?C)PLea2`d8B00x94=m%=2 z>}9JMCTrEeFXp*QcvmrFJAFvl4$ci)AU{mNfPUhmcbWqKP8;miJFKT=_1l#H2p&1- zIk#|101(N+j}rcJ*U=kx5@}VD2oDaXuH&>ED7HNj&mn#QU{nBp0w4+KM3)jop`Gl+ zF(H=h!lcc@oj*??^EV^gnoiBFca(cE?7J!Bzq1Vn&eF=YI^%rfFeg%l{x34HRi z&;Dq(hPgA+7q#mx!*s5NX4c*_j2^yQ2fHU2sbjl^()$><$yox{25Rn)wmvp6Yg9Bl+JvHFPa|I4he`KA_rs^F{p$>b3)F?Siys?tL$EbhUXdNg z0~q$6CY1UL4@$vR6$STZ2g!=>q+hWfzG&1^4aL-Qe#^W$7jINoU|%k_)8X5B&uLMe zgDlU%mIq)xcx-pbr+O)2odWcCKJ4b-Aaco94H2~eH06V}xGIOngF#1pY8}-qTZWB% zMXoj#QuFv1hoaD*PmWlWAo zluzP`{?pC);wypCr(s_1{$FD_E12H*rZrh^wG|0X9Flqn(LQ zUVfb$_?@-k={16UbK0U>Zyf7$7xq@3j4|JnH68}gu`h>v3^?@A5W#P#^MiRG+J6#Q zc?R8QL4l;pk=Fos`N$09Ex(U&>gpyov9qofh&P*_Ebci zQ`~8OW1JHhiq6jC!A1SllEfKS$o3W~WU<*^Sd+&fZ}J)uo@v~4=ijC^N!NBRzZw)t z!Yt_2?c4-+k#Q>zx#B5XR43{*=DkM!%P^sJj75~;n>J`n1poi$y^8=E6p>`z=#F$u zZ;*cS4pyW8z{Nw=+`SIpwRxZC;=YXtZ!gJ|%IE)Cdb%uXB;a&fPX02!W7$Ac`)s7s zgTV_SV0{GKLh#7ju8^@All5G6X&O$8Bz&267<)6AyZWZ7aLWc$;jeMlN_#gOt&Bme z_8IO8I8>^8HJy&MS{52i&h*yRP}Wvih1*sw=4IS3uX8wj~zRr9(WXECH@BR04`?&jG-X1N30mx^( z?dUm2WX>LrNL`63J9&&|a^HnqOyV#~O7PfQ&H54iqRp~el~EpMsf~#QeR|r3+{eyB zbeW9TPx{#yYEBvv@Ar4{Us{Mu_T)KGa>%MjS!4)B>zr;i87OAfz#(m z#VXOhs4vRQ=ma4PaPA5VPxu@V)de5<>&=@bGiIe9V$G4=ttY;?`u)aAo8F@0l$nvl zvb{kW@BosA=o6pRzuwj|V|>BD{<#~~ zun%Pag|({ouHb%4_CbF~V#XS_?OidW3Gbnd`A)Ye{IciAEacb;9eE$2cG+Q8N&6yN zPc`}LUfjp0`~oixO07aFNgFm5KI=9Wo(+uN>_sg;h&pFV;e*^aiEH|I51?T`V{lJ1 zkqs7e#N?mz`HiR6+P_`KFTAWqN7A>aaZB3M)JwT9@ordX$r;yFi6oS1DooH}?X~8v zn`HyywB#GSo2pi!DYpg%0Rnk~_E8qQ5tU(^MCS6RB|latluVmOGn+ET%1&N6L z@*<~jj_7d_zUQ|KS#u(Z#<+`SttrF%gmdH@%_RId62k|5o*|Lmy-2IQUxvRw^N)LJ zw-CpDybsxCueOn;k|?h&Rk|4>2z!UvVvbSS&-YLgLl55K$fFlz4-2yo z#5rX-FhnHE``3B_&!2OZy~g(ZJ|kVC0^hjVROoGjxw%=~>P~&yj7{p*!QGh#{&JucLQ^(*ENS_bu-(;!cT)YcpUU^ISQ@jRmCj<< zp*$2u`=+kg_Gr5cQ2{OEEeBp!ewm@Q=ppIx4dMka$p`1!GUe&($eI0@@m7XRb|Hl+ zZoiDr$^jqpF%xqUE~{?Na~tZR)(8gm;&3cVHZE&piPx>vlE-r z#}}<+(9hFMY+kI_WwPZRST!w%X31+=$2LF;o6HcrXG$3TN$!1i>EMh zZ3qaE5!${X&qnp8_J+qb3E!CyP+A;4;Q>2G11(El5}+HNSox#H(FxS#8GV@J_-6Aq zlB$6X=B5KTnrvAu-mF2Ey5t+c1$?bM1@c5rO^*fKj6`K~odg}dOf zHjR-)BSN^Zt&>+8*Pd0rt43LE4sM4GTiS;}s{NP<|LT;4Rr=y@%YUN@2GZBZF~98< zKPU(kFMNZqI2*{0Q}XLQsoC_32WGmRbh8JrmG&#OwY)#hwpc!>*@S*l%ScHySfzwU zVrkkxn(GRWC$~qIzNqIvI=1(B?ZreNl{e3w#+8tV{}^*A3+5QP%1)g%@UJN*W{t-= z8QDqQNCOAa*cVb0Q{h+-{`fqB(IaEGZv2&GZpuyavx>MT>$^|+_YiTu`L}x9xmgQL z(B+BU%P^Kx7;a%F4v+z_)@buftc(}z(s2kp>UvZOV`G`i0{MO{Z zxSpa9h!I8e{XcilMHw#s5iJBnF^%5(e(WZS$gmqZA}eJk<0ZK_24}+IQ*~@sGTFX7 zf#lXY?voq?-V;?(h`=+M=4NsmN#Jl-!-4Ymt@jQkpSM{4_fkr&PdIns7Ua42{Aq=vUV^v#gmJ5=hV=XXVklGkAhiZ4$o!*^33& z;=Z@OXdx+Mo~P1VCSV6}b-pGa-U?fB)=Z7JE~6K3>duN&`sfbTX?2AvPkUXm=6;r- zYd*Wo8rKhrGjM8nMq5t4{LpwsWu_=_wDhfct+>PU%TRW}^G33-;?f;zh&Np>_%I6* zp+Ns{0w9Tq9`F(vU-xedR{VJlgm?UhCB^XAu;cKz{M|~>UxmjP-M)L}{I7=v52qa9 z+U?Yry9(w)C1a&=CqFUEI58Ov@Y+_RL6;|b5YBMA;&IjV2?Q0t88r=Rf7AlGmZNaGO3digCwjypl~}k z>pj+fRl}&gsc`K=*XRy#dtN?jmuVygP2;`nY^##bb(SO1vCh)H{YInMttS!7Qv(ah zO~5oQb7fz`Kepwt?ooN8Ji(Nq54wTFXfMLkk|c;01J03HjC23*sn+mYzmd|p=~r?7 zKc1%@o8A;p#Y$uE;!;`~b@Kx)*L33}>@bxd8h<~31&X#j4T^-^^9%KFDFm674d$o= zYwau`@l)0z)~=S(8W{mS_r4}fAI^!hU#2h5ZzapiYJVvqX6Vk-d7hqPE6 z`^FlQ?p~Z^1@<$D5yWJlJD9YhH*g@1djP92uNEQcL>K}9TS)s@aJX?dXOl+F_Gx~^^h&4jIvUNaTi3PfIXZWVo=k*nn+ zH>yXpI(l8@$glT3)WnA;a$@a`^|`Lg$9pyOro*(Sk?3L`X4mApBByzdHYN+18RtR- zK8GZHGYW{Pj{RuXWLgHLiHz;|=31Sv$@x6Y*ug(`zTrpIebpXHkVBm1qcacIPa_B$ zB$!Nb1(i(`-Utx{#{wq5SY(DKr?@T<>q%HFhIRnj?@m=Q!}U)b+FsMM;+W~N9iTth zw*2Gkv1S~?t%S$YjM|-7e)J7VF_!;y7S)l!xky&s5iEtpbd+Vu)!6xxY*DcS+meqQ zNfUYCi?eD9^ZuDKE1i?RmY9>5sYMw%f!{iv36{R7DTi^b1Yh$*B0YPYmJx-ueR(~* zrsn?p#*>37Et?TqEAH)m*ce<*4%gf#Q|v*G9WWQL?@;C%?u01mq2yUdX&Km~#vbI< znV%5#@OzJk-JEpFXiL9SBzF>rNE=98)tR^J>jJ@4AznfbhI!=C+ze(qpkvi!_aDuf z#;h2n;_MwBRKF->YhK_#?@z%SQ?bvEAG?zRi%e=VC}=X`8UP&h6lpc@HXIf@bUHK2 zh_;4x4ChAjPmQ+rLBUZybtx84RfBoHe1rt>kf}gh-numXvj6=E;j9K`7ne$ z7(k`){mqaYUD)&G5b|PS4u4)g zV^B2>x9${;;fp$q6%%`6aPDxR?+FnsJ5BRHzM2db3mz$kePqd$J>7UWd-2QYOMS@G zr|A^ZNyW|zp-U9qPpkQWdR)_-?ewR6Mua8hic7~1hqit7e&mcnt-5cTD4aeVhW%O{ zHhsWjNrA~m>B0M!jcM=-tb!+qoomKA5-%Tr>NW@s8K0G=^g|CeBr*r&a_+sU1x4!_5*6)zn9z31Tm!HU*!ed( z-9DB$2cS^R98=qw)Z-}{Z`Eo)H?CFr8I^kM79M(6M9G=#w~NENN-zC|=15rS8ajsH zGSYMs(oDKtl0J^JK)n`}#;xg`RxX|WjCUKkn7DO$d7{> z(`I?;=rsw8;Q%80mDv?>@BhmDqmdI}zlVt|=-V(e5;WiKDPJrP`U3FJCh?A09=lRv4J^ zL5)2kOxVv4Sh({h`SCZtS=U_ddg_Z_{6xm}eXXO7=I@?=N$8K5Q}Bp{domsP?dnXq zKlaE{y30OzyfPpUy#30t2Hx1rz>H!->O2ATZG;McUexFo;EH)KtUkT!`W8CjlH+Cn zJ1*dPBKOrlPlBlNUdxMB>%{=Mi^o|}wdg7>;%5URVvSk96kI1J*d}(S*qhPKJK;E# zP}TQu%(H*q_dJPg@@sw*6(M~aM2au-$F5v7iW~c}Y?TleFqtD-ZqAgZQf)i1_$%u~ zvM|H&xav=@67Nscf8JO(>-m9c-NnbF)e@-orYSRjuu;&c??&&L-kUxpJtf!+SAu&e z7FT$wZ#O#9K5GzZo#Z1e^ zOlwQO#fz6Mv0P?lyU5aVnWg3FiA2R?r2pR!L`3Zj2|n=u|AFbN4~yXkkhQK}tE!y2 G!v6uUfB;JX literal 0 HcmV?d00001 diff --git a/components/shared_code/src/shared_data_model/metrics.py b/components/shared_code/src/shared_data_model/metrics.py index 8873bddc73..21523e69ef 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 cf11fdae77..ae178044c7 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_date 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..38ec02f139 --- /dev/null +++ b/components/shared_code/src/shared_data_model/sources/bitbucket.py @@ -0,0 +1,82 @@ +"""Bitbucket source.""" + +from pydantic import HttpUrl + +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, + BranchesToIgnore, + BranchMergeStatus, + Days, + PrivateToken, + StringParameter, +) + +BITBUCKET_BRANCH_HELP_URL = HttpUrl("https://confluence.atlassian.com/bitbucketserver/branches-776639968.html") + +BITBUCKET = Source( + name="Bitbucket", + description="Bitbucket is a version control platform by Atlassian that supports Git, " + "enabling developers to collaborate on code with features like pull requests, " + "CI/CD, and seamless integration with tools like Jira and Trello.", + url=HttpUrl("https://bitbucket.org/product/guides/getting-started/overview#a-brief-overview-of-bitbucket/"), + documentation={ + "generic": """```{note} +The pagination for the Bitbucket collector has not yet been implemented,\ +and the current limit for retrieving branches for the inactive branches metric using the API is set to 100.\ +Pagination will be implemented in a future update. +```""", + }, + 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"], + ), + "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"], + ), + "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"], + ), + "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 6474d5c0d9..38fb681b0f 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", diff --git a/docs/src/changelog.md b/docs/src/changelog.md index e0562edbaf..b107fcabb9 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -22,6 +22,7 @@ If your currently installed *Quality-time* version is not the latest version, pl ### Added +- Support Bitbucket as source for the 'inactive branches' metric. Note that the amount of branches checked is limited to 100 because pagination for the Bitbucket API has not been implemented yet. Closes [#10083](https://github.com/ICTU/quality-time/issues/10083). - When measuring missing metrics, make the subject type and the metric type of the missing metrics link to the reference documentation. Closes [#10528](https://github.com/ICTU/quality-time/issues/10528). - Allow for measuring the source up-to-dateness of Trivy JSON reports. Closes [#10608](https://github.com/ICTU/quality-time/issues/10608). - Allow for measuring the source up-to-dateness of Harbor JSON reports. Closes [#10609](https://github.com/ICTU/quality-time/issues/10609).