diff --git a/bin/bitbucket-client b/bin/bitbucket-client index fe30ff5f..551e92be 100755 --- a/bin/bitbucket-client +++ b/bin/bitbucket-client @@ -20,6 +20,13 @@ Commands: get-readme --ref get-pull-request-template --ref get-file-url [--start-line ] [--end-line ] + get-commit + get-commit-changes [--cursor ] [--per-page ] + get-commits [--ref ] [--cursor ] [--per-page ] + get-commits-by-path [--ref ] [--cursor ] [--per-page ] + get-commit-url + get-commits-url [--file-path ] [--since YYYY-MM-DD] [--until YYYY-MM-DD] + compare-commits [--include-behind] """ import argparse @@ -28,11 +35,19 @@ import logging import os import pathlib import sys +from datetime import date from scm import Secret from scm.manager import SourceCodeManager from scm.types import ( + CompareCommitsProtocol, GetAppInstallationProtocol, + GetCommitChangesProtocol, + GetCommitProtocol, + GetCommitsByPathProtocol, + GetCommitsProtocol, + GetCommitsUrlProtocol, + GetCommitUrlProtocol, GetDirectoryContentsProtocol, GetFileContentProtocol, GetFileUrlProtocol, @@ -40,6 +55,7 @@ from scm.types import ( GetPullRequestTemplateProtocol, GetReadmeProtocol, GetRepositoryProtocol, + PaginationParams, ) logger = logging.getLogger(__name__) @@ -49,6 +65,20 @@ def dump(obj): logger.info(json.dumps(obj, indent=2, default=str)) +def pagination_args(args) -> PaginationParams | None: + pagination: PaginationParams = {} + if args.cursor is not None: + pagination["cursor"] = args.cursor + if args.per_page is not None: + pagination["per_page"] = args.per_page + return pagination or None + + +def add_pagination(p: argparse.ArgumentParser) -> None: + p.add_argument("--cursor", default=None) + p.add_argument("--per-page", type=int, default=None) + + def load_credentials() -> dict[str, str]: """Load KEY=VALUE pairs from .credentials, skipping blanks and comments.""" creds: dict[str, str] = {} @@ -104,6 +134,35 @@ def main(): p.add_argument("sha") p.add_argument("--start-line", type=int, default=None) p.add_argument("--end-line", type=int, default=None) + p = sub.add_parser("get-commit") + p.add_argument("sha") + + p = sub.add_parser("get-commit-changes") + p.add_argument("sha") + add_pagination(p) + + p = sub.add_parser("get-commits") + p.add_argument("--ref", default=None) + add_pagination(p) + + p = sub.add_parser("get-commits-by-path") + p.add_argument("path") + p.add_argument("--ref", default=None) + add_pagination(p) + + p = sub.add_parser("get-commit-url") + p.add_argument("sha") + + p = sub.add_parser("get-commits-url") + p.add_argument("sha") + p.add_argument("--file-path", default=None) + p.add_argument("--since", type=date.fromisoformat, default=None) + p.add_argument("--until", type=date.fromisoformat, default=None) + + p = sub.add_parser("compare-commits") + p.add_argument("start_sha") + p.add_argument("end_sha") + p.add_argument("--include-behind", action="store_true", dest="include_behind") args = parser.parse_args() if not args.command: @@ -153,6 +212,34 @@ def main(): assert isinstance(scm, GetFileUrlProtocol) dump(scm.get_file_url(args.path, args.sha, start_line=args.start_line, end_line=args.end_line)) + elif args.command == "get-commit": + assert isinstance(scm, GetCommitProtocol) + dump(scm.get_commit(args.sha)) + + elif args.command == "get-commit-changes": + assert isinstance(scm, GetCommitChangesProtocol) + dump(scm.get_commit_changes(args.sha, pagination=pagination_args(args))) + + elif args.command == "get-commits": + assert isinstance(scm, GetCommitsProtocol) + dump(scm.get_commits(ref=args.ref, pagination=pagination_args(args))) + + elif args.command == "get-commits-by-path": + assert isinstance(scm, GetCommitsByPathProtocol) + dump(scm.get_commits_by_path(args.path, ref=args.ref, pagination=pagination_args(args))) + + elif args.command == "get-commit-url": + assert isinstance(scm, GetCommitUrlProtocol) + dump(scm.get_commit_url(args.sha)) + + elif args.command == "get-commits-url": + assert isinstance(scm, GetCommitsUrlProtocol) + dump(scm.get_commits_url(args.sha, file_path=args.file_path, since=args.since, until=args.until)) + + elif args.command == "compare-commits": + assert isinstance(scm, CompareCommitsProtocol) + dump(scm.compare_commits(args.start_sha, args.end_sha, include_behind=args.include_behind)) + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) diff --git a/src/scm/providers/bitbucket/provider.py b/src/scm/providers/bitbucket/provider.py index 5f5e8bc1..a12e2809 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -1,6 +1,8 @@ import base64 import hashlib +import re from collections.abc import Callable, Iterator +from datetime import UTC, date, datetime from typing import Any from urllib.parse import parse_qs, quote, unquote, urlparse @@ -21,9 +23,15 @@ ActionResult, ApiClient, AppInstallation, + Commit, + CommitAuthor, + CommitComparison, + CommitFile, + CommitWithChanges, CredentialsSet, FileContent, FileContentType, + FileStatus, GitRemote, GitRepository, PaginatedActionResult, @@ -64,6 +72,13 @@ PULL_REQUEST_TEMPLATE_PARENT_DIRS = (".github", "", "docs") PULL_REQUEST_TEMPLATE_FILENAME = "pull_request_template.md" PULL_REQUEST_TEMPLATE_DIRNAME = "pull_request_template" +_FILE_STATUSES: dict[str, FileStatus] = { + "added": "added", + "removed": "removed", + "modified": "modified", + "renamed": "renamed", +} +_RAW_AUTHOR = re.compile(r"^(?P.*?)\s*<(?P[^>]*)>\s*$") class BitbucketProvider: @@ -287,6 +302,167 @@ def _resolve_ref(self, ref: str, request_options: RequestOptions | None) -> str: def _src_path(self, commit: str, path: str) -> str: return f"/2.0/repositories/{self.repository_path}/src/{quote(commit, safe='')}/{quote(path.strip('/'))}" + def get_commit_url(self, commit_sha: SHA) -> str: + return f"{self._web_base_url}/{self.repository_path}/commits/{commit_sha}" + + def get_commits_url( + self, + commit_sha: SHA, + *, + file_path: str | None = None, + since: date | None = None, + until: date | None = None, + ) -> str: + """Link to a file's history at the commit, or else the repository's commits page. + + Bitbucket's history pages take no dates, and its commits page can't start at a commit. + """ + if file_path is None: + return f"{self._web_base_url}/{self.repository_path}/commits" + return f"{self._web_base_url}/{self.repository_path}/history-node/{commit_sha}/{quote(file_path)}" + + def get_commit( + self, + sha: SHA, + request_options: RequestOptions | None = None, + ) -> ActionResult[CommitWithChanges]: + """Return a commit with its first page of changed files.""" + response = self.get(f"/2.0/repositories/{self.repository_path}/commit/{sha}", request_options=request_options) + changes = self.get_commit_changes(sha, {"per_page": MAX_PAGE_SIZE}, request_options) + files = changes["data"] + complete = changes["meta"]["next_cursor"] is None + additions = sum(f["additions"] or 0 for f in files) if complete else None + deletions = sum(f["deletions"] or 0 for f in files) if complete else None + return map_action(response, lambda raw: CommitWithChanges(**map_commit(raw, additions, deletions), files=files)) + + def get_commit_changes( + self, + sha: SHA, + pagination: PaginationParams | None = None, + request_options: RequestOptions | None = None, + ) -> PaginatedActionResult[list[CommitFile]]: + """Read one page of a diffstat, with each file's patch cut from the full diff.""" + # Filtering the diff by the page's paths fails: Bitbucket rejects query strings over about 4KB. + repository_api = f"/2.0/repositories/{self.repository_path}" + response = self.get(f"{repository_api}/diffstat/{sha}", pagination=pagination, request_options=request_options) + patches = split_patches(self.get(f"{repository_api}/diff/{sha}", request_options=request_options).text) + return map_paginated_action(response, lambda raw: map_commit_file(raw, patches)) + + def get_commits( + self, + ref: str | None = None, + pagination: PaginationParams | None = None, + since: datetime | None = None, + until: datetime | None = None, + request_options: RequestOptions | None = None, + ) -> PaginatedActionResult[list[Commit]]: + return self._get_commits(ref, {}, pagination, since, until, request_options) + + def get_commits_by_path( + self, + path: str, + ref: str | None = None, + pagination: PaginationParams | None = None, + since: datetime | None = None, + until: datetime | None = None, + request_options: RequestOptions | None = None, + ) -> PaginatedActionResult[list[Commit]]: + return self._get_commits(ref, {"path": path}, pagination, since, until, request_options) + + def compare_commits( + self, + start_sha: SHA, + end_sha: SHA, + pagination: PaginationParams | None = None, + request_options: RequestOptions | None = None, + *, + include_behind: bool = False, + ) -> PaginatedActionResult[CommitComparison]: + """Compare two commits against their merge base. + + Bitbucket returns no counts, so they're the length of a commit list and cap at one page. + `behind_by` takes a second request and is only counted when the commits aren't paged. + """ + if include_behind and pagination is not None: + raise ResourceBadRequest( + detail="'include_behind' cannot be combined with 'pagination': 'ahead_by' would count one page " + "while 'behind_by' counts the whole range.", + ) + commits_path = f"/2.0/repositories/{self.repository_path}/commits" + response = self.get( + commits_path, + params={"include": end_sha, "exclude": start_sha}, + pagination=pagination or {"per_page": MAX_PAGE_SIZE}, + request_options=request_options, + ) + raw = response.json() + commits = [map_commit(commit) for commit in raw["values"]] + # A range spec diffs the first commit against the merge base, so the head goes first. + spec = f"{end_sha}..{start_sha}" + repository_api = f"/2.0/repositories/{self.repository_path}" + patches = split_patches(self.get(f"{repository_api}/diff/{spec}", request_options=request_options).text) + diffstats = list( + iter_all_pages( + lambda page: map_paginated_action( + self.get(f"{repository_api}/diffstat/{spec}", pagination=page, request_options=request_options), + lambda file: map_commit_file(file, patches), + ), + per_page=MAX_PAGE_SIZE, + ) + ) + diff = [file for page in diffstats for file in page["data"]] + comparison = CommitComparison(ahead_by=len(commits), commits=commits, diff=diff) + if pagination is None: + behind = self.get( + commits_path, + params={"include": start_sha, "exclude": end_sha}, + pagination={"per_page": MAX_PAGE_SIZE}, + request_options=request_options, + ) + comparison["behind_by"] = len(behind.json()["values"]) + return { + "data": comparison, + "type": PROVIDER_TYPE, + "raw": { + "data": {**raw, "diffstat": [file for page in diffstats for file in page["raw"]["data"]["values"]]}, + "headers": dict(response.headers), + }, + "meta": {"next_cursor": next_cursor(raw)}, + } + + def _get_commits( + self, + ref: str | None, + params: dict[str, str], + pagination: PaginationParams | None, + since: datetime | None, + until: datetime | None, + request_options: RequestOptions | None, + ) -> PaginatedActionResult[list[Commit]]: + # Without a revision Bitbucket lists commits on every branch; HEAD is the main branch. + revision = quote(ref or "HEAD", safe="/") + while True: + response = self.get( + f"/2.0/repositories/{self.repository_path}/commits/{revision}", + params=params, + pagination=pagination, + request_options=request_options, + ) + result = map_paginated_action(response, map_commit) + if since is None and until is None: + return result + # Commits aren't strictly date-ordered, so only a page that's entirely past `since` ends the walk. + # Bitbucket's commit `date` is the committer date; the API has no author date. + dates = [datetime.fromisoformat(raw["date"]) for raw in result["raw"]["data"].get("values", [])] + start, end = as_utc(since or datetime.min), as_utc(until or datetime.max) + result["data"] = [commit for commit, d in zip(result["data"], dates, strict=True) if start <= d <= end] + if dates and all(d < start for d in dates): + result["meta"]["next_cursor"] = None + # Callers stop at an empty page, so skip the ones that only hold commits after `until`. + if result["data"] or not (cursor := result["meta"]["next_cursor"]): + return result + pagination = {**(pagination or {}), "cursor": cursor} + def parse_scopes(header: str) -> set[str]: return {scope.strip() for scope in header.split(",") if scope.strip()} @@ -353,6 +529,52 @@ def map_directory_entry(raw: dict[str, Any]) -> FileContent: ) +def map_commit_author(raw: dict[str, Any], raw_date: str) -> CommitAuthor: + """Parse the git author, which is a raw `Name ` string whether or not it links to a Bitbucket user.""" + if match := _RAW_AUTHOR.match(raw["raw"]): + name, email = match["name"], match["email"] + else: + name, email = raw["raw"], "" + return CommitAuthor(name=name, email=email, date=datetime.fromisoformat(raw_date)) + + +def as_utc(value: datetime) -> datetime: + return value if value.tzinfo else value.replace(tzinfo=UTC) + + +def map_commit(raw: dict[str, Any], additions: int | None = None, deletions: int | None = None) -> Commit: + return Commit( + id=raw["hash"], + message=raw["message"], + author=map_commit_author(raw["author"], raw["date"]), + additions=additions, + deletions=deletions, + ) + + +def map_commit_file(raw: dict[str, Any], patches: dict[str, str]) -> CommitFile: + old, new = raw["old"], raw["new"] + filename = (new or old)["path"] + return CommitFile( + filename=filename, + status=_FILE_STATUSES.get(raw["status"], "unknown"), + patch=patches.get(filename), + additions=raw["lines_added"], + deletions=raw["lines_removed"], + previous_filename=old["path"] if raw["status"] == "renamed" else None, + ) + + +def split_patches(diff: str) -> dict[str, str]: + """Split a unified diff into each file's hunks, keyed by the file's new path.""" + patches = {} + for section in re.split(r"^(?=diff --git )", diff, flags=re.MULTILINE): + header, _, body = section.partition("\n") + if header and (hunks := re.search(r"^@@", body, flags=re.MULTILINE)): + patches[header.rpartition(" b/")[2]] = body[hunks.start() :] + return patches + + def next_cursor(raw: dict[str, Any]) -> str | None: """Read the `page` value out of the `next` link, which is a page number or an opaque token.""" if not (next_url := raw.get("next")): diff --git a/tests/unit/provider/fixtures/bitbucket/commit.json b/tests/unit/provider/fixtures/bitbucket/commit.json new file mode 100644 index 00000000..eb6ab591 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commit.json @@ -0,0 +1,95 @@ +{ + "body": { + "author": { + "raw": "Test User ", + "type": "author", + "user": { + "account_id": "557058:00000000-0000-0000-0000-000000000001", + "display_name": "Test User", + "links": { + "avatar": { + "href": "https://avatar.example.com/test-user.png" + } + }, + "nickname": "test-user", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}" + } + }, + "date": "2026-11-05T20:16:34+00:00", + "hash": "e723f15089c22737bb51aee8db06e0eab91e4ba1", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/statuses" + } + }, + "message": "Rename, remove, add and change files\n", + "parents": [ + { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + } + }, + "type": "commit" + } + ], + "participants": [], + "rendered": { + "message": { + "html": "

Rename, remove, add and change files

", + "markup": "markdown", + "raw": "Rename, remove, add and change files\n", + "type": "rendered" + } + }, + "repository": { + "full_name": "test-workspace/test-repo", + "links": { + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B06cc78f5-9826-41c4-9bac-b4f44ca67ef4%7D?ts=default" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo" + } + }, + "name": "test-repo", + "type": "repository", + "uuid": "{06cc78f5-9826-41c4-9bac-b4f44ca67ef4}" + }, + "summary": { + "html": "

Rename, remove, add and change files

", + "markup": "markdown", + "raw": "Rename, remove, add and change files\n", + "type": "rendered" + }, + "type": "commit" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/commit_not_found.json b/tests/unit/provider/fixtures/bitbucket/commit_not_found.json new file mode 100644 index 00000000..be3a6c39 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commit_not_found.json @@ -0,0 +1,20 @@ +{ + "body": { + "data": { + "shas": [ + "deadbeef" + ] + }, + "error": { + "data": { + "shas": [ + "deadbeef" + ] + }, + "message": "Commit not found" + }, + "type": "error" + }, + "headers": {}, + "status": 404 +} diff --git a/tests/unit/provider/fixtures/bitbucket/commits_by_path_page_1.json b/tests/unit/provider/fixtures/bitbucket/commits_by_path_page_1.json new file mode 100644 index 00000000..63e13a8a --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commits_by_path_page_1.json @@ -0,0 +1,102 @@ +{ + "body": { + "next": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commits/s4-commits?path=src%2Ftest_app%2Fmath.py&pagelen=1&page=2", + "page": 1, + "pagelen": 1, + "size": 2, + "values": [ + { + "author": { + "raw": "Test User ", + "type": "author", + "user": { + "account_id": "557058:00000000-0000-0000-0000-000000000001", + "display_name": "Test User", + "links": { + "avatar": { + "href": "https://avatar.example.com/test-user.png" + } + }, + "nickname": "test-user", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}" + } + }, + "date": "2026-11-05T20:16:34+00:00", + "hash": "e723f15089c22737bb51aee8db06e0eab91e4ba1", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/statuses" + } + }, + "message": "Rename, remove, add and change files\n", + "parents": [ + { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + } + }, + "type": "commit" + } + ], + "rendered": { + "message": { + "html": "

Rename, remove, add and change files

", + "markup": "markdown", + "raw": "Rename, remove, add and change files\n", + "type": "rendered" + } + }, + "repository": { + "full_name": "test-workspace/test-repo", + "links": { + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B06cc78f5-9826-41c4-9bac-b4f44ca67ef4%7D?ts=default" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo" + } + }, + "name": "test-repo", + "type": "repository", + "uuid": "{06cc78f5-9826-41c4-9bac-b4f44ca67ef4}" + }, + "summary": { + "html": "

Rename, remove, add and change files

", + "markup": "markdown", + "raw": "Rename, remove, add and change files\n", + "type": "rendered" + }, + "type": "commit" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/commits_page_1.json b/tests/unit/provider/fixtures/bitbucket/commits_page_1.json new file mode 100644 index 00000000..3ce5bf08 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commits_page_1.json @@ -0,0 +1,190 @@ +{ + "body": { + "next": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commits/s4-commits?pagelen=2&ctx=f57da8e9c551db63dbf2e55108caf6ab&page=2", + "pagelen": 2, + "values": [ + { + "author": { + "raw": "Test User ", + "type": "author", + "user": { + "account_id": "557058:00000000-0000-0000-0000-000000000001", + "display_name": "Test User", + "links": { + "avatar": { + "href": "https://avatar.example.com/test-user.png" + } + }, + "nickname": "test-user", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}" + } + }, + "date": "2026-11-05T20:16:34+00:00", + "hash": "e3ef83d1a831b7b26c838372f668b1627bbf9a1a", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/e3ef83d1a831b7b26c838372f668b1627bbf9a1a" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/e3ef83d1a831b7b26c838372f668b1627bbf9a1a" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/e3ef83d1a831b7b26c838372f668b1627bbf9a1a" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e3ef83d1a831b7b26c838372f668b1627bbf9a1a" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/statuses" + } + }, + "message": "Update README\n", + "parents": [ + { + "hash": "e723f15089c22737bb51aee8db06e0eab91e4ba1", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1" + } + }, + "type": "commit" + } + ], + "rendered": { + "message": { + "html": "

Update README

", + "markup": "markdown", + "raw": "Update README\n", + "type": "rendered" + } + }, + "repository": { + "full_name": "test-workspace/test-repo", + "links": { + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B06cc78f5-9826-41c4-9bac-b4f44ca67ef4%7D?ts=default" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo" + } + }, + "name": "test-repo", + "type": "repository", + "uuid": "{06cc78f5-9826-41c4-9bac-b4f44ca67ef4}" + }, + "summary": { + "html": "

Update README

", + "markup": "markdown", + "raw": "Update README\n", + "type": "rendered" + }, + "type": "commit" + }, + { + "author": { + "raw": "Test User ", + "type": "author", + "user": { + "account_id": "557058:00000000-0000-0000-0000-000000000001", + "display_name": "Test User", + "links": { + "avatar": { + "href": "https://avatar.example.com/test-user.png" + } + }, + "nickname": "test-user", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}" + } + }, + "date": "2026-11-05T20:16:34+00:00", + "hash": "e723f15089c22737bb51aee8db06e0eab91e4ba1", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/statuses" + } + }, + "message": "Rename, remove, add and change files\n", + "parents": [ + { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + } + }, + "type": "commit" + } + ], + "rendered": { + "message": { + "html": "

Rename, remove, add and change files

", + "markup": "markdown", + "raw": "Rename, remove, add and change files\n", + "type": "rendered" + } + }, + "repository": { + "full_name": "test-workspace/test-repo", + "links": { + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B06cc78f5-9826-41c4-9bac-b4f44ca67ef4%7D?ts=default" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo" + } + }, + "name": "test-repo", + "type": "repository", + "uuid": "{06cc78f5-9826-41c4-9bac-b4f44ca67ef4}" + }, + "summary": { + "html": "

Rename, remove, add and change files

", + "markup": "markdown", + "raw": "Rename, remove, add and change files\n", + "type": "rendered" + }, + "type": "commit" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/compare_behind.json b/tests/unit/provider/fixtures/bitbucket/compare_behind.json new file mode 100644 index 00000000..1acd8f86 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/compare_behind.json @@ -0,0 +1,99 @@ +{ + "body": { + "pagelen": 100, + "values": [ + { + "author": { + "raw": "Test User ", + "type": "author", + "user": { + "account_id": "557058:00000000-0000-0000-0000-000000000001", + "display_name": "Test User", + "links": { + "avatar": { + "href": "https://avatar.example.com/test-user.png" + } + }, + "nickname": "test-user", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}" + } + }, + "date": "2026-11-05T20:00:34+00:00", + "hash": "c495d0431cfae535ec9a05e4def54229d0eb2ad2", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/c495d0431cfae535ec9a05e4def54229d0eb2ad2/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/c495d0431cfae535ec9a05e4def54229d0eb2ad2/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/c495d0431cfae535ec9a05e4def54229d0eb2ad2" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/c495d0431cfae535ec9a05e4def54229d0eb2ad2" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/c495d0431cfae535ec9a05e4def54229d0eb2ad2" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/c495d0431cfae535ec9a05e4def54229d0eb2ad2" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/c495d0431cfae535ec9a05e4def54229d0eb2ad2/statuses" + } + }, + "message": "Change math on test-branch-1\n", + "parents": [ + { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + } + }, + "type": "commit" + } + ], + "rendered": { + "message": { + "html": "

Change math on test-branch-1

", + "markup": "markdown", + "raw": "Change math on test-branch-1\n", + "type": "rendered" + } + }, + "repository": { + "full_name": "test-workspace/test-repo", + "links": { + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B06cc78f5-9826-41c4-9bac-b4f44ca67ef4%7D?ts=default" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo" + } + }, + "name": "test-repo", + "type": "repository", + "uuid": "{06cc78f5-9826-41c4-9bac-b4f44ca67ef4}" + }, + "summary": { + "html": "

Change math on test-branch-1

", + "markup": "markdown", + "raw": "Change math on test-branch-1\n", + "type": "rendered" + }, + "type": "commit" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/compare_commits.json b/tests/unit/provider/fixtures/bitbucket/compare_commits.json new file mode 100644 index 00000000..79c7360a --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/compare_commits.json @@ -0,0 +1,189 @@ +{ + "body": { + "pagelen": 100, + "values": [ + { + "author": { + "raw": "Test User ", + "type": "author", + "user": { + "account_id": "557058:00000000-0000-0000-0000-000000000001", + "display_name": "Test User", + "links": { + "avatar": { + "href": "https://avatar.example.com/test-user.png" + } + }, + "nickname": "test-user", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}" + } + }, + "date": "2026-11-05T20:16:34+00:00", + "hash": "e3ef83d1a831b7b26c838372f668b1627bbf9a1a", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/e3ef83d1a831b7b26c838372f668b1627bbf9a1a" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/e3ef83d1a831b7b26c838372f668b1627bbf9a1a" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/e3ef83d1a831b7b26c838372f668b1627bbf9a1a" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e3ef83d1a831b7b26c838372f668b1627bbf9a1a" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/statuses" + } + }, + "message": "Update README\n", + "parents": [ + { + "hash": "e723f15089c22737bb51aee8db06e0eab91e4ba1", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1" + } + }, + "type": "commit" + } + ], + "rendered": { + "message": { + "html": "

Update README

", + "markup": "markdown", + "raw": "Update README\n", + "type": "rendered" + } + }, + "repository": { + "full_name": "test-workspace/test-repo", + "links": { + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B06cc78f5-9826-41c4-9bac-b4f44ca67ef4%7D?ts=default" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo" + } + }, + "name": "test-repo", + "type": "repository", + "uuid": "{06cc78f5-9826-41c4-9bac-b4f44ca67ef4}" + }, + "summary": { + "html": "

Update README

", + "markup": "markdown", + "raw": "Update README\n", + "type": "rendered" + }, + "type": "commit" + }, + { + "author": { + "raw": "Test User ", + "type": "author", + "user": { + "account_id": "557058:00000000-0000-0000-0000-000000000001", + "display_name": "Test User", + "links": { + "avatar": { + "href": "https://avatar.example.com/test-user.png" + } + }, + "nickname": "test-user", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}" + } + }, + "date": "2026-11-05T20:16:34+00:00", + "hash": "e723f15089c22737bb51aee8db06e0eab91e4ba1", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/e723f15089c22737bb51aee8db06e0eab91e4ba1/statuses" + } + }, + "message": "Rename, remove, add and change files\n", + "parents": [ + { + "hash": "a682892aa0f4502ef7a242f75d72d0b41eaa27b9", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + } + }, + "type": "commit" + } + ], + "rendered": { + "message": { + "html": "

Rename, remove, add and change files

", + "markup": "markdown", + "raw": "Rename, remove, add and change files\n", + "type": "rendered" + } + }, + "repository": { + "full_name": "test-workspace/test-repo", + "links": { + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B06cc78f5-9826-41c4-9bac-b4f44ca67ef4%7D?ts=default" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo" + } + }, + "name": "test-repo", + "type": "repository", + "uuid": "{06cc78f5-9826-41c4-9bac-b4f44ca67ef4}" + }, + "summary": { + "html": "

Rename, remove, add and change files

", + "markup": "markdown", + "raw": "Rename, remove, add and change files\n", + "type": "rendered" + }, + "type": "commit" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/compare_diff.json b/tests/unit/provider/fixtures/bitbucket/compare_diff.json new file mode 100644 index 00000000..7b11312c --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/compare_diff.json @@ -0,0 +1,5 @@ +{ + "body": "diff --git a/README.md b/README.md\nindex 836111e..5d06a84 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,3 +1,3 @@\n-# test-repo\n+# Test repo\n \n-A test repository.\n+Made-up content for tests.\ndiff --git a/PULL_REQUEST_TEMPLATE.md b/docs_PULL_REQUEST_TEMPLATE.md\nsimilarity index 100%\nrename from PULL_REQUEST_TEMPLATE.md\nrename to docs_PULL_REQUEST_TEMPLATE.md\ndiff --git a/src/test_app/math.py b/src/test_app/math.py\nindex 4693ad3..3581473 100644\n--- a/src/test_app/math.py\n+++ b/src/test_app/math.py\n@@ -1,2 +1,6 @@\n def add(a, b):\n return a + b\n+\n+\n+def multiply(a, b):\n+ return a * b\ndiff --git a/src/test_app/ops.py b/src/test_app/ops.py\nnew file mode 100644\nindex 0000000..6078a09\n--- /dev/null\n+++ b/src/test_app/ops.py\n@@ -0,0 +1,2 @@\n+def subtract(a, b):\n+ return a - b\ndiff --git a/tests/test_math.py b/tests/test_math.py\ndeleted file mode 100644\nindex cfac67c..0000000\n--- a/tests/test_math.py\n+++ /dev/null\n@@ -1,5 +0,0 @@\n-from test_app.math import add\n-\n-\n-def test_add():\n- assert add(1, 2) == 3\n", + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/compare_diffstat.json b/tests/unit/provider/fixtures/bitbucket/compare_diffstat.json new file mode 100644 index 00000000..430a42e7 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/compare_diffstat.json @@ -0,0 +1,123 @@ +{ + "body": { + "page": 1, + "pagelen": 100, + "size": 5, + "values": [ + { + "lines_added": 2, + "lines_removed": 2, + "new": { + "escaped_path": "README.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/README.md" + } + }, + "path": "README.md", + "type": "commit_file" + }, + "old": { + "escaped_path": "README.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/README.md" + } + }, + "path": "README.md", + "type": "commit_file" + }, + "status": "modified", + "type": "diffstat" + }, + { + "lines_added": 0, + "lines_removed": 0, + "new": { + "escaped_path": "docs_PULL_REQUEST_TEMPLATE.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/docs_PULL_REQUEST_TEMPLATE.md" + } + }, + "path": "docs_PULL_REQUEST_TEMPLATE.md", + "type": "commit_file" + }, + "old": { + "escaped_path": "PULL_REQUEST_TEMPLATE.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/PULL_REQUEST_TEMPLATE.md" + } + }, + "path": "PULL_REQUEST_TEMPLATE.md", + "type": "commit_file" + }, + "status": "renamed", + "type": "diffstat" + }, + { + "lines_added": 4, + "lines_removed": 0, + "new": { + "escaped_path": "src/test_app/math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/src/test_app/math.py" + } + }, + "path": "src/test_app/math.py", + "type": "commit_file" + }, + "old": { + "escaped_path": "src/test_app/math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/src/test_app/math.py" + } + }, + "path": "src/test_app/math.py", + "type": "commit_file" + }, + "status": "modified", + "type": "diffstat" + }, + { + "lines_added": 2, + "lines_removed": 0, + "new": { + "escaped_path": "src/test_app/ops.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e3ef83d1a831b7b26c838372f668b1627bbf9a1a/src/test_app/ops.py" + } + }, + "path": "src/test_app/ops.py", + "type": "commit_file" + }, + "old": null, + "status": "added", + "type": "diffstat" + }, + { + "lines_added": 0, + "lines_removed": 5, + "new": null, + "old": { + "escaped_path": "tests/test_math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/tests/test_math.py" + } + }, + "path": "tests/test_math.py", + "type": "commit_file" + }, + "status": "removed", + "type": "diffstat" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/diff.json b/tests/unit/provider/fixtures/bitbucket/diff.json new file mode 100644 index 00000000..9312b8c1 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/diff.json @@ -0,0 +1,5 @@ +{ + "body": "diff --git a/PULL_REQUEST_TEMPLATE.md b/docs_PULL_REQUEST_TEMPLATE.md\nsimilarity index 100%\nrename from PULL_REQUEST_TEMPLATE.md\nrename to docs_PULL_REQUEST_TEMPLATE.md\ndiff --git a/src/test_app/math.py b/src/test_app/math.py\nindex 4693ad3..3581473 100644\n--- a/src/test_app/math.py\n+++ b/src/test_app/math.py\n@@ -1,2 +1,6 @@\n def add(a, b):\n return a + b\n+\n+\n+def multiply(a, b):\n+ return a * b\ndiff --git a/src/test_app/ops.py b/src/test_app/ops.py\nnew file mode 100644\nindex 0000000..6078a09\n--- /dev/null\n+++ b/src/test_app/ops.py\n@@ -0,0 +1,2 @@\n+def subtract(a, b):\n+ return a - b\ndiff --git a/tests/test_math.py b/tests/test_math.py\ndeleted file mode 100644\nindex cfac67c..0000000\n--- a/tests/test_math.py\n+++ /dev/null\n@@ -1,5 +0,0 @@\n-from test_app.math import add\n-\n-\n-def test_add():\n- assert add(1, 2) == 3\n", + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/diffstat.json b/tests/unit/provider/fixtures/bitbucket/diffstat.json new file mode 100644 index 00000000..70dcfa64 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/diffstat.json @@ -0,0 +1,97 @@ +{ + "body": { + "page": 1, + "pagelen": 500, + "size": 4, + "values": [ + { + "lines_added": 0, + "lines_removed": 0, + "new": { + "escaped_path": "docs_PULL_REQUEST_TEMPLATE.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e723f15089c22737bb51aee8db06e0eab91e4ba1/docs_PULL_REQUEST_TEMPLATE.md" + } + }, + "path": "docs_PULL_REQUEST_TEMPLATE.md", + "type": "commit_file" + }, + "old": { + "escaped_path": "PULL_REQUEST_TEMPLATE.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/PULL_REQUEST_TEMPLATE.md" + } + }, + "path": "PULL_REQUEST_TEMPLATE.md", + "type": "commit_file" + }, + "status": "renamed", + "type": "diffstat" + }, + { + "lines_added": 4, + "lines_removed": 0, + "new": { + "escaped_path": "src/test_app/math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e723f15089c22737bb51aee8db06e0eab91e4ba1/src/test_app/math.py" + } + }, + "path": "src/test_app/math.py", + "type": "commit_file" + }, + "old": { + "escaped_path": "src/test_app/math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/src/test_app/math.py" + } + }, + "path": "src/test_app/math.py", + "type": "commit_file" + }, + "status": "modified", + "type": "diffstat" + }, + { + "lines_added": 2, + "lines_removed": 0, + "new": { + "escaped_path": "src/test_app/ops.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e723f15089c22737bb51aee8db06e0eab91e4ba1/src/test_app/ops.py" + } + }, + "path": "src/test_app/ops.py", + "type": "commit_file" + }, + "old": null, + "status": "added", + "type": "diffstat" + }, + { + "lines_added": 0, + "lines_removed": 5, + "new": null, + "old": { + "escaped_path": "tests/test_math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/tests/test_math.py" + } + }, + "path": "tests/test_math.py", + "type": "commit_file" + }, + "status": "removed", + "type": "diffstat" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/diffstat_page_1.json b/tests/unit/provider/fixtures/bitbucket/diffstat_page_1.json new file mode 100644 index 00000000..33cf478d --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/diffstat_page_1.json @@ -0,0 +1,64 @@ +{ + "body": { + "next": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diffstat/e723f15089c22737bb51aee8db06e0eab91e4ba1?pagelen=2&page=2", + "page": 1, + "pagelen": 2, + "size": 4, + "values": [ + { + "lines_added": 0, + "lines_removed": 0, + "new": { + "escaped_path": "docs_PULL_REQUEST_TEMPLATE.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e723f15089c22737bb51aee8db06e0eab91e4ba1/docs_PULL_REQUEST_TEMPLATE.md" + } + }, + "path": "docs_PULL_REQUEST_TEMPLATE.md", + "type": "commit_file" + }, + "old": { + "escaped_path": "PULL_REQUEST_TEMPLATE.md", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/PULL_REQUEST_TEMPLATE.md" + } + }, + "path": "PULL_REQUEST_TEMPLATE.md", + "type": "commit_file" + }, + "status": "renamed", + "type": "diffstat" + }, + { + "lines_added": 4, + "lines_removed": 0, + "new": { + "escaped_path": "src/test_app/math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/e723f15089c22737bb51aee8db06e0eab91e4ba1/src/test_app/math.py" + } + }, + "path": "src/test_app/math.py", + "type": "commit_file" + }, + "old": { + "escaped_path": "src/test_app/math.py", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/src/test_app/math.py" + } + }, + "path": "src/test_app/math.py", + "type": "commit_file" + }, + "status": "modified", + "type": "diffstat" + } + ] + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index fa0614de..3e21908d 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -2,6 +2,7 @@ import json import pathlib import unittest.mock +from datetime import UTC, date, datetime from typing import Any import pytest @@ -11,13 +12,22 @@ from scm.providers.bitbucket.provider import ( BitbucketProvider, map_app_installation, + map_commit_author, map_paginated_action, map_repository, next_cursor, parse_scopes, + split_patches, ) from scm.types import ( + CompareCommitsProtocol, GetAppInstallationProtocol, + GetCommitChangesProtocol, + GetCommitProtocol, + GetCommitsByPathProtocol, + GetCommitsProtocol, + GetCommitsUrlProtocol, + GetCommitUrlProtocol, GetDirectoryContentsProtocol, GetFileContentProtocol, GetFileUrlProtocol, @@ -33,6 +43,7 @@ SRC = f"/2.0/repositories/{REPO}/src" SUPPORTED_PROTOCOLS = [ + CompareCommitsProtocol, GetAppInstallationProtocol, GetGitRemoteProtocol, GetDirectoryContentsProtocol, @@ -40,8 +51,18 @@ GetFileUrlProtocol, GetPullRequestTemplateProtocol, GetReadmeProtocol, + GetCommitChangesProtocol, + GetCommitProtocol, + GetCommitsByPathProtocol, + GetCommitsProtocol, + GetCommitsUrlProtocol, + GetCommitUrlProtocol, GetRepositoryProtocol, ] +COMMIT_SHA = "e723f15089c22737bb51aee8db06e0eab91e4ba1" +HEAD_SHA = "e3ef83d1a831b7b26c838372f668b1627bbf9a1a" +BRANCH_SHA = "c495d0431cfae535ec9a05e4def54229d0eb2ad2" +TEST_AUTHOR = {"name": "Test User", "email": "test@example.com", "date": datetime(2026, 11, 5, 20, 16, 34, tzinfo=UTC)} def recorded(name: str) -> requests.Response: @@ -352,3 +373,262 @@ def test_line_anchors(self, provider: BitbucketProvider, start: int | None, end: def test_quotes_path(self, provider: BitbucketProvider) -> None: assert provider.get_file_url("docs/read me.md", "abc123").endswith("/src/abc123/docs/read%20me.md") + + +class TestCommitUrls: + def test_commit_url(self, provider: BitbucketProvider) -> None: + assert provider.get_commit_url("abc") == f"https://bitbucket.org/{REPO}/commits/abc" + + def test_file_history_url_ignores_dates(self, provider: BitbucketProvider) -> None: + url = provider.get_commits_url( + "abc", file_path="src/my file.py", since=date(2026, 1, 1), until=date(2026, 2, 1) + ) + assert url == f"https://bitbucket.org/{REPO}/history-node/abc/src/my%20file.py" + + def test_commits_url_without_path(self, provider: BitbucketProvider) -> None: + assert provider.get_commits_url("abc", since=date(2026, 1, 1)) == f"https://bitbucket.org/{REPO}/commits" + + +class TestGetCommit: + def test_maps_commit_with_files(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("commit"), recorded("diffstat"), recorded("diff")] + + result = provider.get_commit(COMMIT_SHA) + + assert requested_paths(client) == [ + f"/2.0/repositories/{REPO}/commit/{COMMIT_SHA}", + f"/2.0/repositories/{REPO}/diffstat/{COMMIT_SHA}", + f"/2.0/repositories/{REPO}/diff/{COMMIT_SHA}", + ] + commit = result["data"] + assert commit["id"] == COMMIT_SHA + assert commit["message"] == "Rename, remove, add and change files\n" + assert commit["author"] == TEST_AUTHOR + assert (commit["additions"], commit["deletions"]) == (6, 5) + assert [(f["filename"], f["status"]) for f in commit["files"] or []] == [ + ("docs_PULL_REQUEST_TEMPLATE.md", "renamed"), + ("src/test_app/math.py", "modified"), + ("src/test_app/ops.py", "added"), + ("tests/test_math.py", "removed"), + ] + + def test_partial_files_leave_totals_unset( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [recorded("commit"), recorded("diffstat_page_1"), recorded("diff")] + + commit = provider.get_commit(COMMIT_SHA)["data"] + + assert len(commit["files"] or []) == 2 + assert (commit["additions"], commit["deletions"]) == (None, None) + + def test_missing_commit(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("commit_not_found") + with pytest.raises(ResourceNotFound): + provider.get_commit("deadbeef") + + +class TestGetCommitChanges: + def test_maps_files_with_patches(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("diffstat"), recorded("diff")] + + result = provider.get_commit_changes(COMMIT_SHA) + + assert result["meta"]["next_cursor"] is None + renamed, modified, added, removed = result["data"] + assert renamed == { + "filename": "docs_PULL_REQUEST_TEMPLATE.md", + "status": "renamed", + "patch": None, + "additions": 0, + "deletions": 0, + "previous_filename": "PULL_REQUEST_TEMPLATE.md", + } + assert modified["patch"] == ( + "@@ -1,2 +1,6 @@\n def add(a, b):\n return a + b\n+\n+\n+def multiply(a, b):\n+ return a * b\n" + ) + assert (modified["additions"], modified["deletions"], modified["previous_filename"]) == (4, 0, None) + assert added["patch"] == "@@ -0,0 +1,2 @@\n+def subtract(a, b):\n+ return a - b\n" + assert (removed["filename"], removed["deletions"]) == ("tests/test_math.py", 5) + assert (removed["patch"] or "").startswith("@@ -1,5 +0,0 @@\n-from test_app.math import add") + + def test_pages(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("diffstat_page_1"), recorded("diff")] + + result = provider.get_commit_changes(COMMIT_SHA, pagination={"cursor": "1", "per_page": 2}) + + assert len(result["data"]) == 2 + assert result["meta"]["next_cursor"] == "2" + assert client.request.call_args_list[0].kwargs["params"] == {"pagelen": "2"} + + def test_unknown_status(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + diffstat = recorded("diffstat") + body = json.loads(diffstat.content) + body["values"][1]["status"] = "merge conflict" + diffstat._content = json.dumps(body).encode() + client.request.side_effect = [diffstat, recorded("diff")] + + assert provider.get_commit_changes(COMMIT_SHA)["data"][1]["status"] == "unknown" + + +class TestGetCommits: + def test_lists_commits_on_ref(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("commits_page_1") + + result = provider.get_commits("feature/x", pagination={"per_page": 2}) + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/commits/feature/x" + assert request_kwargs(client)["params"] == {"pagelen": "2"} + assert [c["id"] for c in result["data"]] == [HEAD_SHA, COMMIT_SHA] + assert result["data"][0]["message"] == "Update README\n" + assert result["data"][0]["author"] == TEST_AUTHOR + assert result["meta"]["next_cursor"] == "2" + + def test_defaults_to_main_branch(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("commits_page_1") + provider.get_commits() + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/commits/HEAD" + + def test_date_filter_skips_pages_after_until( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + def page(date: str, next_url: str | None) -> requests.Response: + response = requests.Response() + response.status_code = 200 + commit = {"hash": date, "message": "m", "author": {"raw": "Test User "}, "date": date} + body: dict[str, Any] = {"values": [commit]} + if next_url: + body["next"] = next_url + response._content = json.dumps(body).encode() + return response + + client.request.side_effect = [ + page("2026-11-05T00:00:00+00:00", "https://api.bitbucket.org/2.0/commits?page=2"), + page("2026-11-01T00:00:00+00:00", None), + ] + + result = provider.get_commits(until=datetime(2026, 11, 2, tzinfo=UTC), pagination={"per_page": 1}) + + assert [c["id"] for c in result["data"]] == ["2026-11-01T00:00:00+00:00"] + assert client.request.call_args_list[1].kwargs["params"] == {"pagelen": "1", "page": "2"} + + def test_lists_commits_by_path(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("commits_by_path_page_1") + + result = provider.get_commits_by_path("src/test_app/math.py", ref="s4-commits", pagination={"per_page": 1}) + + assert request_kwargs(client)["path"] == f"/2.0/repositories/{REPO}/commits/s4-commits" + assert request_kwargs(client)["params"] == {"path": "src/test_app/math.py", "pagelen": "1"} + assert [c["id"] for c in result["data"]] == [COMMIT_SHA] + assert result["meta"]["next_cursor"] == "2" + + @pytest.mark.parametrize( + ("kwargs", "ids", "next_cursor"), + [ + ({"since": datetime(2026, 11, 3, tzinfo=UTC)}, [HEAD_SHA], "2"), + ({"since": datetime(2026, 11, 1)}, [HEAD_SHA, COMMIT_SHA], "2"), + ({"until": datetime(2026, 11, 3, tzinfo=UTC)}, [COMMIT_SHA], "2"), + ({"since": datetime(2026, 11, 6, tzinfo=UTC)}, [], None), + ], + ) + def test_filters_by_date( + self, + provider: BitbucketProvider, + client: unittest.mock.MagicMock, + kwargs: dict[str, Any], + ids: list[str], + next_cursor: str | None, + ) -> None: + page = recorded("commits_page_1") + body = json.loads(page.content) + body["values"][1]["date"] = "2026-11-02T08:00:00+00:00" + page._content = json.dumps(body).encode() + client.request.return_value = page + + result = provider.get_commits_by_path("README.md", **kwargs) + + assert [c["id"] for c in result["data"]] == ids + assert result["meta"]["next_cursor"] == next_cursor + + +class TestCompareCommits: + def test_compares_against_merge_base(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + recorded("compare_commits"), + recorded("compare_diff"), + recorded("compare_diffstat"), + recorded("compare_behind"), + ] + + result = provider.compare_commits(BRANCH_SHA, HEAD_SHA) + + calls = client.request.call_args_list + assert calls[0].kwargs["params"] == {"include": HEAD_SHA, "exclude": BRANCH_SHA, "pagelen": "100"} + assert requested_paths(client)[1:3] == [ + f"/2.0/repositories/{REPO}/diff/{HEAD_SHA}..{BRANCH_SHA}", + f"/2.0/repositories/{REPO}/diffstat/{HEAD_SHA}..{BRANCH_SHA}", + ] + assert calls[3].kwargs["params"] == {"include": BRANCH_SHA, "exclude": HEAD_SHA, "pagelen": "100"} + comparison = result["data"] + assert (comparison["ahead_by"], comparison["behind_by"]) == (2, 1) + assert [c["id"] for c in comparison["commits"]] == [HEAD_SHA, COMMIT_SHA] + assert [(f["filename"], f["status"]) for f in comparison["diff"]] == [ + ("README.md", "modified"), + ("docs_PULL_REQUEST_TEMPLATE.md", "renamed"), + ("src/test_app/math.py", "modified"), + ("src/test_app/ops.py", "added"), + ("tests/test_math.py", "removed"), + ] + assert all(f["patch"] for f in comparison["diff"] if f["status"] != "renamed") + assert result["meta"]["next_cursor"] is None + + def test_paged_compare_skips_behind(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + recorded("compare_commits"), + recorded("compare_diff"), + recorded("compare_diffstat"), + ] + + result = provider.compare_commits(BRANCH_SHA, HEAD_SHA, pagination={"per_page": 2}) + + assert client.request.call_count == 3 + assert "behind_by" not in result["data"] + + def test_reads_every_changed_file(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + recorded("compare_commits"), + recorded("compare_diff"), + recorded("diffstat_page_1"), + recorded("diffstat"), + ] + + result = provider.compare_commits(BRANCH_SHA, HEAD_SHA, pagination={"per_page": 2}) + + files = len(recorded("diffstat_page_1").json()["values"]) + len(recorded("diffstat").json()["values"]) + assert len(result["data"]["diff"]) == files + + def test_include_behind_with_pagination(self, provider: BitbucketProvider) -> None: + with pytest.raises(ResourceBadRequest): + provider.compare_commits(BRANCH_SHA, HEAD_SHA, pagination={"per_page": 1}, include_behind=True) + + +class TestCommitMapping: + @pytest.mark.parametrize( + ("raw", "name", "email"), + [ + ({"raw": "Test User "}, "Test User", "test@example.com"), + ({"raw": ""}, "", "test@example.com"), + ({"raw": "Test User"}, "Test User", ""), + ], + ) + def test_author_without_linked_user(self, raw: dict[str, Any], name: str, email: str) -> None: + author = map_commit_author(raw, "2026-11-05T20:16:34+00:00") + assert (author["name"], author["email"]) == (name, email) + + def test_split_patches_skips_files_without_hunks(self) -> None: + diff = ( + "diff --git a/old.md b/new.md\nsimilarity index 100%\nrename from old.md\nrename to new.md\n" + "diff --git a/img.png b/img.png\nBinary files a/img.png and b/img.png differ\n" + "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n@@ -1 +1 @@\n-x\n+y\n" + ) + assert split_patches(diff) == {"a.py": "@@ -1 +1 @@\n-x\n+y\n"}