Skip to content

Commit 480cb56

Browse files
authored
feat(bitbucket): add server-level hook-script management methods (#1653)
Add get_hook_script, get_hook_script_content, update_hook_script and delete_hook_script for Bitbucket Data Center global hook scripts, completing the server-level endpoints requested in #1554. Includes unit tests and documentation. Co-authored-by: mrcrysta1 <mrcrysta1@users.noreply.github.com>
1 parent 6f49a1a commit 480cb56

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

‎atlassian/bitbucket/__init__.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,50 @@ def delete_repo_hook_script(self, project_key: str, repository_slug: str, script
366366
url = f"{self._url_repo(project_key, repository_slug, api_version='latest')}/hook-scripts/{script_id}"
367367
return self.delete(url)
368368

369+
def get_hook_script(self, script_id: int):
370+
"""Return the registered global hook script with ``script_id``.
371+
372+
The result only contains the script metadata; use
373+
``get_hook_script_content()`` to fetch the script body.
374+
"""
375+
return self.get(f"{self._url_hook_scripts()}/{script_id}")
376+
377+
def get_hook_script_content(self, script_id: int):
378+
"""Return the raw body of the registered global hook script.
379+
380+
``not_json_response`` is used because the endpoint returns the
381+
executable script as plain text rather than a JSON payload.
382+
"""
383+
return self.get(
384+
f"{self._url_hook_scripts()}/{script_id}/content",
385+
not_json_response=True,
386+
)
387+
388+
def update_hook_script(
389+
self, script_id: int, content: bytes, name: str, hook_type: str, description: Optional[str] = None
390+
):
391+
"""Update a registered global hook script on Bitbucket Data Center.
392+
393+
``hook_type`` must be ``PRE`` or ``POST``. Like ``create_hook_script``,
394+
the API expects a multipart form; ``content`` is the executable script
395+
bytes.
396+
"""
397+
if hook_type not in ("PRE", "POST"):
398+
raise ValueError("hook_type must be 'PRE' or 'POST'")
399+
400+
files: Dict[str, Any] = {
401+
"content": ("hook-script", content, "application/octet-stream"),
402+
"name": (None, name),
403+
"type": (None, hook_type),
404+
}
405+
if description is not None:
406+
files["description"] = (None, description)
407+
return self.put(f"{self._url_hook_scripts()}/{script_id}", files=files, headers=self.no_check_headers)
408+
409+
def delete_hook_script(self, script_id: int):
410+
"""Delete the registered global hook script with ``script_id``."""
411+
return self.delete(f"{self._url_hook_scripts()}/{script_id}")
412+
369413
def get_categories(self, project_key, repository_slug=None):
370414
"""
371415
Get a list of categories assigned to a project or repository.

‎docs/bitbucket.rst‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ configured scripts. ``delete_project_hook_script()`` and
9898
``delete_repo_hook_script()`` remove only a scope configuration; they do not
9999
delete the global registered script.
100100

101+
The global registered script itself is managed with the server-level methods.
102+
``get_hook_script()`` returns a script's metadata, ``get_hook_script_content()``
103+
returns its raw body, ``update_hook_script()`` replaces its body or metadata,
104+
and ``delete_hook_script()`` removes it entirely:
105+
106+
.. code-block:: python
107+
108+
metadata = bitbucket.get_hook_script(hook_script["id"])
109+
script = bitbucket.get_hook_script_content(hook_script["id"])
110+
111+
bitbucket.update_hook_script(
112+
hook_script["id"],
113+
content=script.replace(b"old", b"new"),
114+
name="Audit pushes (v2)",
115+
hook_type="POST",
116+
description="Records every push",
117+
)
118+
bitbucket.delete_hook_script(hook_script["id"])
119+
101120
Release report from two refs (Server/Data Center)
102121
-------------------------------------------------
103122

‎tests/test_bitbucket_server.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,50 @@ def test_configure_repo_hook_script(self, mock_put):
134134
"rest/api/latest/projects/PROJ/repos/repository/hook-scripts/12", data={"triggerIds": []}
135135
)
136136

137+
@patch.object(Bitbucket, "get")
138+
def test_get_hook_script(self, mock_get):
139+
mock_get.return_value = {"id": 12, "name": "Audit pushes"}
140+
141+
result = self.bitbucket.get_hook_script(12)
142+
143+
self.assertEqual(result["id"], 12)
144+
mock_get.assert_called_once_with("rest/api/latest/hook-scripts/12")
145+
146+
@patch.object(Bitbucket, "get")
147+
def test_get_hook_script_content_is_not_json(self, mock_get):
148+
mock_get.return_value = b"#!/bin/sh\necho hook\n"
149+
150+
result = self.bitbucket.get_hook_script_content(12)
151+
152+
self.assertEqual(result, b"#!/bin/sh\necho hook\n")
153+
mock_get.assert_called_once_with("rest/api/latest/hook-scripts/12/content", not_json_response=True)
154+
155+
@patch.object(Bitbucket, "put")
156+
def test_update_hook_script_uses_multipart_latest_endpoint(self, mock_put):
157+
script = b"#!/bin/sh\necho hook v2\n"
158+
mock_put.return_value = {"id": 12}
159+
160+
result = self.bitbucket.update_hook_script(12, script, "Audit pushes", "POST", "Audit every push")
161+
162+
self.assertEqual(result, {"id": 12})
163+
files = mock_put.call_args.kwargs["files"]
164+
self.assertEqual(files["content"], ("hook-script", script, "application/octet-stream"))
165+
self.assertEqual(files["name"], (None, "Audit pushes"))
166+
self.assertEqual(files["type"], (None, "POST"))
167+
self.assertEqual(files["description"], (None, "Audit every push"))
168+
self.assertEqual(mock_put.call_args.args[0], "rest/api/latest/hook-scripts/12")
169+
self.assertEqual(mock_put.call_args.kwargs["headers"], self.bitbucket.no_check_headers)
170+
171+
def test_update_hook_script_rejects_unknown_hook_type(self):
172+
with self.assertRaisesRegex(ValueError, "PRE.*POST"):
173+
self.bitbucket.update_hook_script(12, b"#!/bin/sh", "Invalid", "PRE_RECEIVE")
174+
175+
@patch.object(Bitbucket, "delete")
176+
def test_delete_hook_script(self, mock_delete):
177+
self.bitbucket.delete_hook_script(12)
178+
179+
mock_delete.assert_called_once_with("rest/api/latest/hook-scripts/12")
180+
137181

138182
class TestPersonalRepositories(TestCase):
139183
def setUp(self):

0 commit comments

Comments
 (0)