diff --git a/bin/bitbucket-client b/bin/bitbucket-client index 551e92b..6fb4023 100755 --- a/bin/bitbucket-client +++ b/bin/bitbucket-client @@ -27,9 +27,18 @@ Commands: get-commit-url get-commits-url [--file-path ] [--since YYYY-MM-DD] [--until YYYY-MM-DD] compare-commits [--include-behind] + create-commit + [--create = ...] ( of @ uploads that local file) + [--update = ...] + [--delete ...] + [--move : ...] + [--create-branch] + [--author-name --author-email ] + [--expected-head-sha ] """ import argparse +import base64 import json import logging import os @@ -40,7 +49,11 @@ from datetime import date from scm import Secret from scm.manager import SourceCodeManager from scm.types import ( + ChmodCommitAction, + CommitAuthorParam, CompareCommitsProtocol, + CreateCommitProtocol, + DeleteCommitAction, GetAppInstallationProtocol, GetCommitChangesProtocol, GetCommitProtocol, @@ -55,7 +68,9 @@ from scm.types import ( GetPullRequestTemplateProtocol, GetReadmeProtocol, GetRepositoryProtocol, + MoveCommitAction, PaginationParams, + WriteCommitAction, ) logger = logging.getLogger(__name__) @@ -163,6 +178,18 @@ def main(): p.add_argument("start_sha") p.add_argument("end_sha") p.add_argument("--include-behind", action="store_true", dest="include_behind") + p = sub.add_parser("create-commit") + p.add_argument("branch") + p.add_argument("parent_sha") + p.add_argument("message") + p.add_argument("--create", action="append", dest="creates", default=None, metavar="FILENAME=CONTENT") + p.add_argument("--update", action="append", dest="updates", default=None, metavar="FILENAME=CONTENT") + p.add_argument("--delete", action="append", dest="deletes", default=None, metavar="FILENAME") + p.add_argument("--move", action="append", dest="moves", default=None, metavar="OLD:NEW") + p.add_argument("--create-branch", action="store_true", dest="create_branch") + p.add_argument("--author-name", dest="author_name", default=None) + p.add_argument("--author-email", dest="author_email", default=None) + p.add_argument("--expected-head-sha", dest="expected_head_sha", default=None) args = parser.parse_args() if not args.command: @@ -240,6 +267,42 @@ def main(): assert isinstance(scm, CompareCommitsProtocol) dump(scm.compare_commits(args.start_sha, args.end_sha, include_behind=args.include_behind)) + elif args.command == "create-commit": + assert isinstance(scm, CreateCommitProtocol) + actions: list[ChmodCommitAction | DeleteCommitAction | MoveCommitAction | WriteCommitAction] = [] + for kind, specs in (("create", args.creates), ("update", args.updates)): + for spec in specs or []: + filename, _, content = spec.partition("=") + if content.startswith("@"): + with open(content[1:], "rb") as f: + content = base64.b64encode(f.read()).decode() + actions.append( + WriteCommitAction(action=kind, filename=filename, content=content, encoding="base64") + ) + else: + actions.append(WriteCommitAction(action=kind, filename=filename, content=content, encoding="utf-8")) + for filename in args.deletes or []: + actions.append(DeleteCommitAction(filename=filename)) + for spec in args.moves or []: + old, _, new = spec.partition(":") + actions.append(MoveCommitAction(old_filename=old, new_filename=new)) + author = None + if args.author_name or args.author_email: + if not (args.author_name and args.author_email): + parser.error("--author-name and --author-email must be used together") + author = CommitAuthorParam(name=args.author_name, email=args.author_email) + dump( + scm.create_commit( + args.branch, + args.parent_sha, + args.message, + actions, + create_branch=args.create_branch, + author=author, + expected_head_sha=args.expected_head_sha, + ) + ) + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) diff --git a/src/scm/actions.py b/src/scm/actions.py index 1ed9706..0d9fc27 100644 --- a/src/scm/actions.py +++ b/src/scm/actions.py @@ -718,6 +718,10 @@ def create_commit( still wins, and `stale_branch_head` is then raised *after* the commit exists and the branch has already moved. Treat the error as "the commit landed on an unexpected parent", not as "nothing happened". + - Bitbucket is atomic: it only commits onto the branch head, so + `parent_sha` must be the head (and `expected_head_sha`, if given, must + equal it), and a moved head raises `stale_branch_head` with nothing + written. """ return scm.create_commit( branch, diff --git a/src/scm/providers/bitbucket/provider.py b/src/scm/providers/bitbucket/provider.py index a12e280..90ad000 100644 --- a/src/scm/providers/bitbucket/provider.py +++ b/src/scm/providers/bitbucket/provider.py @@ -1,5 +1,7 @@ import base64 +import binascii import hashlib +import posixpath import re from collections.abc import Callable, Iterator from datetime import UTC, date, datetime @@ -7,14 +9,17 @@ from urllib.parse import parse_qs, quote, unquote, urlparse import requests +from urllib3 import encode_multipart_formdata from scm.errors import ( PathIsDirectory, PathIsNotDirectory, ReadmeNotFound, ResourceBadRequest, + ResourceConflict, ResourceNotFound, SCMCodedError, + StaleBranchHead, error_class_for_status, ) from scm.helpers import iter_all_pages @@ -23,22 +28,28 @@ ActionResult, ApiClient, AppInstallation, + BranchName, + ChmodCommitAction, Commit, CommitAuthor, + CommitAuthorParam, CommitComparison, CommitFile, CommitWithChanges, CredentialsSet, + DeleteCommitAction, FileContent, FileContentType, FileStatus, GitRemote, GitRepository, + MoveCommitAction, PaginatedActionResult, PaginationParams, ProviderName, Repository, RequestOptions, + WriteCommitAction, ) PROVIDER_TYPE: ProviderName = "bitbucket" @@ -463,6 +474,98 @@ def _get_commits( return result pagination = {**(pagination or {}), "cursor": cursor} + def create_commit( + self, + branch: BranchName, + parent_sha: SHA, + message: str, + actions: list[ChmodCommitAction | DeleteCommitAction | MoveCommitAction | WriteCommitAction], + force: bool = False, + create_branch: bool = False, + author: CommitAuthorParam | None = None, + *, + expected_head_sha: SHA | None = None, + ) -> ActionResult[Commit]: + """Commit `actions` onto `branch`. See :func:`scm.actions.create_commit`. + + Bitbucket only commits at the branch head, so `parent_sha` must match it. + """ + if force: + raise ResourceBadRequest(detail="Bitbucket cannot force a branch to a new commit.") + if expected_head_sha is not None: + if create_branch: + raise ResourceBadRequest( + detail="'expected_head_sha' cannot be combined with 'create_branch': the branch has no head yet.", + ) + if expected_head_sha != parent_sha: + raise ResourceBadRequest(detail="Bitbucket commits onto the branch head, so it must be 'parent_sha'.") + + # Bitbucket creates a missing branch from `parents` rather than failing. + exists = self._branch_exists(branch) + if create_branch and exists: + raise ResourceConflict(detail=f"Branch '{branch}' already exists.") + if not create_branch and not exists: + raise ResourceNotFound(detail=f"Branch '{branch}' does not exist.") + + fields: list[tuple[str, str | tuple[str, bytes, str]]] = [ + ("message", message), + ("branch", branch), + ("parents", parent_sha), + ] + if author is not None: + fields.append(("author", f"{author['name']} <{author['email']}>")) + for action in actions: + if isinstance(action, WriteCommitAction): + fields.append(file_part(action.filename, decode_content(action))) + elif isinstance(action, DeleteCommitAction): + fields.append(("files", action.filename)) + elif isinstance(action, MoveCommitAction): + fields.append(("files", action.old_filename)) + fields.append(file_part(action.new_filename, self._file_content(action.old_filename, parent_sha))) + else: + raise ResourceBadRequest(detail="Bitbucket cannot change a file's mode through its commit API.") + + body, content_type = encode_multipart_formdata(fields) + try: + response = self.request( + "POST", + f"/2.0/repositories/{self.repository_path}/src", + headers={"Content-Type": content_type}, + data=body, + ) + except ResourceBadRequest as e: + if "is not the head of branch" not in e.detail: + raise + raise StaleBranchHead(detail=f"Branch '{branch}' is not at {parent_sha}.") from e + + sha = response.headers["Location"].rstrip("/").rsplit("/", 1)[-1] + commit_author = CommitAuthor(name=author["name"], email=author["email"], date=None) if author else None + return { + "data": Commit(id=sha, message=message, author=commit_author, additions=None, deletions=None), + "type": PROVIDER_TYPE, + "raw": {"data": None, "headers": dict(response.headers)}, + "meta": {}, + } + + def _branch_exists(self, branch: BranchName) -> bool: + try: + self.get(f"/2.0/repositories/{self.repository_path}/refs/branches/{quote(branch)}") + except ResourceNotFound: + return False + return True + + def _file_content(self, path: str, ref: SHA) -> bytes: + """Read a file to rewrite it elsewhere, refusing what an upload can't recreate.""" + file_path = self._src_path(ref, path) + # An upload can't set attributes, so a moved executable or symlink would lose them. + # `binary` only describes the content, which the upload keeps. + attributes = set(self.get(file_path, params={"format": "meta"}).json().get("attributes", [])) - {"binary"} + if attributes: + raise ResourceBadRequest( + detail=f"Bitbucket cannot move '{path}' and keep its {', '.join(sorted(attributes))} attribute." + ) + return self.get(file_path).content + def parse_scopes(header: str) -> set[str]: return {scope.strip() for scope in header.split(",") if scope.strip()} @@ -575,6 +678,25 @@ def split_patches(diff: str) -> dict[str, str]: return patches +def decode_content(action: WriteCommitAction) -> bytes: + if action.encoding == "base64": + try: + # GitHub wraps its base64 file content in newlines. + return base64.b64decode("".join(action.content.split()), validate=True) + except binascii.Error as e: + raise ResourceBadRequest(detail=f"'{action.filename}' is not valid base64.") from e + return action.content.encode("utf-8") + + +def file_part(path: str, content: bytes) -> tuple[str, tuple[str, bytes, str]]: + """A form part that Bitbucket writes to `path`. + + The leading slash keeps a file named like a form field, such as `message`, from being read as that field. + """ + # Bitbucket decodes a part without a filename as text, which mangles binary content. + return f"/{path.lstrip('/')}", (posixpath.basename(path), content, "application/octet-stream") + + 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_branch.json b/tests/unit/provider/fixtures/bitbucket/commit_branch.json new file mode 100644 index 0000000..62db5ad --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commit_branch.json @@ -0,0 +1,111 @@ +{ + "body": { + "default_merge_strategy": "merge_commit", + "links": { + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commits/test-branch" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/branch/test-branch" + }, + "pullrequest_create": { + "href": "https://bitbucket.org/test-workspace/test-repo/pull-requests/new?source=test-branch&t=1" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/refs/branches/test-branch" + } + }, + "merge_strategies": [ + "merge_commit", + "squash", + "fast_forward", + "squash_fast_forward", + "rebase_fast_forward", + "rebase_merge" + ], + "name": "test-branch", + "sync_strategies": [ + "merge_commit", + "rebase" + ], + "target": { + "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:25:05+00:00", + "hash": "25a878f7635147b9eef09e958ca34395bc67b705", + "links": { + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/25a878f7635147b9eef09e958ca34395bc67b705/approve" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/25a878f7635147b9eef09e958ca34395bc67b705/comments" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/diff/25a878f7635147b9eef09e958ca34395bc67b705" + }, + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/25a878f7635147b9eef09e958ca34395bc67b705" + }, + "patch": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/patch/25a878f7635147b9eef09e958ca34395bc67b705" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/25a878f7635147b9eef09e958ca34395bc67b705" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/25a878f7635147b9eef09e958ca34395bc67b705/statuses" + } + }, + "message": "Add notes\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" + } + ], + "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}" + }, + "type": "commit" + }, + "type": "branch" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/commit_branch_not_found.json b/tests/unit/provider/fixtures/bitbucket/commit_branch_not_found.json new file mode 100644 index 0000000..8ec3cbf --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commit_branch_not_found.json @@ -0,0 +1,10 @@ +{ + "body": { + "error": { + "message": "missing-branch" + }, + "type": "error" + }, + "headers": {}, + "status": 404 +} diff --git a/tests/unit/provider/fixtures/bitbucket/commit_created.json b/tests/unit/provider/fixtures/bitbucket/commit_created.json new file mode 100644 index 0000000..16c9a6b --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commit_created.json @@ -0,0 +1,7 @@ +{ + "body": "", + "headers": { + "location": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/25a878f7635147b9eef09e958ca34395bc67b705" + }, + "status": 201 +} diff --git a/tests/unit/provider/fixtures/bitbucket/commit_stale_parent.json b/tests/unit/provider/fixtures/bitbucket/commit_stale_parent.json new file mode 100644 index 0000000..4be6234 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/commit_stale_parent.json @@ -0,0 +1,10 @@ +{ + "body": { + "error": { + "message": "The parent commit specified (a682892aa0f4502ef7a242f75d72d0b41eaa27b9) is not the head of branch test-branch." + }, + "type": "error" + }, + "headers": {}, + "status": 400 +} diff --git a/tests/unit/provider/fixtures/bitbucket/src_file.json b/tests/unit/provider/fixtures/bitbucket/src_file.json new file mode 100644 index 0000000..c7d16f2 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/src_file.json @@ -0,0 +1,5 @@ +{ + "body": "# test-repo\n\nA test repository.\n", + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/src_meta.json b/tests/unit/provider/fixtures/bitbucket/src_meta.json new file mode 100644 index 0000000..102d521 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/src_meta.json @@ -0,0 +1,36 @@ +{ + "body": { + "attributes": [], + "commit": { + "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" + }, + "escaped_path": "README.md", + "has_malware_warning": false, + "links": { + "history": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/filehistory/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/README.md" + }, + "meta": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/README.md?format=meta" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/a682892aa0f4502ef7a242f75d72d0b41eaa27b9/README.md" + } + }, + "mimetype": "text/markdown", + "path": "README.md", + "size": 32, + "type": "commit_file" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/fixtures/bitbucket/src_meta_executable.json b/tests/unit/provider/fixtures/bitbucket/src_meta_executable.json new file mode 100644 index 0000000..63eebe0 --- /dev/null +++ b/tests/unit/provider/fixtures/bitbucket/src_meta_executable.json @@ -0,0 +1,38 @@ +{ + "body": { + "attributes": [ + "executable" + ], + "commit": { + "hash": "09f3712b91703e74794d7f8aa5d9c7c65c3edd1b", + "links": { + "html": { + "href": "https://bitbucket.org/test-workspace/test-repo/commits/09f3712b91703e74794d7f8aa5d9c7c65c3edd1b" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/commit/09f3712b91703e74794d7f8aa5d9c7c65c3edd1b" + } + }, + "type": "commit" + }, + "escaped_path": "deploy.sh", + "has_malware_warning": false, + "links": { + "history": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/filehistory/09f3712b91703e74794d7f8aa5d9c7c65c3edd1b/deploy.sh" + }, + "meta": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/09f3712b91703e74794d7f8aa5d9c7c65c3edd1b/deploy.sh?format=meta" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/src/09f3712b91703e74794d7f8aa5d9c7c65c3edd1b/deploy.sh" + } + }, + "mimetype": "text/x-sh", + "path": "deploy.sh", + "size": 18, + "type": "commit_file" + }, + "headers": {}, + "status": 200 +} diff --git a/tests/unit/provider/test_bitbucket.py b/tests/unit/provider/test_bitbucket.py index 3e21908..26e82d3 100644 --- a/tests/unit/provider/test_bitbucket.py +++ b/tests/unit/provider/test_bitbucket.py @@ -1,4 +1,6 @@ import base64 +import email +import email.policy import json import pathlib import unittest.mock @@ -8,9 +10,18 @@ import pytest import requests -from scm.errors import PathIsDirectory, PathIsNotDirectory, ReadmeNotFound, ResourceBadRequest, ResourceNotFound +from scm.errors import ( + PathIsDirectory, + PathIsNotDirectory, + ReadmeNotFound, + ResourceBadRequest, + ResourceConflict, + ResourceNotFound, + StaleBranchHead, +) from scm.providers.bitbucket.provider import ( BitbucketProvider, + decode_content, map_app_installation, map_commit_author, map_paginated_action, @@ -20,7 +31,10 @@ split_patches, ) from scm.types import ( + ChmodCommitAction, CompareCommitsProtocol, + CreateCommitProtocol, + DeleteCommitAction, GetAppInstallationProtocol, GetCommitChangesProtocol, GetCommitProtocol, @@ -35,7 +49,9 @@ GetPullRequestTemplateProtocol, GetReadmeProtocol, GetRepositoryProtocol, + MoveCommitAction, Repository, + WriteCommitAction, ) FIXTURES = pathlib.Path(__file__).parent / "fixtures" / "bitbucket" @@ -58,6 +74,7 @@ GetCommitsUrlProtocol, GetCommitUrlProtocol, GetRepositoryProtocol, + CreateCommitProtocol, ] COMMIT_SHA = "e723f15089c22737bb51aee8db06e0eab91e4ba1" HEAD_SHA = "e3ef83d1a831b7b26c838372f668b1627bbf9a1a" @@ -65,6 +82,13 @@ TEST_AUTHOR = {"name": "Test User", "email": "test@example.com", "date": datetime(2026, 11, 5, 20, 16, 34, tzinfo=UTC)} +PARENT = "a682892aa0f4502ef7a242f75d72d0b41eaa27b9" + +PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\xff\xfe" + +PNG_BASE64 = base64.b64encode(PNG).decode() + + def recorded(name: str) -> requests.Response: """Build a response from a fixture recorded against Bitbucket Cloud.""" fixture = json.loads((FIXTURES / f"{name}.json").read_text()) @@ -632,3 +656,208 @@ def test_split_patches_skips_files_without_hunks(self) -> None: "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"} + + +def sent_parts(client: unittest.mock.MagicMock) -> list[tuple[str, str | None, bytes]]: + """The (name, filename, content) of each part of the multipart body sent to Bitbucket.""" + kwargs = request_kwargs(client) + head = f"Content-Type: {kwargs['headers']['Content-Type']}\r\n\r\n".encode() + message = email.message_from_bytes(head + kwargs["data"], policy=email.policy.HTTP) + parts = [] + for part in message.walk(): + if part.is_multipart(): + continue + name = part.get_param("name", header="content-disposition") + payload = part.get_payload(decode=True) + assert isinstance(name, str) and isinstance(payload, bytes) + parts.append((name, part.get_filename(), payload)) + return parts + + +def sent_form(client: unittest.mock.MagicMock) -> list[tuple[str, bytes]]: + return [(name, content) for name, _, content in sent_parts(client)] + + +def sent_filenames(client: unittest.mock.MagicMock) -> dict[str, str]: + return {name: filename for name, filename, _ in sent_parts(client) if filename} + + +class TestCreateCommit: + def test_commits_writes_and_deletes(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("commit_branch"), recorded("commit_created")] + + result = provider.create_commit( + "test-branch", + PARENT, + "Add notes", + [ + WriteCommitAction(action="create", filename="notes.txt", content="hello\n", encoding="utf-8"), + WriteCommitAction(action="create", filename="logo.png", content=PNG_BASE64, encoding="base64"), + DeleteCommitAction(filename="PULL_REQUEST_TEMPLATE.md"), + ], + author={"name": "Test User", "email": "test@example.com"}, + ) + + branch_call, commit_call = client.request.call_args_list + assert branch_call.kwargs["path"] == f"/2.0/repositories/{REPO}/refs/branches/test-branch" + assert commit_call.kwargs["method"] == "POST" + assert commit_call.kwargs["path"] == f"/2.0/repositories/{REPO}/src" + assert commit_call.kwargs["headers"]["Content-Type"].startswith("multipart/form-data; boundary=") + assert sent_form(client) == [ + ("message", b"Add notes"), + ("branch", b"test-branch"), + ("parents", PARENT.encode()), + ("author", b"Test User "), + ("/notes.txt", b"hello\n"), + ("/logo.png", PNG), + ("files", b"PULL_REQUEST_TEMPLATE.md"), + ] + assert sent_filenames(client) == {"/notes.txt": "notes.txt", "/logo.png": "logo.png"} + assert result["data"] == { + "id": "25a878f7635147b9eef09e958ca34395bc67b705", + "message": "Add notes", + "author": {"name": "Test User", "email": "test@example.com", "date": None}, + "additions": None, + "deletions": None, + } + + def test_creates_branch(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("commit_branch_not_found"), recorded("commit_created")] + + result = provider.create_commit("test-branch", PARENT, "m", [], create_branch=True) + + assert result["data"]["id"] == "25a878f7635147b9eef09e958ca34395bc67b705" + assert result["data"]["author"] is None + assert "author" not in dict(sent_form(client)) + + def test_create_branch_conflicts_with_existing_branch( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.return_value = recorded("commit_branch") + with pytest.raises(ResourceConflict): + provider.create_commit("test-branch", PARENT, "m", [], create_branch=True) + assert client.request.call_count == 1 + + def test_missing_branch_is_not_created(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.return_value = recorded("commit_branch_not_found") + with pytest.raises(ResourceNotFound): + provider.create_commit("missing-branch", PARENT, "m", []) + assert client.request.call_count == 1 + + def test_moved_head_raises_stale_branch_head( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [recorded("commit_branch"), recorded("commit_stale_parent")] + with pytest.raises(StaleBranchHead): + provider.create_commit("test-branch", PARENT, "m", [], expected_head_sha=PARENT) + + def test_other_bad_request_is_not_stale(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + rejected = recorded("commit_stale_parent") + rejected._content = json.dumps({"type": "error", "error": {"message": "Invalid file path"}}).encode() + client.request.side_effect = [recorded("commit_branch"), rejected] + with pytest.raises(ResourceBadRequest): + provider.create_commit("test-branch", PARENT, "m", []) + + def test_encodes_branch_name(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("commit_branch"), recorded("commit_created")] + + provider.create_commit("feature/#12", PARENT, "m", []) + + assert ( + client.request.call_args_list[0].kwargs["path"] == f"/2.0/repositories/{REPO}/refs/branches/feature/%2312" + ) + + def test_strips_leading_slash_from_filename( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock + ) -> None: + client.request.side_effect = [recorded("commit_branch"), recorded("commit_created")] + + provider.create_commit( + "test-branch", + PARENT, + "m", + [WriteCommitAction(action="create", filename="/notes.txt", content="hello\n", encoding="utf-8")], + ) + + assert ("/notes.txt", b"hello\n") in sent_form(client) + assert sent_filenames(client) == {"/notes.txt": "notes.txt"} + + def test_moves_file(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [ + recorded("commit_branch"), + recorded("src_meta"), + recorded("src_file"), + recorded("commit_created"), + ] + + provider.create_commit( + "test-branch", PARENT, "m", [MoveCommitAction(old_filename="README.md", new_filename="docs/README.md")] + ) + + meta_call = client.request.call_args_list[1] + assert meta_call.kwargs["path"] == f"/2.0/repositories/{REPO}/src/{PARENT}/README.md" + assert meta_call.kwargs["params"] == {"format": "meta"} + form = sent_form(client) + assert ("files", b"README.md") in form + assert ("/docs/README.md", b"# test-repo\n\nA test repository.\n") in form + + def test_moves_binary_file(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + meta = recorded("src_meta") + meta._content = json.dumps({**meta.json(), "attributes": ["binary"]}).encode() + binary_file = recorded("src_file") + binary_file._content = PNG + client.request.side_effect = [recorded("commit_branch"), meta, binary_file, recorded("commit_created")] + + provider.create_commit( + "test-branch", PARENT, "m", [MoveCommitAction(old_filename="logo.png", new_filename="img/logo.png")] + ) + + assert ("/img/logo.png", PNG) in sent_form(client) + + def test_refuses_to_move_executable(self, provider: BitbucketProvider, client: unittest.mock.MagicMock) -> None: + client.request.side_effect = [recorded("commit_branch"), recorded("src_meta_executable")] + with pytest.raises(ResourceBadRequest): + provider.create_commit( + "test-branch", PARENT, "m", [MoveCommitAction(old_filename="deploy.sh", new_filename="run.sh")] + ) + assert client.request.call_count == 2 + + @pytest.mark.parametrize( + "action", + [ + WriteCommitAction(action="create", filename="logo.png", content="not base64!", encoding="base64"), + ChmodCommitAction(executable=True, filename="run.sh"), + ], + ids=["invalid_base64", "chmod"], + ) + def test_refuses_what_an_upload_cannot_write( + self, + provider: BitbucketProvider, + client: unittest.mock.MagicMock, + action: WriteCommitAction | ChmodCommitAction, + ) -> None: + client.request.return_value = recorded("commit_branch") + with pytest.raises(ResourceBadRequest): + provider.create_commit("test-branch", PARENT, "m", [action]) + assert client.request.call_count == 1 + + @pytest.mark.parametrize( + "kwargs", + [ + {"force": True}, + {"expected_head_sha": "0" * 40}, + {"expected_head_sha": PARENT, "create_branch": True}, + ], + ids=["force", "lease_not_parent", "lease_with_create_branch"], + ) + def test_rejects_unsupported_options( + self, provider: BitbucketProvider, client: unittest.mock.MagicMock, kwargs: dict[str, Any] + ) -> None: + with pytest.raises(ResourceBadRequest): + provider.create_commit("test-branch", PARENT, "m", [], **kwargs) + client.request.assert_not_called() + + +def test_decode_content_ignores_line_wrapping() -> None: + action = WriteCommitAction(action="create", filename="a.bin", content="aGVs\nbG8=\n", encoding="base64") + assert decode_content(action) == b"hello"