diff --git a/src/sc/branching/branching.py b/src/sc/branching/branching.py index 2ee5a8b..bdc3429 100644 --- a/src/sc/branching/branching.py +++ b/src/sc/branching/branching.py @@ -22,6 +22,8 @@ from repo_library import RepoLibrary from .branch import Branch, BranchType +from .commands.branch_rename import BranchRename +from .commands.branch_rm_merged import BranchRmMerged from .commands.checkout import Checkout from .commands.clean import Clean from .commands.command import Command @@ -33,13 +35,13 @@ from .commands.list import List from .commands.pull import Pull from .commands.push import Push -from .commands.show import ShowBranch, ShowLog, ShowRepoFlowConfig +from .commands.show import ShowBranch, ShowLog, ShowRepoFlowConfig, ShowMergedRelease from .commands.start import Start from .commands.status import Status from .commands.tag import (TagCheck, TagCreate, TagList, TagPush, TagRm, TagShow) from .commands.reset import Reset -from .exceptions import ScInitError +from sc.exceptions import ScError logger = logging.getLogger(__name__) @@ -86,7 +88,7 @@ def checkout( Checkout(top_dir, branch, force=force, verify=verify), project_type ) - + @staticmethod def delete( branch_type: BranchType, @@ -238,6 +240,19 @@ def show_repo_flow_config(run_dir: Path = Path.cwd()): project_type ) + @staticmethod + def show_merged_release( + previous_release: str | None, + current_release: str | None, + wiki: bool, + run_dir: Path = Path.cwd() + ): + top_dir, project_type = detect_project(run_dir) + run_command_by_project_type( + ShowMergedRelease(top_dir, previous_release, current_release, wiki), + project_type + ) + @staticmethod def group_checkout(group: str, branch: str, run_dir: Path = Path.cwd()): top_dir, project_type = detect_project(run_dir) @@ -300,6 +315,29 @@ def group_tag( project_type ) + @staticmethod + def branch_rename(old_branch: str, new_branch: str, run_dir: Path = Path.cwd()): + top_dir, project_type = detect_project(run_dir) + run_command_by_project_type( + BranchRename(top_dir, old_branch, new_branch), + project_type + ) + + @staticmethod + def branch_rm_merged( + not_merged: bool, + all: bool, + yes: bool, + git_only: bool, + dry_run: bool, + run_dir: Path = Path.cwd() + ): + top_dir, project_type = detect_project(run_dir) + run_command_by_project_type( + BranchRmMerged(top_dir, not_merged, all, yes, git_only, dry_run), + project_type + ) + def detect_project(run_dir: Path) -> tuple[Path | ProjectType]: if root := RepoLibrary.get_repo_root_dir(run_dir): return root.parent, ProjectType.REPO @@ -334,13 +372,13 @@ def create_branch( sys.exit(1) def run_command_by_project_type(command: Command, project_type: ProjectType): - if project_type == ProjectType.GIT: - command.run_git_command() - elif project_type == ProjectType.REPO: - try: + try: + if project_type == ProjectType.GIT: + command.run_git_command() + elif project_type == ProjectType.REPO: command.run_repo_command() - except ScInitError as e: - logger.error(e) - sys.exit(1) - else: - raise RuntimeError("Should not get here.") + else: + raise RuntimeError("Should not get here.") + except ScError as e: + logger.error(e) + sys.exit(1) diff --git a/src/sc/branching/commands/branch_rename.py b/src/sc/branching/commands/branch_rename.py new file mode 100644 index 0000000..d261970 --- /dev/null +++ b/src/sc/branching/commands/branch_rename.py @@ -0,0 +1,216 @@ +from dataclasses import dataclass +from enum import Enum, auto +import logging +from pathlib import Path + +import git +from git import Repo + +from .command import Command +from sc.exceptions import ScError +from sc_manifest_parser import ScManifest + +logger = logging.getLogger(__name__) + +class BranchState(Enum): + RENAME_NEEDED = auto() + ALREADY_RENAMED = auto() + INVALID = auto() + +@dataclass +class RemoteInfo: + name: str + old_commit: str | None + new_commit: str | None + +@dataclass +class BranchRename(Command): + old_branch: str + new_branch: str + + def run_git_command(self): + self._rename_branch(self.top_dir, self.old_branch, self.new_branch) + + def run_repo_command(self): + manifest = ScManifest.from_repo_root(self.top_dir / ".repo") + + for proj in manifest.projects: + if proj.lock_status is None: + logger.info(f"Attempting renaming branch in repo: {self.top_dir / proj.path}") + self._rename_branch( + self.top_dir / proj.path, + self.old_branch, + self.new_branch, + proj.remote, + ) + + logger.info(f"Attempting renaming manifest branch: {self.top_dir / '.repo' / 'manifests'}") + self._rename_branch( + self.top_dir / ".repo" / "manifests", + self.old_branch, + self.new_branch, + ) + + def _rename_branch( + self, + directory: Path, + old_branch: str, + new_branch: str, + remote_name: str | None = None): + try: + repo = Repo(directory) + except git.InvalidGitRepositoryError as e: + raise ScError(f"Invalid git repo: {directory}") from e + + branch_state = self._get_rename_state(repo, old_branch, new_branch) + if branch_state is BranchState.INVALID: + return + + try: + remote = self._get_remote_name(repo, remote_name) + except RuntimeError as e: + logger.warning(f"{e} Skipping remote renaming.") + remote = None + + # Need to check here so not to rename locally if a different branch already exists + # on the remote with the new name. + if remote and self._has_remote_conflict(repo, remote, old_branch, new_branch, branch_state): + return + + if branch_state is BranchState.RENAME_NEEDED: + self._rename_local(repo, old_branch, new_branch) + + if remote: + self._rename_remote( + repo, + remote, + old_branch, + new_branch, + ) + + def _get_rename_state(self, repo: Repo, old_branch: str, new_branch: str) -> BranchState: + has_old = self._has_branch(repo, old_branch) + has_new = self._has_branch(repo, new_branch) + + if has_old and not has_new: + return BranchState.RENAME_NEEDED + + if not has_old and has_new: + logger.info(f"Branch already renamed in local repo {repo.working_dir}.") + return BranchState.ALREADY_RENAMED + + if has_old and has_new: + logger.warning( + f"Both branches exist in repo {repo.working_dir}: " + f"{old_branch}, {new_branch}. Skipping." + ) + return BranchState.INVALID + + logger.warning( + f"Neither branch exists in repo {repo.working_dir}: " + f"{old_branch}, {new_branch}. Skipping." + ) + return BranchState.INVALID + + def _rename_local(self, repo: Repo, old_branch: str, new_branch: str): + try: + repo.git.branch("-m", old_branch, new_branch) + logger.info("Renamed locally.") + except git.GitCommandError as e: + raise ScError( + f"Unable to rename branch in repo {repo.working_dir}: {e.stderr}" + ) from e + + def _rename_remote( + self, + repo: Repo, + remote: str, + old_branch: str, + new_branch: str, + ): + try: + # Push/create the new branch before deleting the old branch. + repo.git.push("-u", remote, f"{new_branch}:refs/heads/{new_branch}") + + if self._remote_branch_commit(repo, remote, old_branch) is not None: + repo.git.push(remote, "--delete", old_branch) + + logger.info("Renamed remotely.") + except git.GitCommandError as e: + logger.warning( + f"Failed to rename branch in remote in repo {repo.working_dir}: " + f"{e.stderr}" + ) + + def _has_remote_conflict( + self, + repo: Repo, + remote: str, + old_branch: str, + new_branch: str, + branch_state: BranchState) -> bool: + """ + True if new branch already exists on remote and isn't the same commit + as the branch we are renaming. + """ + try: + new_remote_commit = self._remote_branch_commit(repo, remote, new_branch) + except git.GitCommandError as e: + logger.warning( + f"Unable to query remote {remote} in repo {repo.working_dir}. " + f"Skipping remote branch rename: {e.stderr}" + ) + return True + + if new_remote_commit is None: + # New branch doesn't exist on remote. + return False + + local_branch = old_branch + if branch_state is BranchState.ALREADY_RENAMED: + local_branch = new_branch + + local_commit = self._local_branch_commit(repo, local_branch) + if local_commit == new_remote_commit: + # Already renamed on remote. + return False + + logger.warning( + f"Remote branch {new_branch} already exists on {remote} in repo " + f"{repo.working_dir} and points at a different commit. Skipping." + ) + return True + + def _get_remote_name(self, repo: Repo, remote_name: str | None) -> str: + if remote_name: + try: + _ = repo.remote(remote_name) + return remote_name + except ValueError as e: + raise RuntimeError( + f"No remote named {remote_name} found for {repo.working_dir}.") from e + + try: + return repo.remotes[0].name + except IndexError as e: + raise RuntimeError(f"No remote found for repo {repo.working_dir}.") from e + + def _has_branch(self, repo: Repo, branch_name: str) -> bool: + return branch_name in [branch.name for branch in repo.branches] + + def _local_branch_commit(self, repo: Repo, branch_name: str) -> str: + return repo.git.rev_parse(branch_name) + + def _remote_branch_commit( + self, + repo: Repo, + remote_name: str, + branch_name: str) -> str | None: + out = repo.git.ls_remote("--heads", remote_name, f"refs/heads/{branch_name}") + + for line in out.splitlines(): + commit, ref = line.split() + if ref == f"refs/heads/{branch_name}": + return commit + + return None diff --git a/src/sc/branching/commands/branch_rm_merged.py b/src/sc/branching/commands/branch_rm_merged.py new file mode 100644 index 0000000..dc58345 --- /dev/null +++ b/src/sc/branching/commands/branch_rm_merged.py @@ -0,0 +1,109 @@ +# Copyright 2025 RDK Management +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass +from pathlib import Path + +import git +from git import Repo +from git_flow_library import GitFlowLibrary + +from ..branch import Branch +from .command import Command +from .delete import Delete +from sc.exceptions import ScError +from sc.prompter import Prompter +from sc.services.tickets.ticket_service import TicketService + +@dataclass +class BranchRmMerged(Command): + not_merged: bool = False + all: bool = False + no_prompt: bool = False + git_only: bool = False + dry_run: bool = False + + def run_git_command(self): + self._verify_options() + self._rm_merged(self.top_dir, git_only=True) + + def run_repo_command(self): + self._error_on_sc_uninitialised() + self._verify_options() + + if self.git_only: + root = GitFlowLibrary.get_git_root(Path.cwd()) + if not root: + raise ScError(f"{Path.cwd()} not a valid git repository!") + self._rm_merged(root, self.git_only) + else: + self._rm_merged(self.top_dir) + + def _verify_options(self): + if self.not_merged and self.all: + raise ScError("Cannot pass both --all and --no-merged.") + + def _rm_merged(self, path: Path, git_only: bool = False): + filtered_branches = self._get_feature_branches(path) + + ticket_service = TicketService() + + for branch in filtered_branches: + ticket = ticket_service.get_ticket_from_branch(branch.name) + print(ticket.to_terminal(one_line=True)) + print(branch.name) + + if self.dry_run: + continue + + if self.no_prompt: + self._delete_branch(path, branch, git_only) + elif Prompter.yn("Delete branch?"): + self._delete_branch(path, branch, git_only) + + def _get_feature_branches(self, path: Path) -> list[Branch]: + try: + repo = Repo(path) + except git.InvalidGitRepositoryError as e: + raise ScError(f"Invalid git repo: {path}") from e + + develop = GitFlowLibrary.get_develop_branch(path) + master = GitFlowLibrary.get_master_branch(path) + hotfix = GitFlowLibrary.get_config_value("prefix.hotfix", path) + release = GitFlowLibrary.get_config_value("prefix.release", path) + support = GitFlowLibrary.get_config_value("prefix.support", path) + + branch_filters = [develop, master, hotfix, release, support, "m/feature"] + + feature = GitFlowLibrary.get_config_value("prefix.feature", path) + + merge_type = "--merged" if not self.not_merged else "--no-merged" + merge_type = "--all" if self.all else merge_type + branches = repo.git.branch("-r", merge_type, develop).splitlines() + + feature_branches = [ + branch.strip().split("/", 1)[1] + for branch in branches + if not any(f in branch for f in branch_filters) + and feature in branch + ] + + return [Branch(*b.split("/", 1)) for b in feature_branches] + + def _delete_branch(self, path: Path, branch: Branch, git_only: bool): + if git_only: + Delete(path, branch, remote=True).run_git_command() + else: + Delete(path, branch, remote=True).run_repo_command() + diff --git a/src/sc/branching/commands/delete.py b/src/sc/branching/commands/delete.py index 4f1cade..7b06340 100644 --- a/src/sc/branching/commands/delete.py +++ b/src/sc/branching/commands/delete.py @@ -15,7 +15,8 @@ import logging from pathlib import Path -from git import GitCommandError, Repo +import git +from git import Repo from git_flow_library import GitFlowLibrary from sc_manifest_parser import ScManifest @@ -31,31 +32,45 @@ class Delete(Command): def run_git_command(self): self._delete_branch(self.top_dir) - + def run_repo_command(self): self._error_on_sc_uninitialised() if self.remote: logger.info(f"Removing Local & Remote Branch {self.branch.name}") else: logger.info(f"Removing Local Branch {self.branch.name}") - + manifest = ScManifest.from_repo_root(self.top_dir / '.repo') for proj in manifest.projects: if proj.lock_status is None: self._delete_branch(self.top_dir / proj.path, proj.remote) - + self._delete_branch(self.top_dir / '.repo' / 'manifests') - + def _delete_branch(self, dir: Path, remote_name: str | None = None): - repo = Repo(dir) + logger.info(f"Operating on {dir}") + try: + repo = Repo(dir) + except git.InvalidGitRepositoryError: + logger.info(f"{dir} is not a valid git repository.") + return + if repo.active_branch.name == self.branch.name: repo.git.switch(GitFlowLibrary.get_develop_branch(dir)) - repo.git.branch("-D", self.branch.name) + try: + repo.git.branch("-D", self.branch.name) + logger.info(f"Deleted {self.branch.name} in {dir}") + except git.GitCommandError as e: + logger.info(f"Failed to delete branch in {dir}: {e.stderr}") + if self.remote: if not remote_name: remote_name = repo.remotes[0].name - repo.git.push(remote_name, "--delete", f"{self.branch.name}") + try: + repo.git.push(remote_name, "--delete", f"{self.branch.name}") + except git.GitCommandError as e: + logger.info(f"Failed to delete from remote {remote_name} in path {dir}: {e.stderr}") try: repo.git.update_ref("-d", f"refs/remotes/{remote_name}/{self.branch.name}") - except GitCommandError as e: + except git.GitCommandError as e: logger.info("Failed to delete remote ref, may just not be fetched: %s", e) diff --git a/src/sc/branching/commands/push.py b/src/sc/branching/commands/push.py index af40f45..b2418ab 100644 --- a/src/sc/branching/commands/push.py +++ b/src/sc/branching/commands/push.py @@ -36,6 +36,7 @@ def run_git_command(self): repo = Repo(self.top_dir) remote = repo.remotes[0].name repo.git.push("-u", remote, self.branch.name) + repo.git.push("--tags") def run_repo_command(self): self._error_on_sc_uninitialised() diff --git a/src/sc/branching/commands/show.py b/src/sc/branching/commands/show.py index ee10ad4..665167c 100644 --- a/src/sc/branching/commands/show.py +++ b/src/sc/branching/commands/show.py @@ -20,9 +20,14 @@ import git from git import Repo +from git_flow_library import GitFlowLibrary from sc_manifest_parser import ProjectElementInterface, ScManifest from .command import Command +from sc.exceptions import ScError +from sc.services.tickets.exceptions import TicketIdentifierNotFound +from sc.services.tickets.ticket import Ticket +from sc.services.tickets.ticket_service import TicketService logger = logging.getLogger(__name__) @@ -133,3 +138,85 @@ def _show_log(self, repo_dir: Path): cwd=repo_dir, check=False ) + +@dataclass +class ShowMergedRelease(Command): + """Show which tickets have been merged between two tags or SHAs. + + Arguments: + previous_release (str): Oldest tag/SHA to check merged tickets. + current_release (str): Newest tag/SHA to check merged tickets, defaults to + None which assigns it to tip of develop. + url (bool): Show + + """ + previous_release: str | None = None + current_release: str | None = None + wiki: bool = False + + def run_git_command(self): + self._show_merged_release(self.top_dir) + + def run_repo_command(self): + self._show_merged_release(self.top_dir / '.repo' / 'manifests') + + def _show_merged_release(self, dir: Path): + def search(strings: list[str], value: str) -> list[str]: + result = [] + for s in strings: + if value in s: + result.append(s) + return result + + develop = GitFlowLibrary.get_develop_branch(dir) + prev_release = self.previous_release or develop + curr_release = self.current_release or develop + + try: + repo = Repo(dir) + except git.InvalidGitRepositoryError as e: + raise ScError(f"Invalid git repo {dir}") from e + + try: + git_log = repo.git.log( + f"{prev_release}...{curr_release}", format="%s", first_parent=True, merges=True) + except git.GitCommandError as e: + raise ScError(f"Invalid tag/sha {prev_release} or {curr_release}") + + print(f"Tag: [{prev_release}...{curr_release}]") + + merged_branches = repo.git.branch("-r", "--merged", develop).splitlines() + unmerged_branches = repo.git.branch("-r", "--no-merged", develop).splitlines() + + ticket_service = TicketService() + found_refs = [] + for line in git_log.splitlines(): + try: + identifier, ticket_num = ticket_service.get_ref_from_branch(line) + ref = (identifier, ticket_num) + if ref in found_refs: + continue + + ticket = ticket_service.get_ticket(identifier, ticket_num) + if self.wiki: + self._print_wiki(ticket, identifier, ticket_num) + else: + merged = search(search(merged_branches, ticket_num), identifier) + unmerged = search(search(unmerged_branches, ticket_num), identifier) + self._print_default(ticket, merged, unmerged) + found_refs.append(ref) + except TicketIdentifierNotFound as e: + pass + + def _print_default(self, ticket: Ticket, merged: list[str], unmerged: list[str]): + print(ticket.to_terminal(one_line=True)) + if merged: + print("Merged Branches: ") + print("\n".join(merged)) + if unmerged: + print("Un-Merged Branches: ") + print("\n".join(unmerged)) + print() + + def _print_wiki(self, ticket: Ticket, identifier: str, ticket_num: str): + print(f"[{identifier}-{ticket_num}]{ticket.url} - {ticket.title}") diff --git a/src/sc/branching/exceptions.py b/src/sc/branching/exceptions.py index 891248a..bbbba60 100644 --- a/src/sc/branching/exceptions.py +++ b/src/sc/branching/exceptions.py @@ -12,5 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -class ScInitError(RuntimeError): +from sc.exceptions import ScError + +class ScInitError(ScError): pass \ No newline at end of file diff --git a/src/sc/exceptions.py b/src/sc/exceptions.py new file mode 100644 index 0000000..a5b1666 --- /dev/null +++ b/src/sc/exceptions.py @@ -0,0 +1,34 @@ +# Copyright 2025 RDK Management +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +class ScError(Exception): + """Generic error for sc exceptions.""" + pass + +class ConfigError(ScError): + """Raised when there is an error with the config. + """ + pass + +class PermissionsError(ScError): + """Raised when permission is denied. + + Args: + resource (str): The resource access is denied to. + resolution_message (str): Additional info about access denial, defaults to ''. + """ + def __init__(self, resource: str, resolution_message: str = ''): + super().__init__( + f'You do not have permission to access {resource}\n{resolution_message}' + ) diff --git a/src/sc/project_cli.py b/src/sc/project_cli.py index 61c3674..2bc1dae 100644 --- a/src/sc/project_cli.py +++ b/src/sc/project_cli.py @@ -120,6 +120,13 @@ def show_group(group): """List groups or show information about a group.""" SCBranching.group_show(group) +@show.command(name="merged_release") +@click.option("-p", "--previous-release", help="Previous release tag.") +@click.option("-c", "--current-release", help="Newer release tag, defaults to latest develop.") +@click.option("-w", "--wiki", is_flag=True, help="Print in a wiki pastable format.") +def show_merged_release(previous_release, current_release, wiki): + SCBranching.show_merged_release(previous_release, current_release, wiki) + @cli.group() def group(): @@ -171,3 +178,26 @@ def group_show(group): def group_tag(group, tag, message, push): """Tag all projects in a group.""" SCBranching.group_tag(group, tag, message, push) + +@cli.group() +def branch(): + pass + +@branch.command(name="rename") +@click.argument("old_branch") +@click.argument("new_branch") +def branch_rename(old_branch, new_branch): + SCBranching.branch_rename(old_branch, new_branch) + +@branch.command(name="show") +def branch_show(): + SCBranching.show_branch() + +@branch.command(name="rm_merged") +@click.option("-n", "--no-merged", is_flag=True, help="Show not merged branches instead.") +@click.option("-a", "--all", is_flag=True, help="Show all branches.") +@click.option("-y", "--yes", is_flag=True, help="Auto yes (no-prompt).") +@click.option("-g", "--git", is_flag=True, help="Perform on a single git repo.") +@click.option("-d", "--dry", is_flag=True, help="Perform dry run.") +def branch_rm_merged(no_merged, all, yes, git, dry): + SCBranching.branch_rm_merged(no_merged, all, yes, git, dry) \ No newline at end of file diff --git a/src/sc/prompter.py b/src/sc/prompter.py new file mode 100644 index 0000000..a70a24e --- /dev/null +++ b/src/sc/prompter.py @@ -0,0 +1,26 @@ +# Copyright 2025 RDK Management +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import logging + +logger = logging.getLogger(__name__) + +class Prompter: + """Prompts for user input.""" + @staticmethod + def yn(msg: str) -> bool: + return input(f"{msg} (y/n): ").strip().lower() == 'y' + + @staticmethod + def ask(msg: str) -> str: + return input(f"{msg}\n> ").strip() diff --git a/src/sc/review/git_host_service.py b/src/sc/review/git_host_service.py index 4b57871..ba76ca2 100644 --- a/src/sc/review/git_host_service.py +++ b/src/sc/review/git_host_service.py @@ -14,7 +14,7 @@ from collections.abc import Iterable from .models import CodeReview -from .exceptions import RemoteUrlNotFound +from sc.exceptions import ConfigError from .git_flow_branch_strategy import GitFlowBranchStrategy from .git_instances import GitFactory, GitInstance from .models import RepoInfo @@ -70,5 +70,5 @@ def _match_remote_pattern(remote_url: str, url_patterns: Iterable[str]) -> str: for pattern in url_patterns: if pattern in remote_url: return pattern - raise RemoteUrlNotFound(f"{remote_url} doesn't match any patterns! \n" + raise ConfigError(f"{remote_url} doesn't match any patterns! \n" f"Remote patterns found: {', '.join(url_patterns)}") diff --git a/src/sc/review/models.py b/src/sc/review/models.py index a1ecb09..2baa289 100644 --- a/src/sc/review/models.py +++ b/src/sc/review/models.py @@ -17,27 +17,6 @@ from pathlib import Path from urllib import parse -@dataclass -class Ticket: - url: str - author: str | None = None - assignee: str | None = None - comments: str | None = None - id: str | None = None - status: str | None = None - target_version: str | None = None - title: str | None = None - - def to_terminal(self) -> str: - def c(code, text): - return f"\033[{code}m{text}\033[0m" - - return ( - f"URL: [{c('34', self.url)}]\n" - f"ID: [{c('33', self.id)}]\n" - f"Title: [{c('33', self.title)}]\n" - ) - class CRStatus(str, Enum): OPEN = "Open" CLOSED = "Closed" diff --git a/src/sc/review/prompter.py b/src/sc/review/prompter.py deleted file mode 100644 index 98f1195..0000000 --- a/src/sc/review/prompter.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright 2025 RDK Management -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import logging - -from .review_config import TicketHostModel - -logger = logging.getLogger(__name__) - -class Prompter: - def yn(self, msg: str) -> bool: - return input(f"{msg} (y/n): ").strip().lower() == 'y' - - def ticket_selection( - self, ticket_conf: dict[str, TicketHostModel])-> tuple[str, str]: - """Prompt the user to select a ticketing instance and enter a ticket number. - - Returns: - tuple[str, str]: The selected ticketing instance identifier and ticket - number. - """ - logger.info("Please enter the prefix of the ticket instance:") - logger.info("PREFIX --- INSTANCE URL --- DESCRIPTION") - for id, conf in ticket_conf.items(): - logger.info(f"{id} --- {conf.url} --- {conf.description or ''}") - - input_id = input("> ") - while input_id not in ticket_conf.keys(): - logger.info(f"Prefix {input_id} not found in instances.") - input_id = input("> ") - - logger.info("Please enter your ticket number:") - input_num = input("> ") - - return input_id, input_num diff --git a/src/sc/review/review.py b/src/sc/review/review.py index 24faa47..6dab22e 100644 --- a/src/sc/review/review.py +++ b/src/sc/review/review.py @@ -17,14 +17,16 @@ from pathlib import Path import sys -from .exceptions import ReviewException from git_flow_library import GitFlowLibrary from repo_library import RepoLibrary + +from .git_instances import GitFactory from .repo_source import ManifestRepoSource, SingleRepoSource +from .review_config import GitHostConfig, GitHostModel +from sc.exceptions import ScError +from ..services.tickets.ticketing_instances import TicketingInstanceFactory +from ..services.tickets.ticket_config import TicketHostConfig, TicketHostModel from .ticket_updater import TicketUpdater -from .review_config import GitHostConfig, GitHostModel, TicketHostConfig, TicketHostModel -from .ticketing_instances import TicketingInstanceFactory -from .git_instances import GitFactory logger = logging.getLogger(__name__) @@ -43,7 +45,7 @@ def update_ticket(single_git: bool = False): try: TicketUpdater(repo_source).run() - except (ReviewException, ConnectionError, RuntimeError) as e: + except (ScError, ConnectionError, RuntimeError) as e: logger.error(e) sys.exit(1) diff --git a/src/sc/review/review_config.py b/src/sc/review/review_config.py index 5e841a3..3f31a9d 100644 --- a/src/sc/review/review_config.py +++ b/src/sc/review/review_config.py @@ -12,53 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Literal - from pydantic import BaseModel, ConfigDict, ValidationError -from .exceptions import ConfigError +from sc.exceptions import ConfigError from sc.config_manager import ConfigManager -class TicketHostModel(BaseModel): - model_config = ConfigDict(extra='forbid') - - url: str - provider: str - api_key: str - username: str | None = None - auth_type: Literal["token", "basic"] = "token" - project_prefix: str | None = None - description: str | None = None - cert: str | None = None - -class TicketHostConfig: - def __init__(self): - self._ticket_config = ConfigManager('ticketing_instances') - - def get_config(self) -> dict[str, TicketHostModel]: - """Return all ticketing instance configs keyed by identifier.""" - return {k: TicketHostModel(**v) for k,v in self._ticket_config.get_config().items()} - - def get_identifiers(self) -> set[str]: - """Return all configured ticketing instance identifiers.""" - return set(self._ticket_config.get_config().keys()) - - def get(self, identifier: str) -> TicketHostModel: - """Return the ticketing config for a specific identifier.""" - data = self._ticket_config.get_config().get(identifier) - if not data: - raise ConfigError( - f"Ticket instance config doesn't contain entry for {identifier}") - try: - return TicketHostModel(**data) - except ValidationError as e: - raise ConfigError(f"Invalid config for ticketing instance {identifier}: {e}") - - def write(self, branch_prefix: str, ticket_data: TicketHostModel): - """Persist ticketing config for a branch prefix.""" - self._ticket_config.update_config( - {branch_prefix: ticket_data.model_dump(exclude_none=True)}) - class GitHostModel(BaseModel): model_config = ConfigDict(extra='forbid') diff --git a/src/sc/review/ticket_service.py b/src/sc/review/ticket_service.py deleted file mode 100644 index 8a6964c..0000000 --- a/src/sc/review/ticket_service.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright 2025 RDK Management -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import re - -from .exceptions import TicketIdentifierNotFound -from .models import Ticket -from .prompter import Prompter -from .review_config import TicketHostConfig -from .ticketing_instances import TicketingInstance, TicketingInstanceFactory - -class TicketService: - def __init__( - self, - config: TicketHostConfig | None = None, - factory: TicketingInstanceFactory | None = None, - prompter: Prompter | None = None - ): - self._config = config or TicketHostConfig() - self._factory = factory or TicketingInstanceFactory() - self._prompter = prompter or Prompter() - - def resolve( - self, identifier: str, ticket_num: str) -> tuple[TicketingInstance, Ticket]: - """Match an instance identifier and ticket num to a ticket instance and ticket.""" - cfg = self._config.get(identifier) - instance = self._factory.create( - provider=cfg.provider, - url=cfg.url, - token=cfg.api_key, - auth_type=cfg.auth_type, - username=cfg.username, - cert=cfg.cert - ) - ticket_id = f"{cfg.project_prefix or ''}{ticket_num}" - ticket = instance.read_ticket(ticket_id) - - return instance, ticket - - def update(self, instance: TicketingInstance, ticket: Ticket, comment: str): - """Update a ticket on an instance with a comment.""" - instance.add_comment_to_ticket(ticket.id, comment) - - def match_branch(self, branch_name: str) -> tuple[str, str]: - """Match the branch to an identifier in the config. - - Args: - branch_name (str): The current branch name. - - Raises: - TicketIdentifierNotFound: Raised when the branch doesn't match any - identifiers in the ticket host config. - - Returns: - tuple[str, str]: (matched_identifier, ticket_number) - """ - host_identifiers = self._config.get_identifiers() - for identifier in host_identifiers: - # Matches the identifier, followed by - or _, followed by a number - if m := re.search(fr'(?:^|/){re.escape(identifier)}[-_]?(\d+)', branch_name): - ticket_num = m.group(1) - return identifier, ticket_num - raise TicketIdentifierNotFound( - f"Branch {branch_name} doesn't match any ticketing instances! " - f"Found instances {', '.join(host_identifiers)}") - - def prompt_ticket(self) -> tuple[str, str]: - """Returns identifier and ticket num by user choice.""" - return self._prompter.ticket_selection(self._config.get_config()) diff --git a/src/sc/review/ticket_updater.py b/src/sc/review/ticket_updater.py index d4f0b16..395bebb 100644 --- a/src/sc/review/ticket_updater.py +++ b/src/sc/review/ticket_updater.py @@ -14,13 +14,13 @@ import logging -from .exceptions import ReviewException from .git_host_service import GitHostService -from .models import CodeReview, CommentData, RepoInfo, Ticket -from .prompter import Prompter +from .models import CodeReview, CommentData, RepoInfo +from sc.prompter import Prompter from .repo_source import RepoSource -from .ticketing_instances import TicketingInstance -from .ticket_service import TicketService +from sc.exceptions import ScError +from ..services.tickets import Ticket +from ..services.tickets.ticket_service import TicketService logger = logging.getLogger(__name__) @@ -38,7 +38,7 @@ def __init__( self._prompter = prompter or Prompter() def run(self): - ticket_instance, ticket = self._get_ticket_and_instance() + ticket = self._get_ticket() comments = [] for repo_info in self.repo_source.get_repos(): @@ -56,28 +56,24 @@ def run(self): if self._prompter.yn("Update ticket?"): ticket_comment = self._generate_combined_ticket_comment(comments) - self._ticket_service.update(ticket_instance, ticket, ticket_comment) + ticket.add_comment(ticket_comment) - def _get_ticket_and_instance(self) -> tuple[TicketingInstance, Ticket]: + def _get_ticket(self) -> Ticket: """Get ticket and ticketing instance from branch, on failure prompt the user to manually enter. """ try: - identifier, ticket_num = self._ticket_service.match_branch( - self.repo_source.active_branch) - ticket_instance, ticket = self._ticket_service.resolve(identifier, ticket_num) - except ReviewException as e: + ticket = self._ticket_service.get_ticket_from_branch(self.repo_source.active_branch) + except ScError as e: logger.warning(e) - identifier, ticket_num = self._ticket_service.prompt_ticket() - ticket_instance, ticket = self._ticket_service.resolve(identifier, ticket_num) + ticket = self._ticket_service.prompt_ticket() while True: print(ticket.to_terminal()) if self._prompter.yn("Use this ticket?"): - return ticket_instance, ticket + return ticket - identifier, ticket_num = self._ticket_service.prompt_ticket() - ticket_instance, ticket = self._ticket_service.resolve(identifier, ticket_num) + ticket = self._ticket_service.prompt_ticket() def _create_comment_data( self, diff --git a/src/sc/services/tickets/__init__.py b/src/sc/services/tickets/__init__.py new file mode 100644 index 0000000..c1f06d6 --- /dev/null +++ b/src/sc/services/tickets/__init__.py @@ -0,0 +1,2 @@ +from .ticket import Ticket, TicketData +from .ticket_service import TicketService \ No newline at end of file diff --git a/src/sc/review/exceptions.py b/src/sc/services/tickets/exceptions.py similarity index 60% rename from src/sc/review/exceptions.py rename to src/sc/services/tickets/exceptions.py index 97b04c2..cd097d6 100644 --- a/src/sc/review/exceptions.py +++ b/src/sc/services/tickets/exceptions.py @@ -12,11 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -class ReviewException(Exception): - """Base exception for sc review errors.""" +from sc.exceptions import ScError + +class TicketError(ScError): + """Base class for all ticket-related errors.""" + pass + +class TicketIdentifierNotFound(TicketError): + """Raise when a ticket identifier isn't found.""" pass -class TicketNotFound(ReviewException): +class TicketNotFound(TicketError): """Raised when a ticket cannot be found. Args: @@ -25,7 +31,7 @@ class TicketNotFound(ReviewException): def __init__(self, ticket_url: str): super().__init__(f'Ticket not found at url: {ticket_url}') -class TicketingInstanceUnreachable(ReviewException): +class TicketingInstanceUnreachable(TicketError): """Raised when a ticketing instance is unreachable. Args: @@ -37,29 +43,7 @@ def __init__(self, instance_url: str, additional_info: str = ''): f'Ticketing instance at {instance_url} is unreachable: {additional_info}' ) -class PermissionsError(ReviewException): - """Raised when permission is denied. - - Args: - resource (str): The resource access is denied to. - resolution_message (str): Additional info about access denial, defaults to ''. - """ - def __init__(self, resource: str, resolution_message: str = ''): - super().__init__( - f'You do not have permission to access {resource}\n{resolution_message}' - ) - -class ConfigError(ReviewException): - """Raised when there is an error with the config. - """ - pass - -class TicketIdentifierNotFound(ConfigError): +class TicketIdentifierNotFound(TicketError): """Raised when ticket ID isn't found in the config. """ pass - -class RemoteUrlNotFound(ConfigError): - """Raised when remote url matches no patterns in the config. - """ - pass diff --git a/src/sc/services/tickets/ticket.py b/src/sc/services/tickets/ticket.py new file mode 100644 index 0000000..c65c0bb --- /dev/null +++ b/src/sc/services/tickets/ticket.py @@ -0,0 +1,70 @@ +# Copyright 2025 RDK Management +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from .ticketing_instances.ticketing_instance import TicketingInstance + +@dataclass +class TicketData: + url: str + id: str + author: str | None = None + assignee: str | None = None + comments: str | None = None + status: str | None = None + target_version: str | None = None + title: str | None = None + + def to_terminal(self, one_line: bool = False) -> str: + sections = [ + f"URL: [{click.style(self.url, fg='blue')}]", + f"ID: [{click.style(self.id, fg='yellow')}]", + f"Title: [{click.style(self.title, fg='yellow')}]", + f"Author: [{click.style(self.author, fg='yellow')}]", + f"Status: [{click.style(self.status, fg='green')}]" + ] + + if one_line: + return " ".join(sections) + else: + return "\n".join(sections) + +class Ticket: + def __init__(self, instance: TicketingInstance, ticket_data: TicketData): + self._instance = instance + self.data = ticket_data + + @property + def url(self) -> str: + return self.data.url + + @property + def id(self) -> str: + return self.data.id + + @property + def title(self) -> str | None: + return self.data.title + + def add_comment(self, comment: str): + self._instance.add_comment_to_ticket(self.data.id, comment) + + def to_terminal(self, one_line: bool = False) -> str: + return self.data.to_terminal(one_line) diff --git a/src/sc/services/tickets/ticket_config.py b/src/sc/services/tickets/ticket_config.py new file mode 100644 index 0000000..226a505 --- /dev/null +++ b/src/sc/services/tickets/ticket_config.py @@ -0,0 +1,48 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict, ValidationError + +from sc.config_manager import ConfigManager +from sc.exceptions import ConfigError + +class TicketHostModel(BaseModel): + model_config = ConfigDict(extra='forbid') + + url: str + provider: str + api_key: str + username: str | None = None + auth_type: Literal["token", "basic"] = "token" + project_prefix: str | None = None + description: str | None = None + cert: str | None = None + +class TicketHostConfig: + def __init__(self): + self._ticket_config = ConfigManager('ticketing_instances') + + def get_config(self) -> dict[str, TicketHostModel]: + """Return all ticketing instance configs keyed by identifier.""" + return {k: TicketHostModel(**v) for k,v in self._ticket_config.get_config().items()} + + def get_identifiers(self) -> set[str]: + """Return all configured ticketing instance identifiers.""" + return set(self._ticket_config.get_config().keys()) + + def get(self, identifier: str) -> TicketHostModel: + """Return the ticketing config for a specific identifier.""" + lookup = {k.lower(): k for k in self.get_identifiers()} + data = self._ticket_config.get_config().get(lookup.get(identifier.lower())) + if not data: + raise ConfigError( + f"Ticket instance config doesn't contain entry for {identifier}") + try: + return TicketHostModel(**data) + except ValidationError as e: + raise ConfigError(f"Invalid config for ticketing instance {identifier}: {e}") + + def write(self, branch_prefix: str, ticket_data: TicketHostModel): + """Persist ticketing config for a branch prefix.""" + self._ticket_config.update_config( + {branch_prefix: ticket_data.model_dump(exclude_none=True)}) + diff --git a/src/sc/services/tickets/ticket_service.py b/src/sc/services/tickets/ticket_service.py new file mode 100644 index 0000000..a4b4cfb --- /dev/null +++ b/src/sc/services/tickets/ticket_service.py @@ -0,0 +1,108 @@ +# Copyright 2025 RDK Management +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from functools import cached_property +import re +import logging + +from .exceptions import TicketIdentifierNotFound +from sc.prompter import Prompter +from .ticket import Ticket +from .ticket_config import TicketHostConfig, TicketHostModel +from .ticketing_instances.ticket_instance_factory import TicketingInstanceFactory +from .ticketing_instances.ticketing_instance import TicketingInstance + +logger = logging.getLogger(__name__) + +class TicketService: + def __init__( + self, + config: TicketHostConfig | None = None, + factory: TicketingInstanceFactory | None = None, + prompter: Prompter | None = None + ): + self._config = config or TicketHostConfig() + self._factory = factory or TicketingInstanceFactory() + self._prompter = prompter or Prompter() + + def get_identifiers(self) -> set[str]: + """Return all configured ticketing instance identifiers.""" + return self._config.get_identifiers() + + def get_ticket(self, identifier: str, ticket_num: str) -> Ticket: + """Match an instance identifier and ticket num to a ticket instance and ticket.""" + cfg = self._config.get(identifier) + instance = self._create_instance(cfg) + + ticket_id = f"{cfg.project_prefix or ''}{ticket_num}" + return instance.read_ticket(ticket_id) + + def get_ticket_from_branch(self, branch_name: str) -> Ticket: + """Match the branch to a ticket and return said ticket. + + Args: + branch_name (str): The current branch name. + + Raises: + TicketIdentifierNotFound: Raised when the branch doesn't match any + identifiers in the ticket host config. + """ + identifier, ticket_num = self.get_ref_from_branch(branch_name) + return self.get_ticket(identifier, ticket_num) + + def prompt_ticket(self) -> Ticket: + """Prompt the user to select a ticket and return it.""" + ticket_conf = self._config.get_config() + logger.info("Please enter the prefix of the ticket instance:") + logger.info("PREFIX --- INSTANCE URL --- DESCRIPTION") + for id, conf in ticket_conf.items(): + logger.info(f"{id} --- {conf.url} --- {conf.description or ''}") + + while True: + identifier = self._prompter.ask("Prefix") + if identifier in ticket_conf: + break + logger.info(f"Prefix {identifier} not found in instances") + + ticket_num = self._prompter.ask("Ticket number") + + return self.get_ticket(identifier, ticket_num) + + def get_ref_from_branch(self, branch_name: str) -> tuple[str, str]: + pattern = self._branch_id_pattern + if m := pattern.search(branch_name): + identifier = m.group(1) + ticket_num = m.group(2) + return identifier, ticket_num + raise TicketIdentifierNotFound( + f"Branch {branch_name} doesn't match any ticketing instances! " + f"Found instances {', '.join(self._config.get_identifiers())}") + + @cached_property + def _branch_id_pattern(self) -> re.Pattern: + host_identifiers = self._config.get_identifiers() + identifiers_pattern = "|".join(map(re.escape, host_identifiers)) + return re.compile( + fr'(?:^|/)({identifiers_pattern})[-_]?(\d+)', + re.IGNORECASE + ) + + def _create_instance(self, cfg: TicketHostModel) -> TicketingInstance: + return self._factory.create( + provider=cfg.provider, + url=cfg.url, + token=cfg.api_key, + auth_type=cfg.auth_type, + username=cfg.username, + cert=cfg.cert + ) diff --git a/src/sc/review/ticketing_instances/__init__.py b/src/sc/services/tickets/ticketing_instances/__init__.py similarity index 100% rename from src/sc/review/ticketing_instances/__init__.py rename to src/sc/services/tickets/ticketing_instances/__init__.py diff --git a/src/sc/review/ticketing_instances/instances/__init__.py b/src/sc/services/tickets/ticketing_instances/instances/__init__.py similarity index 100% rename from src/sc/review/ticketing_instances/instances/__init__.py rename to src/sc/services/tickets/ticketing_instances/instances/__init__.py diff --git a/src/sc/review/ticketing_instances/instances/jira_instance.py b/src/sc/services/tickets/ticketing_instances/instances/jira_instance.py similarity index 93% rename from src/sc/review/ticketing_instances/instances/jira_instance.py rename to src/sc/services/tickets/ticketing_instances/instances/jira_instance.py index 4695a22..2a61094 100644 --- a/src/sc/review/ticketing_instances/instances/jira_instance.py +++ b/src/sc/services/tickets/ticketing_instances/instances/jira_instance.py @@ -11,6 +11,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + from requests.exceptions import RequestException from typing import Literal import urllib3 @@ -18,9 +20,10 @@ from jira import JIRA from jira.exceptions import JIRAError -from sc.review.exceptions import PermissionsError, TicketNotFound -from sc.review.models import Ticket -from .. import TicketingInstance +from sc.exceptions import PermissionsError +from sc.services.tickets.ticket import Ticket, TicketData +from sc.services.tickets.exceptions import TicketNotFound +from ..ticketing_instance import TicketingInstance class JiraInstance(TicketingInstance): """A class to handle operations on Jira tickets. @@ -108,7 +111,7 @@ def read_ticket(self, ticket_id: str) -> Ticket: ticket_url = f'{self.url}/browse/{ticket_id}' - return Ticket( + ticket_data = TicketData( url=ticket_url, assignee=assignee, author=author, @@ -119,6 +122,8 @@ def read_ticket(self, ticket_id: str) -> Ticket: title=title, ) + return Ticket(self, ticket_data) + def add_comment_to_ticket(self, ticket_id: str, comment_message: str): """Adds a comment to the ticket diff --git a/src/sc/review/ticketing_instances/instances/redmine_instance.py b/src/sc/services/tickets/ticketing_instances/instances/redmine_instance.py similarity index 92% rename from src/sc/review/ticketing_instances/instances/redmine_instance.py rename to src/sc/services/tickets/ticketing_instances/instances/redmine_instance.py index 87fa3c7..3f8d198 100644 --- a/src/sc/review/ticketing_instances/instances/redmine_instance.py +++ b/src/sc/services/tickets/ticketing_instances/instances/redmine_instance.py @@ -11,6 +11,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import urllib3 from redminelib import Redmine @@ -18,9 +20,10 @@ from redminelib.exceptions import BaseRedmineError, ForbiddenError, ResourceNotFoundError, AuthError from requests.exceptions import RequestException, SSLError -from sc.review.exceptions import PermissionsError, TicketingInstanceUnreachable, TicketNotFound -from sc.review.models import Ticket -from .. import TicketingInstance +from ..ticketing_instance import TicketingInstance +from sc.exceptions import PermissionsError +from sc.services.tickets.exceptions import TicketingInstanceUnreachable, TicketNotFound +from sc.services.tickets.ticket import Ticket, TicketData class RedmineInstance(TicketingInstance): """ @@ -71,7 +74,7 @@ def read_ticket(self, ticket_id: str) -> Ticket: ticket_url, additional_info=''.join(str(arg) for arg in e.args)) - issue_contents = issue.__dict__ + issue_contents = issue._decoded_attrs author = issue_contents.get("author", {}).get("name") assignee = issue_contents.get("assigned_to", {}).get("name") @@ -80,17 +83,19 @@ def read_ticket(self, ticket_id: str) -> Ticket: target_version = issue_contents.get("fixed_version", {}).get("name") title = issue.subject - return Ticket( + ticket_data = TicketData( ticket_url, + id=ticket_id, author=author, assignee=assignee, comments=comments, - id=ticket_id, status=status, title=title, - target_version=target_version + target_version=target_version, ) + return Ticket(self, ticket_data) + def add_comment_to_ticket(self, ticket_id: str, comment_message: str): """Add a comment to a ticket on the redmine instance diff --git a/src/sc/review/ticketing_instances/ticket_instance_factory.py b/src/sc/services/tickets/ticketing_instances/ticket_instance_factory.py similarity index 97% rename from src/sc/review/ticketing_instances/ticket_instance_factory.py rename to src/sc/services/tickets/ticketing_instances/ticket_instance_factory.py index 9c27665..6cf2182 100644 --- a/src/sc/review/ticketing_instances/ticket_instance_factory.py +++ b/src/sc/services/tickets/ticketing_instances/ticket_instance_factory.py @@ -13,7 +13,7 @@ # limitations under the License. from typing import Literal -from sc.review.exceptions import TicketIdentifierNotFound +from sc.services.tickets.exceptions import TicketIdentifierNotFound from .instances import JiraInstance, RedmineInstance from .ticketing_instance import TicketingInstance diff --git a/src/sc/review/ticketing_instances/ticketing_instance.py b/src/sc/services/tickets/ticketing_instances/ticketing_instance.py similarity index 93% rename from src/sc/review/ticketing_instances/ticketing_instance.py rename to src/sc/services/tickets/ticketing_instances/ticketing_instance.py index d0df9a9..e11035e 100644 --- a/src/sc/review/ticketing_instances/ticketing_instance.py +++ b/src/sc/services/tickets/ticketing_instances/ticketing_instance.py @@ -11,10 +11,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations from abc import ABC, abstractmethod +from typing import TYPE_CHECKING -from ..models import Ticket +if TYPE_CHECKING: + from .. import Ticket class TicketingInstance(ABC): """ diff --git a/tests/branching/test_branch_rename.py b/tests/branching/test_branch_rename.py new file mode 100644 index 0000000..6038f7b --- /dev/null +++ b/tests/branching/test_branch_rename.py @@ -0,0 +1,128 @@ +# Copyright 2025 RDK Management +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import subprocess +import unittest + +from git import Repo + +from .repo_client_creator import RepoTestClientCreator + +def _remote_branch_exists(repo: Repo, branch: str) -> bool: + out = repo.git.ls_remote("--heads", repo.remotes[0].name, branch) + return bool(out.strip()) + +class TestBranchRename(unittest.TestCase): + def setUp(self): + self.repo_client = RepoTestClientCreator() + + def tearDown(self): + self.repo_client.cleanup() + + def test_branch_rename(self): + self.repo_client.add_branches(["master", "develop", "feature/donut"]) + proj = self.repo_client.add_project() + top_dir = self.repo_client.create("feature/donut") + + subprocess.run( + ["sc", "branch", "rename", "feature/donut", "feature/pizza"], + cwd=top_dir, + check=True) + + proj_repo = Repo(top_dir / proj.name) + manifest_repo = Repo(top_dir / ".repo" / "manifests") + self.assertEqual(proj_repo.active_branch.name, "feature/pizza") + self.assertEqual(manifest_repo.active_branch.name, "feature/pizza") + self.assertFalse(_remote_branch_exists(proj_repo, "feature/donut")) + self.assertTrue(_remote_branch_exists(proj_repo, "feature/pizza")) + + def test_branch_rename_can_be_rerun(self): + self.repo_client.add_branches(["master", "develop", "feature/donut"]) + proj = self.repo_client.add_project() + top_dir = self.repo_client.create("feature/donut") + + subprocess.run( + ["sc", "branch", "rename", "feature/donut", "feature/pizza"], + cwd=top_dir, + check=True, + ) + subprocess.run( + ["sc", "branch", "rename", "feature/donut", "feature/pizza"], + cwd=top_dir, + check=True, + ) + + proj_repo = Repo(top_dir / proj.name) + manifest_repo = Repo(top_dir / ".repo" / "manifests") + self.assertEqual(proj_repo.active_branch.name, "feature/pizza") + self.assertEqual(manifest_repo.active_branch.name, "feature/pizza") + self.assertFalse(_remote_branch_exists(proj_repo, "feature/donut")) + self.assertTrue(_remote_branch_exists(proj_repo, "feature/pizza")) + + def test_branch_rename_skips_when_neither_branch_exists(self): + self.repo_client.add_branches(["master", "develop", "feature/donut"]) + proj = self.repo_client.add_project() + top_dir = self.repo_client.create("develop") + + subprocess.run( + ["sc", "branch", "rename", "feature/missing", "feature/pizza"], + cwd=top_dir, + check=True, + ) + + proj_repo = Repo(top_dir / proj.name) + manifest_repo = Repo(top_dir / ".repo" / "manifests") + self.assertEqual(proj_repo.active_branch.name, "develop") + self.assertEqual(manifest_repo.active_branch.name, "develop") + self.assertFalse(_remote_branch_exists(proj_repo, "feature/missing")) + self.assertFalse(_remote_branch_exists(proj_repo, "feature/pizza")) + + def test_branch_rename_skips_when_both_branches_exist(self): + self.repo_client.add_branches(["master", "develop", "feature/donut", "feature/pizza"]) + proj = self.repo_client.add_project() + top_dir = self.repo_client.create("feature/donut") + + subprocess.run( + ["sc", "branch", "rename", "feature/donut", "feature/pizza"], + cwd=top_dir, + check=True, + ) + + proj_repo = Repo(top_dir / proj.name) + manifest_repo = Repo(top_dir / ".repo" / "manifests") + self.assertEqual(proj_repo.active_branch.name, "feature/donut") + self.assertEqual(manifest_repo.active_branch.name, "feature/donut") + self.assertTrue(_remote_branch_exists(proj_repo, "feature/donut")) + self.assertTrue(_remote_branch_exists(proj_repo, "feature/pizza")) + + def test_branch_rename_when_remote_old_branch_does_not_exist(self): + self.repo_client.add_branches(["master", "develop", "feature/donut"]) + proj = self.repo_client.add_project() + top_dir = self.repo_client.create("feature/donut") + + proj_repo = Repo(top_dir / proj.name) + proj_repo.git.push(proj_repo.remotes[0].name, "--delete", "feature/donut") + + subprocess.run( + ["sc", "branch", "rename", "feature/donut", "feature/pizza"], + cwd=top_dir, + check=True, + ) + + proj_repo = Repo(top_dir / proj.name) + manifest_repo = Repo(top_dir / ".repo" / "manifests") + self.assertEqual(proj_repo.active_branch.name, "feature/pizza") + self.assertEqual(manifest_repo.active_branch.name, "feature/pizza") + self.assertFalse(_remote_branch_exists(proj_repo, "feature/donut")) + self.assertTrue(_remote_branch_exists(proj_repo, "feature/pizza")) diff --git a/tests/branching/test_branch_rm_merged.py b/tests/branching/test_branch_rm_merged.py new file mode 100644 index 0000000..6fd87ac --- /dev/null +++ b/tests/branching/test_branch_rm_merged.py @@ -0,0 +1,152 @@ +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from sc.branching.commands.branch_rm_merged import BranchRmMerged +from sc.exceptions import ScError + + +class TestBranchRmMerged(unittest.TestCase): + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._error_on_sc_uninitialised") + def test_verify_options_errors_with_all_and_not_merged( + self, error_on_sc_uninitialised + ): + cmd = BranchRmMerged(top_dir=Path("/workspace"), all=True, not_merged=True) + + with self.assertRaisesRegex(ScError, "Cannot pass both --all and --no-merged"): + cmd.run_repo_command() + error_on_sc_uninitialised.assert_called_once_with() + + @patch("sc.branching.commands.branch_rm_merged.Delete") + @patch("sc.branching.commands.branch_rm_merged.TicketService") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._get_feature_branches") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._error_on_sc_uninitialised") + def test_run_repo_command_deletes_without_prompt( + self, + error_on_sc_uninitialised, + get_feature_branches, + ticket_service_cls, + delete_cls, + ): + top_dir = Path("/workspace") + + branch = Mock() + branch.name = "feature/donut" + + ticket = Mock() + ticket.to_terminal.return_value = "TICKET-123 donut" + ticket_service_cls.return_value.get_ticket_from_branch.return_value = ticket + + get_feature_branches.return_value = [branch] + + cmd = BranchRmMerged(top_dir=top_dir, no_prompt=True) + cmd.run_repo_command() + + error_on_sc_uninitialised.assert_called_once_with() + get_feature_branches.assert_called_once_with(top_dir) + ticket_service_cls.return_value.get_ticket_from_branch.assert_called_once_with( + "feature/donut" + ) + delete_cls.assert_called_once_with(top_dir, branch, remote=True) + delete_cls.return_value.run_repo_command.assert_called_once_with() + delete_cls.return_value.run_git_command.assert_not_called() + + @patch("sc.branching.commands.branch_rm_merged.Delete") + @patch("sc.branching.commands.branch_rm_merged.TicketService") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._get_feature_branches") + def test_run_git_command_deletes_using_git_command( + self, + get_feature_branches, + ticket_service_cls, + delete_cls, + ): + top_dir = Path("/repo") + + branch = Mock() + branch.name = "feature/donut" + + ticket_service_cls.return_value.get_ticket_from_branch.return_value.to_terminal.return_value = ( + "TICKET-123 donut" + ) + get_feature_branches.return_value = [branch] + + cmd = BranchRmMerged(top_dir=top_dir, no_prompt=True) + cmd.run_git_command() + + get_feature_branches.assert_called_once_with(top_dir) + delete_cls.assert_called_once_with(top_dir, branch, remote=True) + delete_cls.return_value.run_git_command.assert_called_once_with() + delete_cls.return_value.run_repo_command.assert_not_called() + + @patch("sc.branching.commands.branch_rm_merged.Prompter.yn", return_value=False) + @patch("sc.branching.commands.branch_rm_merged.Delete") + @patch("sc.branching.commands.branch_rm_merged.TicketService") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._get_feature_branches") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._error_on_sc_uninitialised") + def test_run_repo_command_does_not_delete_when_prompt_declines( + self, + error_on_sc_uninitialised, + get_feature_branches, + ticket_service_cls, + delete_cls, + prompt_yn, + ): + top_dir = Path("/workspace") + + branch = Mock() + branch.name = "feature/donut" + get_feature_branches.return_value = [branch] + ticket_service_cls.return_value.get_ticket_from_branch.return_value.to_terminal.return_value = ( + "TICKET-123 donut" + ) + + cmd = BranchRmMerged(top_dir=top_dir) + cmd.run_repo_command() + + prompt_yn.assert_called_once_with("Delete branch?") + delete_cls.assert_not_called() + + @patch("sc.branching.commands.branch_rm_merged.Prompter.yn") + @patch("sc.branching.commands.branch_rm_merged.Delete") + @patch("sc.branching.commands.branch_rm_merged.TicketService") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._get_feature_branches") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._error_on_sc_uninitialised") + def test_run_repo_command_no_prompt_does_not_prompt( + self, + error_on_sc_uninitialised, + get_feature_branches, + ticket_service_cls, + delete_cls, + prompt_yn, + ): + branch = Mock() + branch.name = "feature/donut" + get_feature_branches.return_value = [branch] + ticket_service_cls.return_value.get_ticket_from_branch.return_value.to_terminal.return_value = ( + "TICKET-123 donut" + ) + + cmd = BranchRmMerged(top_dir=Path("/workspace"), no_prompt=True) + cmd.run_repo_command() + + prompt_yn.assert_not_called() + delete_cls.assert_called_once() + + @patch("sc.branching.commands.branch_rm_merged.GitFlowLibrary.get_git_root") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._rm_merged") + @patch("sc.branching.commands.branch_rm_merged.BranchRmMerged._error_on_sc_uninitialised") + def test_run_repo_command_git_only_uses_current_git_root( + self, + error_on_sc_uninitialised, + rm_merged, + get_git_root, + ): + git_root = Path("/workspace/project") + get_git_root.return_value = git_root + + cmd = BranchRmMerged(top_dir=Path("/workspace"), git_only=True) + cmd.run_repo_command() + + error_on_sc_uninitialised.assert_called_once_with() + get_git_root.assert_called_once() + rm_merged.assert_called_once_with(git_root, True) diff --git a/tests/review/test_git_host_service.py b/tests/review/test_git_host_service.py index 6a769b4..9e5f497 100644 --- a/tests/review/test_git_host_service.py +++ b/tests/review/test_git_host_service.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock from sc.review.git_host_service import GitHostService, _match_remote_pattern -from sc.review.exceptions import RemoteUrlNotFound +from sc.exceptions import ConfigError class TestGitHostService(unittest.TestCase): @@ -92,7 +92,7 @@ def test_match_remote_pattern_success(self): self.assertEqual(result, "github.com") def test_match_remote_pattern_failure(self): - with self.assertRaises(RemoteUrlNotFound): + with self.assertRaises(ConfigError): _match_remote_pattern( "https://bitbucket.org/org/repo", ["gitlab.com", "github.com"] diff --git a/tests/review/test_ticket_service.py b/tests/review/test_ticket_service.py deleted file mode 100644 index 0a90240..0000000 --- a/tests/review/test_ticket_service.py +++ /dev/null @@ -1,73 +0,0 @@ -import unittest -from unittest.mock import MagicMock - -from sc.review.ticket_service import TicketService -from sc.review.exceptions import TicketIdentifierNotFound - -class TestTicketService(unittest.TestCase): - def setUp(self): - self.config = MagicMock() - self.factory = MagicMock() - self.prompter = MagicMock() - - self.service = TicketService( - config=self.config, - factory=self.factory, - prompter=self.prompter - ) - - def test_resolve(self): - cfg = MagicMock( - provider="prov", - url="url", - api_key="key", - auth_type="config", - username="user", - cert="cert", - project_prefix="ABC-" - ) - self.config.get.return_value = cfg - - mock_instance = MagicMock() - self.factory.create.return_value = mock_instance - - mock_ticket = MagicMock(id="ABC-123") - mock_instance.read_ticket.return_value = mock_ticket - - instance, ticket = self.service.resolve("jira", 123) - - self.factory.create.assert_called_once() - instance.read_ticket.assert_called_once_with("ABC-123") - self.assertEqual(instance, mock_instance) - self.assertEqual(ticket, mock_ticket) - - def test_update(self): - mock_instance = MagicMock() - mock_ticket = MagicMock(id="ABC-123") - - self.service.update(mock_instance, mock_ticket, "comment") - - mock_instance.add_comment_to_ticket.assert_called_once_with("ABC-123", "comment") - - def test_match_branch_success(self): - self.config.get_identifiers.return_value = ["ABC"] - - identifier, ticket_num = self.service.match_branch("feature/ABC-123") - - self.assertEqual(identifier, "ABC") - self.assertEqual(ticket_num, "123") - - def test_match_branch_failure(self): - self.config.get_identifiers.return_value = ["ABC"] - - with self.assertRaises(TicketIdentifierNotFound): - self.service.match_branch("feature/no-match") - - def test_prompt_ticket(self): - self.config.get_config.return_value = {"cfg": "value"} - self.prompter.ticket_selection.return_value = ("ABC", "123") - - result = self.service.prompt_ticket() - - self.prompter.ticket_selection.assert_called_once_with({"cfg": "value"}) - self.assertEqual(result, ("ABC", "123")) diff --git a/tests/review/test_ticket_updater.py b/tests/review/test_ticket_updater.py index 8824d0d..b91cedd 100644 --- a/tests/review/test_ticket_updater.py +++ b/tests/review/test_ticket_updater.py @@ -1,11 +1,12 @@ import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, MagicMock, patch from sc.review.ticket_updater import TicketUpdater -from sc.review.exceptions import ReviewException, TicketIdentifierNotFound +from sc.services.tickets.exceptions import TicketIdentifierNotFound +from sc.services.tickets.ticket import Ticket +from sc.exceptions import ScError from sc.review.models import CodeReview, RepoInfo - class TestTicketUpdater(unittest.TestCase): def setUp(self): @@ -14,7 +15,7 @@ def setUp(self): self.git_service = MagicMock() self.prompter = MagicMock() - self.review = TicketUpdater( + self.ticket_updater = TicketUpdater( repo_source=self.repo_source, ticket_service=self.ticket_service, git_service=self.git_service, @@ -43,90 +44,73 @@ def setUp(self): def test_run_happy_path(self, mock_print): cr = CodeReview(status="OPEN", url="http://cr") self.git_service.get_git_review_data.return_value = cr - self.ticket_service.match_branch.return_value = ("ABC", "123") + ticket = Mock(spec=Ticket) + self.ticket_service.get_ticket_from_branch.return_value = ticket # Use this ticket? yes. Update ticket? yes. self.prompter.yn.side_effect = [True, True] - self.review.run() + self.ticket_updater.run() - self.ticket_service.resolve.assert_called_once_with("ABC", "123") + self.ticket_service.get_ticket_from_branch.assert_called_once_with("feature/test") self.git_service.get_git_review_data.assert_called_once_with(self.repo_info) - self.ticket_service.update.assert_called_once() + ticket.add_comment.assert_called_once() @patch("builtins.print") def test_run_ticket_not_found_fallback(self, mock_print): - self.ticket_service.match_branch.side_effect = TicketIdentifierNotFound("err") - self.ticket_service.prompt_ticket.return_value = ("ABC", "123") + self.ticket_service.get_ticket_from_branch.side_effect = TicketIdentifierNotFound("err") + ticket = Mock(spec=Ticket) + self.ticket_service.prompt_ticket.return_value = ticket self.git_service.get_git_review_data.return_value = CodeReview(status=None, url=None) # Use this ticket? yes. Update ticket? no. self.prompter.yn.side_effect = [True, False] - self.review.run() + self.ticket_updater.run() self.ticket_service.prompt_ticket.assert_called_once() - self.ticket_service.resolve.assert_called_once_with("ABC", "123") - self.ticket_service.update.assert_not_called() + ticket.add_comment.assert_not_called() @patch("builtins.print") def test_ticket_unable_to_resolve_fallback(self, mock_print): - self.ticket_service.match_branch.return_value = ("ABC", "123") - self.ticket_service.prompt_ticket.return_value = ("DEF", "456") - - fallback_ticket = MagicMock(id=2, url="http://fallback-ticket", title="Fallback title") - fallback_ticket.to_terminal.return_value = "fallback ticket terminal output" - - self.ticket_service.resolve.side_effect = [ - ReviewException("cannot resolve"), - ("ticket_instance", fallback_ticket), - ] + self.ticket_service.get_ticket_from_branch.side_effect = ScError("cannot resolve") + ticket = Mock(spec=Ticket) + self.ticket_service.prompt_ticket.return_value = ticket self.prompter.yn.return_value = True - ticket_instance, ticket = self.review._get_ticket_and_instance() + result = self.ticket_updater._get_ticket() + self.ticket_service.get_ticket_from_branch.assert_called_once() self.ticket_service.prompt_ticket.assert_called_once() - self.assertEqual(self.ticket_service.resolve.call_count, 2) - self.assertEqual(ticket_instance, "ticket_instance") - self.assertEqual(ticket, fallback_ticket) + self.assertEqual(result, ticket) @patch("builtins.print") def test_user_rejects_detected_ticket_and_enters_new_ticket(self, mock_print): - self.ticket_service.match_branch.return_value = ("ABC", "123") - self.ticket_service.prompt_ticket.return_value = ("DEF", "456") - - detected_ticket = MagicMock(id=1, url="http://detected-ticket", title="Detected") - detected_ticket.to_terminal.return_value = "detected ticket" - - manual_ticket = MagicMock(id=2, url="http://manual-ticket", title="Manual") - manual_ticket.to_terminal.return_value = "manual ticket" - - self.ticket_service.resolve.side_effect = [ - ("detected_instance", detected_ticket), - ("manual_instance", manual_ticket), - ] + detected_ticket = Mock(spec=Ticket) + self.ticket_service.get_ticket_from_branch.return_value = detected_ticket + manual_ticket = Mock(spec=Ticket) + self.ticket_service.prompt_ticket.return_value = manual_ticket # Use detected ticket? no. Use manual ticket? yes. self.prompter.yn.side_effect = [False, True] - ticket_instance, ticket = self.review._get_ticket_and_instance() + ticket = self.ticket_updater._get_ticket() - self.assertEqual(ticket_instance, "manual_instance") self.assertEqual(ticket, manual_ticket) + self.ticket_service.get_ticket_from_branch.assert_called_once() self.ticket_service.prompt_ticket.assert_called_once() - self.assertEqual(self.ticket_service.resolve.call_count, 2) @patch("builtins.print") def test_run_git_failure_creates_url(self, mock_print): - self.ticket_service.match_branch.return_value = ("ABC", "123") + self.ticket_service.get_ticket_from_branch.return_value = Mock(spec=Ticket) self.git_service.get_git_review_data.return_value = None self.git_service.get_create_cr_url.return_value = "http://create" # Use this ticket? yes. Update ticket? no. self.prompter.yn.side_effect = [True, False] - self.review.run() + self.ticket_updater.run() self.git_service.get_create_cr_url.assert_called_once_with(self.repo_info) self.ticket_service.update.assert_not_called() @@ -135,7 +119,7 @@ def test_create_comment_data(self): cr = CodeReview(status="OPEN", url="http://cr") ticket = MagicMock(url="http://ticket", title="Ticket title") - result = self.review._create_comment_data(self.repo_info, ticket, cr, None) + result = self.ticket_updater._create_comment_data(self.repo_info, ticket, cr, None) self.assertEqual(result.ticket_url, "http://ticket") self.assertEqual(result.ticket_title, "Ticket title") @@ -145,7 +129,7 @@ def test_create_comment_data(self): def test_create_comment_data_no_cr(self): ticket = MagicMock(url="http://ticket", title="Ticket title") - result = self.review._create_comment_data( + result = self.ticket_updater._create_comment_data( self.repo_info, ticket, None, @@ -165,7 +149,7 @@ def test_generate_combined_terminal_comment(self): c2 = MagicMock() c2.to_terminal.return_value = "B" - result = self.review._generate_combined_terminal_comment([c1, c2]) + result = self.ticket_updater._generate_combined_terminal_comment([c1, c2]) self.assertEqual(result, f"A\n{'-'*100}\nB") @@ -176,7 +160,7 @@ def test_generate_combined_ticket_comment(self): c2 = MagicMock() c2.to_ticket.return_value = "B" - result = self.review._generate_combined_ticket_comment([c1, c2]) + result = self.ticket_updater._generate_combined_ticket_comment([c1, c2]) self.assertEqual(result, f"A\n{'-'*100}\nB") diff --git a/tests/tickets/test_ticket_service.py b/tests/tickets/test_ticket_service.py new file mode 100644 index 0000000..064e7e9 --- /dev/null +++ b/tests/tickets/test_ticket_service.py @@ -0,0 +1,100 @@ +import unittest +from unittest.mock import Mock, MagicMock, patch + +from sc.services.tickets import Ticket, TicketService +from sc.services.tickets.exceptions import TicketIdentifierNotFound + +class TestTicketService(unittest.TestCase): + def setUp(self): + self.config = MagicMock() + self.factory = MagicMock() + self.prompter = MagicMock() + + self.service = TicketService( + config=self.config, + factory=self.factory, + prompter=self.prompter + ) + + def test_get_ticket(self): + cfg = MagicMock( + provider="prov", + url="url", + api_key="key", + auth_type="config", + username="user", + cert="cert", + project_prefix="ABC-" + ) + self.config.get.return_value = cfg + + mock_instance = MagicMock() + self.factory.create.return_value = mock_instance + + mock_ticket = MagicMock(id="ABC-123") + mock_instance.read_ticket.return_value = mock_ticket + + ticket = self.service.get_ticket("jira", 123) + + self.factory.create.assert_called_once() + mock_instance.read_ticket.assert_called_once_with("ABC-123") + self.assertEqual(ticket, mock_ticket) + + def test_get_ticket_from_branch_success(self): + self.config.get_identifiers.return_value = ["ABC"] + expected_ticket = Mock(spec=Ticket) + + with patch.object(self.service, "get_ticket", return_value=expected_ticket) as get_ticket: + result = self.service.get_ticket_from_branch("feature/ABC-123") + + get_ticket.assert_called_once_with("ABC", "123") + self.assertIs(result, expected_ticket) + + def test_get_ticket_from_branch_failure(self): + self.config.get_identifiers.return_value = ["ABC"] + + with self.assertRaises(TicketIdentifierNotFound): + self.service.get_ticket_from_branch("feature/no-match") + + def test_prompt_ticket(self): + conf = Mock() + conf.url = "https://tickets.example.com" + conf.description = "Main tickets" + + self.config.get_config.return_value = {"ABC": conf} + self.prompter.ask.side_effect = ["ABC", "123"] + + expected_ticket = Mock(spec=Ticket) + + with patch.object(self.service, "get_ticket", return_value=expected_ticket) as get_ticket: + result = self.service.prompt_ticket() + + self.config.get_config.assert_called_once_with() + self.prompter.ask.assert_any_call("Prefix") + self.prompter.ask.assert_any_call("Ticket number") + get_ticket.assert_called_once_with("ABC", "123") + self.assertEqual(result, expected_ticket) + + def test_prompt_ticket_reprompts_until_valid_prefix(self): + conf = Mock() + conf.url = "https://tickets.example.com" + conf.description = "Main tickets" + + self.config.get_config.return_value = {"ABC": conf} + self.prompter.ask.side_effect = ["BAD", "ABC", "123"] + + expected_ticket = Mock(spec=Ticket) + + with patch.object(self.service, "get_ticket", return_value=expected_ticket) as get_ticket: + result = self.service.prompt_ticket() + + self.assertEqual( + self.prompter.ask.call_args_list, + [ + (("Prefix",),), + (("Prefix",),), + (("Ticket number",),), + ], + ) + get_ticket.assert_called_once_with("ABC", "123") + self.assertEqual(result, expected_ticket)