Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions bin/bitbucket-client
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ Commands:
get-readme --ref <ref>
get-pull-request-template --ref <ref>
get-file-url <path> <sha> [--start-line <n>] [--end-line <n>]
get-commit <sha>
get-commit-changes <sha> [--cursor <cursor>] [--per-page <n>]
get-commits [--ref <ref>] [--cursor <cursor>] [--per-page <n>]
get-commits-by-path <path> [--ref <ref>] [--cursor <cursor>] [--per-page <n>]
get-commit-url <sha>
get-commits-url <sha> [--file-path <path>] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
compare-commits <start_sha> <end_sha> [--include-behind]
"""

import argparse
Expand All @@ -28,18 +35,27 @@ 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,
GetGitRemoteProtocol,
GetPullRequestTemplateProtocol,
GetReadmeProtocol,
GetRepositoryProtocol,
PaginationParams,
)

logger = logging.getLogger(__name__)
Expand All @@ -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] = {}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
222 changes: 222 additions & 0 deletions src/scm/providers/bitbucket/provider.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -21,9 +23,15 @@
ActionResult,
ApiClient,
AppInstallation,
Commit,
CommitAuthor,
CommitComparison,
CommitFile,
CommitWithChanges,
CredentialsSet,
FileContent,
FileContentType,
FileStatus,
GitRemote,
GitRepository,
PaginatedActionResult,
Expand Down Expand Up @@ -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<name>.*?)\s*<(?P<email>[^>]*)>\s*$")


class BitbucketProvider:
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -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 <email>` 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")):
Expand Down
Loading
Loading