Skip to content

Commit 00b28bd

Browse files
authored
[ISV-5276] Create helper functions for GitHub branches for copy and delete operations. (#772)
* [ISV-5276] Add helper function for github copy and delete branch operations. * Fix the black test. * Remove unnecessary json file. * Fix the black test. * Fix the copy function with the new try-except block logic. * Add docstring about the limitations.
1 parent 39dc9a6 commit 00b28bd

File tree

2 files changed

+271
-1
lines changed

2 files changed

+271
-1
lines changed

operator-pipeline-images/operatorcert/github.py

+130-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
from typing import Any, Dict, List, Optional
99

1010
import requests
11-
from github import Github, Label, PaginatedList, PullRequest
11+
from github import (
12+
Github,
13+
Label,
14+
PaginatedList,
15+
PullRequest,
16+
GithubException,
17+
InputGitTreeElement,
18+
)
1219
from operatorcert.utils import add_session_retries
1320

1421
LOGGER = logging.getLogger("operator-cert")
@@ -336,3 +343,125 @@ def close_pull_request(
336343
"""
337344
pull_request.edit(state="closed")
338345
return pull_request
346+
347+
348+
def copy_branch(
349+
github_client: Github,
350+
src_repo_name: str,
351+
src_branch_name: str,
352+
dest_repo_name: str,
353+
dest_branch_name: str,
354+
) -> None:
355+
"""
356+
Copy a branch from Source Repository to Destination Repository.
357+
358+
Limitations:
359+
- This method does not handle symbolic links or non-regular files.
360+
- Binary files that cannot be decoded as UTF-8 text will not be copied correctly.
361+
- A proper fix would require cloning the source
362+
repository locally and pushing it to the destination.
363+
- While this approach has limitations, it may be sufficient for short-term needs,
364+
and improvements can be made in the future.
365+
366+
Args:
367+
github_client (Github): A Github API client
368+
src_repo_name (str): The source repository name in the format "organization/repository"
369+
src_branch_name(str): The name of the branch to copy
370+
dest_repo_name(str): The destination repository name in the format "organization/repository"
371+
dest_branch_name(str): The name of the destination branch
372+
"""
373+
374+
try:
375+
src_repository = github_client.get_repo(src_repo_name)
376+
dest_repository = github_client.get_repo(dest_repo_name)
377+
378+
base_dest_branch = dest_repository.get_branch("main")
379+
base_dest_commit_sha = base_dest_branch.commit.sha
380+
381+
ref = f"refs/heads/{dest_branch_name}"
382+
dest_repository.create_git_ref(ref=ref, sha=base_dest_commit_sha)
383+
384+
tree_elements = []
385+
386+
def collect_files_recursive(path: str = "") -> None:
387+
"""
388+
Recursively fetch and prepare files from the source repo.
389+
Args:
390+
path (str): Content file path
391+
"""
392+
contents = src_repository.get_contents(path, ref=src_branch_name)
393+
394+
if isinstance(contents, list):
395+
for content in contents:
396+
collect_files_recursive(content.path)
397+
else:
398+
file_data = contents.decoded_content
399+
element = InputGitTreeElement(
400+
path=contents.path,
401+
mode="100644",
402+
type="blob",
403+
content=file_data.decode("utf-8"),
404+
)
405+
tree_elements.append(element)
406+
407+
collect_files_recursive()
408+
409+
new_tree = dest_repository.create_git_tree(
410+
tree_elements, base_dest_branch.commit.commit.tree
411+
)
412+
413+
commit_message = f"Copy from {src_branch_name} into {dest_branch_name}."
414+
new_commit = dest_repository.create_git_commit(
415+
commit_message, new_tree, [base_dest_branch.commit.commit]
416+
)
417+
418+
ref = f"heads/{dest_branch_name}"
419+
dest_repository.get_git_ref(ref).edit(new_commit.sha)
420+
421+
LOGGER.debug(
422+
"Branch '%s' from '%s' copied to '%s' in '%s' successfully.",
423+
src_branch_name,
424+
src_repo_name,
425+
dest_branch_name,
426+
dest_repo_name,
427+
)
428+
429+
except GithubException:
430+
LOGGER.exception(
431+
"Error while copying branch '%s' from '%s' to '%s' in '%s'.",
432+
src_branch_name,
433+
src_repo_name,
434+
dest_branch_name,
435+
dest_repo_name,
436+
)
437+
raise
438+
439+
440+
def delete_branch(
441+
github_client: Github,
442+
repository_name: str,
443+
branch_name: str,
444+
) -> None:
445+
"""
446+
Delete a branch from a Github repository.
447+
448+
Args:
449+
github_client (Github): A Github API client
450+
repository_name (str): A repository name in the format "organization/repository"
451+
branch_name (str): The name of the branch to delete
452+
"""
453+
try:
454+
repository = github_client.get_repo(repository_name)
455+
branch_ref = f"heads/{branch_name}"
456+
457+
repository.get_git_ref(branch_ref).delete()
458+
459+
LOGGER.debug(
460+
"Branch '%s' deleted from '%s' successfully.", branch_name, repository_name
461+
)
462+
463+
except GithubException:
464+
LOGGER.exception(
465+
"Error while deleting the '%s' from '%s'.", branch_name, repository_name
466+
)
467+
raise

operator-pipeline-images/tests/test_github.py

+141
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from typing import Any, List
22
from unittest.mock import ANY, MagicMock, call, patch
3+
import base64
34

45
import pytest
6+
from github import GithubException
7+
from github.InputGitTreeElement import InputGitTreeElement
58
from operatorcert import github
69
from requests import HTTPError, Response
710

@@ -229,3 +232,141 @@ def test_close_pull_request() -> None:
229232
mock_pull_request.edit.assert_called_once_with(state="closed")
230233

231234
assert resp == mock_pull_request
235+
236+
237+
def test_copy_branch_success() -> None:
238+
mock_client = MagicMock()
239+
src_repo = mock_client.get_repo.return_value
240+
dest_repo = mock_client.get_repo.return_value
241+
242+
src_branch_ref = MagicMock()
243+
src_branch_ref.commit.sha = "abc123"
244+
src_repo.get_branch.return_value = src_branch_ref
245+
246+
dest_branch_ref = MagicMock()
247+
dest_branch_ref.commit.sha = "def456"
248+
dest_branch_ref.commit.commit.tree = MagicMock()
249+
dest_repo.get_branch.return_value = dest_branch_ref
250+
251+
dest_repo.create_git_ref.return_value = MagicMock()
252+
253+
mock_file = MagicMock()
254+
mock_file.path = "test.txt"
255+
mock_file.type = "file"
256+
mock_file.decoded_content = b"new content"
257+
258+
src_repo.get_contents.side_effect = lambda path, ref: (
259+
[mock_file] if path == "" else mock_file
260+
)
261+
262+
dest_repo.create_git_tree.return_value = MagicMock()
263+
dest_repo.create_git_commit.return_value = MagicMock(sha="new_commit_sha")
264+
265+
ref_mock = MagicMock()
266+
dest_repo.get_git_ref.return_value = ref_mock
267+
268+
github.copy_branch(
269+
mock_client,
270+
"org/source-repo",
271+
"main",
272+
"org/dest-repo",
273+
"copied-branch",
274+
)
275+
276+
dest_repo.create_git_ref.assert_called_once_with(
277+
ref="refs/heads/copied-branch", sha="def456"
278+
)
279+
dest_repo.create_git_tree.assert_called()
280+
dest_repo.create_git_commit.assert_called()
281+
ref_mock.edit.assert_called_once_with("new_commit_sha")
282+
283+
284+
def test_copy_branch_with_nested_files() -> None:
285+
mock_client = MagicMock()
286+
src_repo = mock_client.get_repo.return_value
287+
dest_repo = mock_client.get_repo.return_value
288+
289+
src_branch_ref = MagicMock()
290+
src_branch_ref.commit.sha = "abc123"
291+
src_repo.get_branch.return_value = src_branch_ref
292+
293+
dest_branch_ref = MagicMock()
294+
dest_branch_ref.commit.sha = "def456"
295+
dest_branch_ref.commit.commit.tree = MagicMock()
296+
dest_repo.get_branch.return_value = dest_branch_ref
297+
298+
dest_repo.create_git_ref.return_value = MagicMock()
299+
300+
mock_dir = MagicMock()
301+
mock_dir.path = "dir1"
302+
mock_dir.type = "dir"
303+
304+
mock_file = MagicMock()
305+
mock_file.path = "dir1/test.txt"
306+
mock_file.type = "file"
307+
mock_file.decoded_content = b"nested content"
308+
309+
src_repo.get_contents.side_effect = lambda path, ref: (
310+
[mock_dir] if path == "" else [mock_file] if path == "dir1" else mock_file
311+
)
312+
313+
dest_repo.create_git_tree.return_value = MagicMock()
314+
dest_repo.create_git_commit.return_value = MagicMock(sha="new_commit_sha")
315+
316+
ref_mock = MagicMock()
317+
dest_repo.get_git_ref.return_value = ref_mock
318+
319+
github.copy_branch(
320+
mock_client,
321+
"org/source-repo",
322+
"main",
323+
"org/dest-repo",
324+
"copied-branch",
325+
)
326+
327+
dest_repo.create_git_ref.assert_called_once_with(
328+
ref="refs/heads/copied-branch", sha="def456"
329+
)
330+
dest_repo.create_git_tree.assert_called()
331+
dest_repo.create_git_commit.assert_called()
332+
ref_mock.edit.assert_called_once_with("new_commit_sha")
333+
334+
335+
def test_copy_branch_handles_github_exception() -> None:
336+
mock_client = MagicMock()
337+
mock_client.get_repo.side_effect = GithubException(500, "Internal Server Error", {})
338+
339+
with pytest.raises(GithubException):
340+
github.copy_branch(
341+
mock_client,
342+
"org/source-repo",
343+
"main",
344+
"org/dest-repo",
345+
"copied-branch",
346+
)
347+
348+
349+
def test_delete_branch_success() -> None:
350+
mock_client = MagicMock()
351+
mock_repo = MagicMock()
352+
mock_client.get_repo.return_value = mock_repo
353+
354+
branch_ref = MagicMock()
355+
mock_repo.get_git_ref.return_value = branch_ref
356+
357+
github.delete_branch(mock_client, "org/repo-name", "old-feature-branch")
358+
359+
mock_repo.get_git_ref.assert_called_once_with("heads/old-feature-branch")
360+
branch_ref.delete.assert_called_once()
361+
362+
363+
def test_delete_branch_when_branch_does_not_exist() -> None:
364+
mock_client = MagicMock()
365+
mock_repo = MagicMock()
366+
mock_get_git_ref = MagicMock()
367+
mock_client.get_repo.return_value = mock_repo
368+
mock_repo.get_git_ref.return_value = mock_get_git_ref
369+
mock_get_git_ref.delete.side_effect = GithubException(0, "err", None)
370+
371+
with pytest.raises(GithubException):
372+
github.delete_branch(mock_client, "org/repo-name", "non-existent-branch")

0 commit comments

Comments
 (0)