Skip to content

Commit 8c171b9

Browse files
authored
fix(bitbucket): Fetch all repository pages from Bitbucket Cloud API (#112514)
## Summary - Bitbucket Cloud defaults to `pagelen=10` when no parameter is specified, so `get_repos()` was silently returning only the first 10 repositories for any workspace - This affected repo selector dropdowns, stacktrace linking, code mappings, and unmigratable repo detection for any Bitbucket Cloud workspace with >10 repos - Add `_get_all_from_paginated()` that follows the `next` URL in Bitbucket response bodies with `pagelen=100` (the API max), aggregating all pages - Applied to `get_repos()` only; `search_repositories()` is left unchanged ## Test plan - [x] Existing Bitbucket tests pass (65/65) - [x] Manually test with a Bitbucket Cloud workspace that has >10 repos to confirm all repos are now returned Refs VDY-67
1 parent 8d290fd commit 8c171b9

File tree

3 files changed

+149
-5
lines changed

3 files changed

+149
-5
lines changed

src/sentry/integrations/bitbucket/client.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ class BitbucketApiClient(ApiClient, RepositoryClient):
5656

5757
integration_name = IntegrationProviderSlug.BITBUCKET.value
5858

59+
# Bitbucket Cloud defaults to pagelen=10 and caps at 100.
60+
page_size = 100
61+
page_number_limit = 50
62+
5963
def __init__(self, integration: RpcIntegration | Integration):
6064
self.base_url = integration.metadata["base_url"]
6165
self.shared_secret = integration.metadata["shared_secret"]
@@ -109,8 +113,44 @@ def create_comment(self, repo, issue_id, data):
109113
def get_repo(self, repo):
110114
return self.get(BitbucketAPIPath.repository.format(repo=repo))
111115

112-
def get_repos(self, username):
113-
return self.get(BitbucketAPIPath.repositories.format(username=username))
116+
def _get_all_from_paginated(
117+
self,
118+
path: str,
119+
params: dict[str, Any] | None = None,
120+
page_number_limit: int | None = None,
121+
) -> list[dict[str, Any]]:
122+
"""
123+
Aggregate all pages from a Bitbucket Cloud paginated endpoint.
124+
125+
Bitbucket uses a ``next`` URL in the response body (not headers)
126+
to link to the next page.
127+
"""
128+
if page_number_limit is None or page_number_limit > self.page_number_limit:
129+
page_number_limit = self.page_number_limit
130+
131+
if params is None:
132+
params = {}
133+
params.setdefault("pagelen", self.page_size)
134+
135+
output: list[dict[str, Any]] = []
136+
resp = self.get(path, params=params)
137+
output.extend(resp.get("values", []))
138+
139+
page_number = 1
140+
while "next" in resp and page_number < page_number_limit:
141+
resp = self.get(resp["next"])
142+
output.extend(resp.get("values", []))
143+
page_number += 1
144+
145+
return output
146+
147+
def get_repos(
148+
self, username: str, page_number_limit: int | None = None
149+
) -> list[dict[str, Any]]:
150+
return self._get_all_from_paginated(
151+
BitbucketAPIPath.repositories.format(username=username),
152+
page_number_limit=page_number_limit,
153+
)
114154

115155
def search_repositories(self, username, query):
116156
return self.get(

src/sentry/integrations/bitbucket/integration.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,14 @@ def get_repositories(
140140
) -> list[RepositoryInfo]:
141141
username = self.model.metadata.get("uuid", self.username)
142142
if not query:
143-
resp = self.get_client().get_repos(username)
143+
all_repos = self.get_client().get_repos(username, page_number_limit=page_number_limit)
144144
return [
145145
{
146146
"identifier": repo["full_name"],
147147
"name": repo["full_name"],
148148
"external_id": self.get_repo_external_id(repo),
149149
}
150-
for repo in resp.get("values", [])
150+
for repo in all_repos
151151
]
152152

153153
client = self.get_client()
@@ -215,7 +215,7 @@ def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str
215215
# Bitbucket only methods
216216

217217
@property
218-
def username(self):
218+
def username(self) -> str:
219219
return self.model.name
220220

221221

tests/sentry/integrations/bitbucket/test_integration.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,110 @@ def test_get_repositories_with_uuid(self) -> None:
6262
{"identifier": "sentryuser/stuf", "name": "sentryuser/stuf", "external_id": "{abc-001}"}
6363
]
6464

65+
@responses.activate
66+
def test_get_repositories_multiple_pages(self) -> None:
67+
"""get_repos aggregates all pages by following the 'next' URL."""
68+
base_url = "https://api.bitbucket.org/2.0/repositories/sentryuser"
69+
responses.add(
70+
responses.GET,
71+
base_url,
72+
json={
73+
"values": [{"full_name": "sentryuser/repo-1", "uuid": "{r1}"}],
74+
"next": f"{base_url}?pagelen=100&page=2",
75+
},
76+
)
77+
responses.add(
78+
responses.GET,
79+
f"{base_url}?pagelen=100&page=2",
80+
json={
81+
"values": [{"full_name": "sentryuser/repo-2", "uuid": "{r2}"}],
82+
"next": f"{base_url}?pagelen=100&page=3",
83+
},
84+
)
85+
responses.add(
86+
responses.GET,
87+
f"{base_url}?pagelen=100&page=3",
88+
json={"values": [{"full_name": "sentryuser/repo-3", "uuid": "{r3}"}]},
89+
)
90+
91+
installation = self.integration.get_installation(self.organization.id)
92+
result = installation.get_repositories()
93+
assert result == [
94+
{"identifier": "sentryuser/repo-1", "name": "sentryuser/repo-1", "external_id": "{r1}"},
95+
{"identifier": "sentryuser/repo-2", "name": "sentryuser/repo-2", "external_id": "{r2}"},
96+
{"identifier": "sentryuser/repo-3", "name": "sentryuser/repo-3", "external_id": "{r3}"},
97+
]
98+
99+
@responses.activate
100+
def test_get_repositories_respects_page_limit(self) -> None:
101+
"""Pagination stops at the page_number_limit."""
102+
base_url = "https://api.bitbucket.org/2.0/repositories/sentryuser"
103+
# Page 1 has a next link but we pass page_number_limit=1
104+
responses.add(
105+
responses.GET,
106+
base_url,
107+
json={
108+
"values": [{"full_name": "sentryuser/repo-1", "uuid": "{r1}"}],
109+
"next": f"{base_url}?pagelen=100&page=2",
110+
},
111+
)
112+
# Page 2 should not be fetched
113+
responses.add(
114+
responses.GET,
115+
f"{base_url}?pagelen=100&page=2",
116+
json={"values": [{"full_name": "sentryuser/repo-2", "uuid": "{r2}"}]},
117+
)
118+
119+
installation = self.integration.get_installation(self.organization.id)
120+
result = installation.get_repositories(page_number_limit=1)
121+
assert result == [
122+
{"identifier": "sentryuser/repo-1", "name": "sentryuser/repo-1", "external_id": "{r1}"},
123+
]
124+
assert len(responses.calls) == 1
125+
126+
@responses.activate
127+
def test_get_repositories_zero_page_limit_returns_first_page(self) -> None:
128+
"""A page_number_limit of 0 still returns the first page but fetches no further."""
129+
base_url = "https://api.bitbucket.org/2.0/repositories/sentryuser"
130+
responses.add(
131+
responses.GET,
132+
base_url,
133+
json={
134+
"values": [{"full_name": "sentryuser/repo-1", "uuid": "{r1}"}],
135+
"next": f"{base_url}?pagelen=100&page=2",
136+
},
137+
)
138+
responses.add(
139+
responses.GET,
140+
f"{base_url}?pagelen=100&page=2",
141+
json={"values": [{"full_name": "sentryuser/repo-2", "uuid": "{r2}"}]},
142+
)
143+
144+
installation = self.integration.get_installation(self.organization.id)
145+
result = installation.get_repositories(page_number_limit=0)
146+
assert result == [
147+
{"identifier": "sentryuser/repo-1", "name": "sentryuser/repo-1", "external_id": "{r1}"},
148+
]
149+
assert len(responses.calls) == 1
150+
151+
@responses.activate
152+
def test_get_repositories_clamps_excessive_page_limit(self) -> None:
153+
"""A page_number_limit above the class max is clamped to the default."""
154+
base_url = "https://api.bitbucket.org/2.0/repositories/sentryuser"
155+
responses.add(
156+
responses.GET,
157+
base_url,
158+
json={"values": [{"full_name": "sentryuser/repo-1", "uuid": "{r1}"}]},
159+
)
160+
161+
installation = self.integration.get_installation(self.organization.id)
162+
client = installation.get_client()
163+
result = installation.get_repositories(page_number_limit=client.page_number_limit + 100)
164+
assert result == [
165+
{"identifier": "sentryuser/repo-1", "name": "sentryuser/repo-1", "external_id": "{r1}"},
166+
]
167+
assert len(responses.calls) == 1
168+
65169
@responses.activate
66170
def test_get_repositories_exact_match(self) -> None:
67171
querystring = urlencode({"q": 'name="stuf"'})

0 commit comments

Comments
 (0)