Skip to content
Open
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
63 changes: 63 additions & 0 deletions bin/bitbucket-client
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,18 @@ Commands:
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]
create-commit <branch> <parent_sha> <message>
[--create <filename>=<content> ...] (<content> of @<path> uploads that local file)
[--update <filename>=<content> ...]
[--delete <filename> ...]
[--move <old_filename>:<new_filename> ...]
[--create-branch]
[--author-name <name> --author-email <email>]
[--expected-head-sha <sha>]
"""

import argparse
import base64
import json
import logging
import os
Expand All @@ -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,
Expand All @@ -55,7 +68,9 @@ from scm.types import (
GetPullRequestTemplateProtocol,
GetReadmeProtocol,
GetRepositoryProtocol,
MoveCommitAction,
PaginationParams,
WriteCommitAction,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/scm/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
122 changes: 122 additions & 0 deletions src/scm/providers/bitbucket/provider.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import base64
import binascii
import hashlib
import posixpath
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

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
Expand All @@ -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"
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -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
Comment thread
wedamija marked this conversation as resolved.
Resolved
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")):
Expand Down
Loading
Loading