diff --git a/run_tests.py b/run_tests.py index 100ce16..016dca2 100755 --- a/run_tests.py +++ b/run_tests.py @@ -1,8 +1,12 @@ #!/usr/bin/env python3 +import sys import unittest if __name__ == "__main__": loader = unittest.TestLoader() - suite = loader.discover("tests") + + test_dir = sys.argv[1] if len(sys.argv) > 1 else "tests" + + suite = loader.discover(test_dir) runner = unittest.TextTestRunner() runner.run(suite) \ No newline at end of file diff --git a/src/sc/review/code_review.py b/src/sc/review/git_flow_branch_strategy.py similarity index 58% rename from src/sc/review/code_review.py rename to src/sc/review/git_flow_branch_strategy.py index fe38fac..3a0cc3c 100644 --- a/src/sc/review/code_review.py +++ b/src/sc/review/git_flow_branch_strategy.py @@ -11,19 +11,14 @@ # 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 pathlib import Path -from dataclasses import dataclass -from enum import Enum +from git_flow_library import GitFlowLibrary -class CRStatus(str, Enum): - OPEN = "Open" - CLOSED = "Closed" - MERGED = "Merged" - - def __str__(self): - return self.value - -@dataclass -class CodeReview: - url: str - status: CRStatus \ No newline at end of file +class GitFlowBranchStrategy: + def get_target_branch(self, directory: Path, source_branch: str) -> str: + try: + base = GitFlowLibrary.get_branch_base(source_branch, directory) + return base if base else GitFlowLibrary.get_develop_branch(directory) + except RuntimeError: + return "develop" diff --git a/src/sc/review/git_host_service.py b/src/sc/review/git_host_service.py new file mode 100644 index 0000000..4b57871 --- /dev/null +++ b/src/sc/review/git_host_service.py @@ -0,0 +1,74 @@ +# 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 collections.abc import Iterable + +from .models import CodeReview +from .exceptions import RemoteUrlNotFound +from .git_flow_branch_strategy import GitFlowBranchStrategy +from .git_instances import GitFactory, GitInstance +from .models import RepoInfo +from .review_config import GitHostConfig + +class GitHostService: + def __init__( + self, + git_config: GitHostConfig | None = None, + factory: GitFactory | None = None, + branch_strategy: GitFlowBranchStrategy | None = None + ): + self._git_config = git_config or GitHostConfig() + self._factory = factory or GitFactory() + self._branch_strategy = branch_strategy or GitFlowBranchStrategy() + + def get_git_review_data(self, repo_info: RepoInfo) -> CodeReview | None: + git_instance = self._create_git_instance(repo_info.remote_url) + return git_instance.get_code_review(repo_info.repo_slug, repo_info.branch) + + def get_create_cr_url( + self, + repo_info: RepoInfo, + ) -> str: + git_instance = self._create_git_instance(repo_info.remote_url) + target_branch = self._branch_strategy.get_target_branch( + repo_info.directory, repo_info.branch) + return git_instance.get_create_cr_url(repo_info.repo_slug, repo_info.branch, target_branch) + + def _create_git_instance(self, remote_url: str) -> GitInstance: + remote_pattern = _match_remote_pattern( + remote_url, self._git_config.get_patterns()) + git_data = self._git_config.get(remote_pattern) + return self._factory.create( + git_data.provider, + token=git_data.token, + base_url=git_data.url + ) + +def _match_remote_pattern(remote_url: str, url_patterns: Iterable[str]) -> str: + """Match the remote url to a pattern in the git instance config. + + Args: + remote_url (str): The remote url of the git repository. + url_patterns (Iterable[str]): An iterable of patterns to check against. + + Raises: + RemoteUrlNotFound: Raised when the remote url matches no patterns. + + Returns: + str: The matched pattern. + """ + for pattern in url_patterns: + if pattern in remote_url: + return pattern + raise RemoteUrlNotFound(f"{remote_url} doesn't match any patterns! \n" + f"Remote patterns found: {', '.join(url_patterns)}") diff --git a/src/sc/review/git_instances/git_factory.py b/src/sc/review/git_instances/git_factory.py index ab7cbbd..84fb39b 100644 --- a/src/sc/review/git_instances/git_factory.py +++ b/src/sc/review/git_instances/git_factory.py @@ -11,6 +11,7 @@ # 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 cache from .instances import GithubInstance, GitlabInstance from .git_instance import GitInstance @@ -21,13 +22,9 @@ class GitFactory: "gitlab": GitlabInstance } - @classmethod - def types(cls) -> list[str]: - return list(cls._registry.keys()) - - @classmethod - def create(cls, name: str, token: str, base_url: str | None) -> GitInstance: + @cache + def create(self, name: str, token: str, base_url: str | None) -> GitInstance: try: - return cls._registry[name.lower()](token=token, base_url=base_url) + return self._registry[name.lower()](token=token, base_url=base_url) except KeyError: raise ValueError(f"Provider name {name} doesn't match any VCS instance!") diff --git a/src/sc/review/git_instances/git_instance.py b/src/sc/review/git_instances/git_instance.py index a30551c..53d9142 100644 --- a/src/sc/review/git_instances/git_instance.py +++ b/src/sc/review/git_instances/git_instance.py @@ -14,7 +14,7 @@ from abc import ABC, abstractmethod -from ..code_review import CodeReview +from ..models import CodeReview class GitInstance(ABC): def __init__(self, token: str, base_url: str | None): @@ -35,7 +35,7 @@ def validate_connection(self) -> bool: pass @abstractmethod - def get_code_review(self, repo: str, source_branch: str) -> CodeReview: + def get_code_review(self, repo: str, source_branch: str) -> CodeReview | None: """Get information about a branches code review. Args: @@ -43,7 +43,8 @@ def get_code_review(self, repo: str, source_branch: str) -> CodeReview: source_branch (str): The branch the code review is made from. Returns: - CodeReview: dataclass with information about the code review. + CodeReview | None: dataclass with information about the code review or None + if not found. """ pass diff --git a/src/sc/review/git_instances/instances/github_instance.py b/src/sc/review/git_instances/instances/github_instance.py index d909770..eddb53f 100644 --- a/src/sc/review/git_instances/instances/github_instance.py +++ b/src/sc/review/git_instances/instances/github_instance.py @@ -14,7 +14,7 @@ import requests -from sc.review.code_review import CRStatus, CodeReview +from sc.review.models import CRStatus, CodeReview from ..git_instance import GitInstance class GithubInstance(GitInstance): @@ -77,6 +77,7 @@ def get_code_review(self, repo: str, source_branch: str) -> CodeReview | None: if not prs: return None + pr = prs[0] # GitHub marks merged PRs as state="closed", merged=True if pr.get("merged"): diff --git a/src/sc/review/git_instances/instances/gitlab_instance.py b/src/sc/review/git_instances/instances/gitlab_instance.py index c414a9f..4a9609f 100644 --- a/src/sc/review/git_instances/instances/gitlab_instance.py +++ b/src/sc/review/git_instances/instances/gitlab_instance.py @@ -15,7 +15,7 @@ import requests import urllib.parse -from sc.review.code_review import CodeReview, CRStatus +from sc.review.models import CodeReview, CRStatus from ..git_instance import GitInstance class GitlabInstance(GitInstance): @@ -47,7 +47,7 @@ def validate_connection(self) -> bool: raise ConnectionError( f"Network connection to GitLab failed for {self.base_url}") from e - def get_code_review(self, repo: str, source_branch: str) -> CodeReview: + def get_code_review(self, repo: str, source_branch: str) -> CodeReview | None: """Get information about a code review. Args: @@ -97,8 +97,8 @@ def get_create_cr_url( self, repo: str, source_branch: str, - target_branch: str="develop" - ): + target_branch: str = "develop" + ) -> str: params = { "merge_request[source_branch]": source_branch, "merge_request[target_branch]": target_branch, diff --git a/src/sc/review/main.py b/src/sc/review/main.py deleted file mode 100644 index 9110798..0000000 --- a/src/sc/review/main.py +++ /dev/null @@ -1,160 +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 getpass -import logging -from pathlib import Path -import sys - -from git_flow_library import GitFlowLibrary -from repo_library import RepoLibrary -from .exceptions import ReviewException -from .review import Review -from .review_config import ReviewConfig, TicketHostCfg, GitInstanceCfg -from .ticketing_instances import TicketingInstanceFactory -from .git_instances import GitFactory - -logger = logging.getLogger(__name__) - -def review(): - try: - if root := RepoLibrary.get_repo_root_dir(Path.cwd()): - Review(root.parent).run_repo_command() - elif root := GitFlowLibrary.get_git_root(Path.cwd()): - Review(root.parent).run_git_command() - else: - logger.error("Not in a repo project or git repository!") - sys.exit(1) - except (ReviewException, ConnectionError) as e: - logger.error(e) - sys.exit(1) - -def add_git_instance(): - logger.info("Enter Git provider from the list below: ") - logger.info("github") - logger.info("gitlab") - - provider = input("> ") - print("") - - if provider == "github": - url = "https://api.github.com" - logger.info("Enter a pattern to identify Git from remote url: ") - logger.info( - "E.G. github.com for all github instances or " - "github.com/org for a particular organisation") - pattern = input("> ") - print("") - elif provider == "gitlab": - logger.info( - "Enter the URL for the gitlab instance (e.g. https://gitlab.com " - "or https://your-instance.com): ") - url = input("> ") - print("") - pattern = url.replace("https://", "").replace("http://", "") - else: - logger.error("Provider matches none in the list!") - sys.exit(1) - - logger.info("Enter your api token: ") - api_key = getpass.getpass("> ") - print("") - - instance = GitFactory.create(provider, api_key, url) - - try: - instance.validate_connection() - except ConnectionError as e: - logger.error(f"Failed to connect! {e}") - sys.exit(1) - - logger.info("Connection validated!") - - git_cfg = GitInstanceCfg(url=url, token=api_key, provider=provider) - ReviewConfig().write_git_data(pattern, git_cfg) - - logger.info("Git Provider Added!") - -def add_ticketing_instance(): - logger.info("Enter the ticketing provider from the list below: ") - logger.info("jira") - logger.info("redmine") - provider = input("> ") - print("") - - if provider not in ("jira", "redmine"): - logger.error(f"Provider {provider} not supported!") - sys.exit(1) - - logger.info("Enter the branch prefix (e.g ABC for feature/ABC-123_ticket): ") - branch_prefix = input("> ") - print("") - - username = None - if provider == "jira": - project_prefix = f"{branch_prefix}-" - - logger.info("Auth type:") - logger.info("token") - logger.info("basic") - auth_type = input("> ") - print("") - - if auth_type not in ("token", "basic"): - logger.error(f"Auth type {auth_type} not supported!") - sys.exit(1) - - if auth_type == "basic": - logger.info("Username:") - username = input("> ") - print("") - - else: - project_prefix = None - auth_type = "token" - - logger.info("Enter the base URL: ") - base_url = input("> ") - print("") - - logger.info("API token or password: ") - api_token = getpass.getpass("> ") - print("") - - try: - TicketingInstanceFactory.create( - provider=provider, - url=base_url, - token=api_token, - auth_type=auth_type, - username=username - ) - except ConnectionError as e: - logger.error(f"Failed to connect! {e}") - sys.exit(1) - - logger.info("Connection successful!") - - ticket_cfg = TicketHostCfg( - url=base_url, - provider=provider, - api_key=api_token, - username=username, - auth_type=auth_type, - project_prefix=project_prefix - ) - - ReviewConfig().write_ticketing_data(branch_prefix, ticket_cfg) - - logger.info("Added ticketing instance!") diff --git a/src/sc/review/models.py b/src/sc/review/models.py new file mode 100644 index 0000000..423a521 --- /dev/null +++ b/src/sc/review/models.py @@ -0,0 +1,150 @@ +# 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 datetime import datetime +from enum import Enum +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 + +class CRStatus(str, Enum): + OPEN = "Open" + CLOSED = "Closed" + MERGED = "Merged" + + def __str__(self): + return self.value + +@dataclass +class CodeReview: + url: str + status: CRStatus + +@dataclass +class RepoInfo: + branch: str + directory: str | Path + remote_url: str + commit_sha: str + commit_author: str + commit_date: datetime + commit_message: str + + @property + def repo_slug(self) -> str: + """Return the repository slug (e.g. "org/repo") from a remote url.""" + if self.remote_url.startswith("git@"): + slug = self.remote_url.split(":", 1)[1] + else: + slug = parse.urlparse(self.remote_url).path.lstrip("/") + + slug = slug.strip("/") + + if slug.endswith(".git"): + slug = slug[:-4] + + return slug + +@dataclass +class CommentData: + branch: str + directory: str | Path + remote_url: str + ticket_url: str + review_status: str + review_url: str | None + create_cr_url: str | None + commit_sha: str + commit_author: str + commit_date: datetime + commit_message: str + + def to_terminal(self) -> str: + """Generate the information for one repo to be displayed in the terminal. + + Returns: + str: Information from one repo to be displayed in the terminal. + """ + def c(code, text): + return f"\033[{code}m{text}\033[0m" + + header = [ + f"Branch: [{self.branch}]", + f"Directory: [{self.directory}]", + f"Git: [{self.remote_url}]", + ] + + ticket_link = f"Ticket: [{c('34', self.ticket_url)}]" + if self.review_url: + review_status = f"Review Status: [{c('32', self.review_status)}]" + review_link = f"Review URL: [{c('32', self.review_url)}]" + else: + review_status = f"Review Status: [{c('31', self.review_status)}]" + review_link = f"Create Review URL: [{c('33', self.create_cr_url)}]" + + review = [ticket_link, review_status, review_link] + + commit = ( + f"Last Commit: [{self.commit_sha}]", + f"Author: [{self.commit_author}]", + f"Date: [{self.commit_date}]", + "", + f"{self.commit_message}" + ) + + return "\n".join([*header, "", *review, "", *commit]) + + def to_ticket(self) -> str: + """Generate the information for one repo formatted for a ticket comment. + + Returns: + str: A formatted ticket comment. + """ + header = [ + f"Branch: [{self.branch}]", + f"Directory: [{self.directory}]", + f"Git: [{self.remote_url}]", + ] + + ticket_link = f"Ticket: [{self.ticket_url}]" + if self.review_url: + review_status = f"Review Status: [{self.review_status}]" + review_link = f"Review URL: [{self.review_url}]" + else: + review_status = f"Review Status: [{self.review_status}]" + review_link = f"Create Review URL: [{self.create_cr_url}]" + + review = [ticket_link, review_status, review_link] + + commit = ( + "
",
+ f"Last Commit: [{self.commit_sha}]",
+ f"Author: [{self.commit_author}]",
+ f"Date: [{self.commit_date}]",
+ "",
+ f"{self.commit_message}",
+ ""
+ )
+
+ return "\n".join([*header, "", *review, "", *commit])
diff --git a/src/sc/review/prompter.py b/src/sc/review/prompter.py
new file mode 100644
index 0000000..98f1195
--- /dev/null
+++ b/src/sc/review/prompter.py
@@ -0,0 +1,45 @@
+# 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/repo_source/__init__.py b/src/sc/review/repo_source/__init__.py
new file mode 100644
index 0000000..7f3869c
--- /dev/null
+++ b/src/sc/review/repo_source/__init__.py
@@ -0,0 +1,3 @@
+from .repo_source import RepoSource
+from .manifest_repo_source import ManifestRepoSource
+from .single_repo_source import SingleRepoSource
diff --git a/src/sc/review/repo_source/manifest_repo_source.py b/src/sc/review/repo_source/manifest_repo_source.py
new file mode 100644
index 0000000..5e9315c
--- /dev/null
+++ b/src/sc/review/repo_source/manifest_repo_source.py
@@ -0,0 +1,60 @@
+# 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 pathlib import Path
+
+from git import Repo
+from sc_manifest_parser import ScManifest
+
+from ..models import RepoInfo
+from .repo_source import RepoSource
+
+class ManifestRepoSource(RepoSource):
+ """Returns information about repos in a Repo workspace from its manifest."""
+ def __init__(self, top_dir: Path):
+ self._top_dir = top_dir
+ self.manifest_dir = top_dir / ".repo" / "manifests"
+
+ @property
+ def active_branch(self) -> str:
+ return Repo(self.manifest_dir).active_branch.name
+
+ def get_repos(self) -> list[RepoInfo]:
+ """Get info of all repos in a manifest that are tracked to a branch."""
+ repos = self._get_project_repos()
+ repos.append(self._get_repo_info(Repo(self.manifest_dir)))
+
+ return repos
+
+ def _get_project_repos(self) -> list[RepoInfo]:
+ """Get tracked project repos defined by the manifest."""
+ manifest = ScManifest.from_repo_root(self._top_dir / ".repo")
+ repos = []
+ for proj in manifest.projects:
+ proj_repo = Repo(self._top_dir / proj.path)
+ if self._should_include_project_repo(proj_repo):
+ repos.append(self._get_repo_info(proj_repo))
+
+ return repos
+
+ def _should_include_project_repo(self, proj_repo: Repo) -> bool:
+ if proj_repo.head.is_detached:
+ return False
+
+ if not proj_repo.active_branch.tracking_branch():
+ return False
+
+ return True
+
+
+
diff --git a/src/sc/review/repo_source/repo_source.py b/src/sc/review/repo_source/repo_source.py
new file mode 100644
index 0000000..03487a6
--- /dev/null
+++ b/src/sc/review/repo_source/repo_source.py
@@ -0,0 +1,42 @@
+# 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 abc import ABC, abstractmethod
+
+from git import Repo
+
+from ..models import RepoInfo
+
+class RepoSource(ABC):
+ """An interface for a source that provides a list of git repositories."""
+ @property
+ @abstractmethod
+ def active_branch(self) -> str:
+ pass
+
+ @abstractmethod
+ def get_repos(self) -> list[RepoInfo]:
+ pass
+
+ def _get_repo_info(self, repo: Repo) -> RepoInfo:
+ commit = repo.head.commit
+
+ return RepoInfo(
+ branch=repo.active_branch.name,
+ directory=repo.working_dir,
+ remote_url=repo.remotes[0].url,
+ commit_sha=commit.hexsha[:10],
+ commit_author=f"{commit.author.name} <{commit.author.email}>",
+ commit_date=commit.committed_datetime,
+ commit_message=commit.message.strip()
+ )
diff --git a/src/sc/review/ticket.py b/src/sc/review/repo_source/single_repo_source.py
similarity index 54%
rename from src/sc/review/ticket.py
rename to src/sc/review/repo_source/single_repo_source.py
index 36c3e47..ebc010b 100644
--- a/src/sc/review/ticket.py
+++ b/src/sc/review/repo_source/single_repo_source.py
@@ -11,15 +11,21 @@
# 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
-@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
+from git import Repo
+
+from ..models import RepoInfo
+from .repo_source import RepoSource
+
+class SingleRepoSource(RepoSource):
+ """Returns information about a singular git repo."""
+ def __init__(self, top_dir: Path):
+ self._top_dir = top_dir
+
+ @property
+ def active_branch(self) -> str:
+ return Repo(self._top_dir).active_branch.name
+
+ def get_repos(self) -> list[RepoInfo]:
+ return [self._get_repo_info(Repo(self._top_dir))]
diff --git a/src/sc/review/review.py b/src/sc/review/review.py
index 35f6779..ebc4514 100644
--- a/src/sc/review/review.py
+++ b/src/sc/review/review.py
@@ -12,352 +12,155 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from collections.abc import Iterable
-from dataclasses import dataclass
-from datetime import datetime
+import getpass
import logging
from pathlib import Path
-import re
-from urllib import parse
-
-from git import Repo
+import sys
+from .exceptions import ReviewException
from git_flow_library import GitFlowLibrary
-from sc_manifest_parser import ScManifest
-from .exceptions import RemoteUrlNotFound, TicketIdentifierNotFound
-from .review_config import ReviewConfig, TicketHostCfg
-from .ticketing_instances import TicketingInstance, TicketingInstanceFactory
-from .git_instances import GitFactory, GitInstance
+from repo_library import RepoLibrary
+from .repo_source import ManifestRepoSource, SingleRepoSource
+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__)
-@dataclass
-class CommentData:
- branch: str
- directory: str | Path
- remote_url: str
- review_status: str
- review_url: str | None
- create_pr_url: str
- commit_sha: str
- commit_author: str
- commit_date: datetime
- commit_message: str
-
-
-class Review:
- def __init__(self, top_dir: Path | str):
- self.top_dir = Path(top_dir)
-
- self._config = ReviewConfig()
-
- def run_git_command(self):
- repo = Repo(self.top_dir)
- try:
- identifier, ticket_num = self._match_branch(repo.active_branch.name)
- except TicketIdentifierNotFound as e:
- logger.warning(e)
- identifier, ticket_num = self._prompt_ticket_selection()
-
- ticketing_cfg = self._config.get_ticket_host_data(identifier)
- ticketing_instance = self._create_ticketing_instance(ticketing_cfg)
-
- ticket_id = f"{ticketing_cfg.project_prefix or ''}{ticket_num}"
- ticket = ticketing_instance.read_ticket(ticket_id)
-
- git_instance = self._create_git_instance(repo.remote().url)
- comment_data = self._create_comment_data(repo, git_instance)
-
- logger.info(f"Ticket URL: [{ticket.url if ticket else 'None'}]")
- logger.info("Ticket info: \n")
- print(self._generate_terminal_comment(comment_data))
- print()
-
- if self._prompt_yn("Update ticket?"):
- ticket_comment = self._generate_ticket_comment(comment_data)
- ticketing_instance.add_comment_to_ticket(ticket_id, ticket_comment)
-
- def run_repo_command(self):
- logger.info("Show check ins across all repos. Note branch must be PUSHED.\n")
- manifest_repo = Repo(self.top_dir / '.repo' / 'manifests')
-
- try:
- identifier, ticket_num = self._match_branch(manifest_repo.active_branch.name)
- except TicketIdentifierNotFound as e:
- logger.warning(e)
- identifier, ticket_num = self._prompt_ticket_selection()
-
- ticketing_cfg = self._config.get_ticket_host_data(identifier)
- ticketing_instance = self._create_ticketing_instance(ticketing_cfg)
-
- ticket_id = f"{ticketing_cfg.project_prefix or ''}{ticket_num}"
- ticket = ticketing_instance.read_ticket(ticket_id)
-
- logger.info(f"Ticket URL: [{ticket.url if ticket else ''}]")
- logger.info("Ticket info: \n")
-
- manifest = ScManifest.from_repo_root(self.top_dir / '.repo')
- comments = []
- for project in manifest.projects:
- if project.lock_status:
- continue
-
- proj_repo = Repo(self.top_dir / project.path)
- # Don't generate for projects that haven't got an upstream
- if not proj_repo.active_branch.tracking_branch():
- continue
-
- proj_git = self._create_git_instance(proj_repo.remotes[project.remote].url)
- comment_data = self._create_comment_data(
- proj_repo, proj_git)
- comments.append(comment_data)
-
- manifest_git = self._create_git_instance(manifest_repo.remote().url)
- comment_data = self._create_comment_data(
- manifest_repo, manifest_git)
- comments.append(comment_data)
-
- print(self._generate_combined_terminal_comment(comments))
- print()
-
- if self._prompt_yn("Update tickets?"):
- ticket_comment = self._generate_combined_ticket_comment(comments)
- ticketing_instance.add_comment_to_ticket(ticket_id, ticket_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_ticket_host_identifiers()
- for identifier in host_identifiers:
- # Matches the identifier, followed by - or _, followed by a number
- if m := re.search(fr'{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 _create_git_instance(self, remote_url: str) -> GitInstance:
- git_url_patterns = self._config.get_git_patterns()
- try:
- remote_pattern = self._match_remote_url(
- remote_url, git_url_patterns)
- except RemoteUrlNotFound as e:
- raise RemoteUrlNotFound(
- str(e) + f"\nRemotes patterns found: {', '.join(git_url_patterns)}"
- )
- git_data = self._config.get_git_data(remote_pattern)
- return GitFactory.create(
- git_data.provider,
- token=git_data.token,
- base_url=git_data.url
+def update_ticket():
+ """Add commit/PR information to your ticket."""
+ if root := RepoLibrary.get_repo_root_dir(Path.cwd()):
+ repo_source = ManifestRepoSource(root.parent)
+ elif root := GitFlowLibrary.get_git_root(Path.cwd()):
+ repo_source = SingleRepoSource(root.parent)
+ else:
+ logger.error("Not in a repo project or git repository!")
+ sys.exit(1)
+
+ try:
+ TicketUpdater(repo_source).run()
+ except (ReviewException, ConnectionError) as e:
+ logger.error(e)
+ sys.exit(1)
+
+def add_git_instance():
+ """Add a VCS instance for sc review."""
+ logger.info("Enter Git provider from the list below: ")
+ logger.info("github")
+ logger.info("gitlab")
+
+ provider = input("> ")
+ print("")
+
+ if provider == "github":
+ url = "https://api.github.com"
+ logger.info("Enter a pattern to identify Git from remote url: ")
+ logger.info(
+ "E.G. github.com for all github instances or "
+ "github.com/org for a particular organisation")
+ pattern = input("> ")
+ print("")
+ elif provider == "gitlab":
+ logger.info(
+ "Enter the URL for the gitlab instance (e.g. https://gitlab.com "
+ "or https://your-instance.com): ")
+ url = input("> ")
+ print("")
+ pattern = url.replace("https://", "").replace("http://", "")
+ else:
+ logger.error("Provider matches none in the list!")
+ sys.exit(1)
+
+ logger.info("Enter your api token: ")
+ api_key = getpass.getpass("> ")
+ print("")
+
+ instance = GitFactory().create(provider, api_key, url)
+
+ try:
+ instance.validate_connection()
+ except ConnectionError as e:
+ logger.error(f"Failed to connect! {e}")
+ sys.exit(1)
+
+ logger.info("Connection validated!")
+
+ git_cfg = GitHostModel(url=url, token=api_key, provider=provider)
+ GitHostConfig().write(pattern, git_cfg)
+
+ logger.info("Git Provider Added!")
+
+def add_ticketing_instance():
+ """Add a ticketing instance for sc review."""
+ logger.info("Enter the ticketing provider from the list below: ")
+ logger.info("jira")
+ logger.info("redmine")
+ provider = input("> ")
+ print("")
+
+ if provider not in ("jira", "redmine"):
+ logger.error(f"Provider {provider} not supported!")
+ sys.exit(1)
+
+ logger.info("Enter the branch prefix (e.g ABC for feature/ABC-123_ticket): ")
+ branch_prefix = input("> ")
+ print("")
+
+ username = None
+ if provider == "jira":
+ project_prefix = f"{branch_prefix}-"
+
+ logger.info("Auth type:")
+ logger.info("token")
+ logger.info("basic")
+ auth_type = input("> ")
+ print("")
+
+ if auth_type not in ("token", "basic"):
+ logger.error(f"Auth type {auth_type} not supported!")
+ sys.exit(1)
+
+ if auth_type == "basic":
+ logger.info("Username:")
+ username = input("> ")
+ print("")
+
+ else:
+ project_prefix = None
+ auth_type = "token"
+
+ logger.info("Enter the base URL: ")
+ base_url = input("> ")
+ print("")
+
+ logger.info("API token or password: ")
+ api_token = getpass.getpass("> ")
+ print("")
+
+ try:
+ TicketingInstanceFactory.create(
+ provider=provider,
+ url=base_url,
+ token=api_token,
+ auth_type=auth_type,
+ username=username
)
+ except ConnectionError as e:
+ logger.error(f"Failed to connect! {e}")
+ sys.exit(1)
- def _match_remote_url(
- self,
- remote_url: str,
- git_patterns: Iterable[str]
- ) -> str:
- """Match the remote url to a pattern in the git instance config.
-
- Args:
- remote_url (str): The remote url of the git repository.
- git_patterns (Iterable[str]): An iterable of patterns to check against.
-
- Raises:
- RemoteUrlNotFound: Raised when the remote url matches no patterns.
-
- Returns:
- str: The matched pattern.
- """
- for pattern in git_patterns:
- if pattern in remote_url:
- return pattern
- raise RemoteUrlNotFound(f"{remote_url} doesn't match any patterns!")
-
- def _get_repo_slug(self, remote_url: str) -> str:
- """Return the repository slug (e.g. "org/repo") from a remote url."""
- if remote_url.startswith("git@"):
- slug = remote_url.split(":", 1)[1]
- else:
- slug = parse.urlparse(remote_url).path.lstrip("/")
-
- if slug.endswith(".git"):
- slug = slug[:-4]
-
- return slug
-
- def _get_target_branch(self, directory: Path, source_branch: str) -> str:
- if GitFlowLibrary.is_gitflow_enabled(directory):
- base = GitFlowLibrary.get_branch_base(source_branch, directory)
- return base if base else GitFlowLibrary.get_develop_branch(directory)
- else:
- return "develop"
-
- def _prompt_yn(self, msg: str) -> bool:
- return input(f"{msg} (y/n): ").strip().lower() == 'y'
+ logger.info("Connection successful!")
- def _create_comment_data(self, repo: Repo, git_instance: GitInstance) -> CommentData:
- branch_name = repo.active_branch.name
- repo_slug = self._get_repo_slug(repo.remotes[0].url)
- cr = git_instance.get_code_review(repo_slug, branch_name)
+ ticket_cfg = TicketHostModel(
+ url=base_url,
+ provider=provider,
+ api_key=api_token,
+ username=username,
+ auth_type=auth_type,
+ project_prefix=project_prefix
+ )
- target_branch = self._get_target_branch(repo.working_dir, branch_name)
- create_pr_url = git_instance.get_create_cr_url(
- repo_slug, branch_name, target_branch)
-
- commit = repo.head.commit
-
- review_status = str(cr.status) if cr else "Not Created"
- review_url = cr.url if cr else None
-
- return CommentData(
- branch=branch_name,
- directory=repo.working_dir,
- remote_url=repo.remotes[0].url,
- review_status=review_status,
- review_url=review_url,
- create_pr_url=create_pr_url,
- commit_sha=commit.hexsha[:10],
- commit_author=f"{commit.author.name} <{commit.author.email}>",
- commit_date=commit.committed_datetime,
- commit_message=commit.message.strip()
- )
-
- def _create_ticketing_instance(self, cfg: TicketHostCfg) -> TicketingInstance:
- """Create a ticketing instance.
-
- Args:
- cfg (TicketHostCfg): Config describing a ticketing instance.
-
- Raises:
- ConnectionError: Failed to connect to ticketing instance.
-
- Returns:
- TicketingInstance: A ticketing instance class.
- """
- inst = TicketingInstanceFactory.create(
- provider=cfg.provider,
- url=cfg.url,
- token=cfg.api_key,
- auth_type=cfg.auth_type,
- username=cfg.username,
- cert=cfg.cert
- )
- return inst
-
- def _prompt_ticket_selection(self) -> tuple[str, str]:
- """Prompt the user to select a ticketing instance and enter a ticket number.
-
- Raises:
- TicketIdentifierNotFound: If the instance identifier doesn't match any
- in the config.
-
- Returns:
- tuple[str, str]: The selected ticketing instance identifier and ticket
- number.
- """
- ticket_conf = self._config.get_ticketing_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 ''}")
-
- 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
-
- def _generate_combined_terminal_comment(self, comments: list[CommentData]) -> str:
- return "\n\n".join(self._generate_terminal_comment(c) for c in comments)
-
- def _generate_combined_ticket_comment(self, comments: list[CommentData]) -> str:
- return "\n\n".join(self._generate_ticket_comment(c) for c in comments)
-
- def _generate_terminal_comment(self, data: CommentData) -> str:
- """Generate the information for one repo to be displayed in the terminal.
-
- Args:
- data (CommentData): The data collated from one repo.
-
- Returns:
- str: Information from one repo to be displayed in the terminal.
- """
- def c(code, text):
- return f"\033[{code}m{text}\033[0m"
-
- header = [
- f"Branch: [{data.branch}]",
- f"Directory: [{data.directory}]",
- f"Git: [{data.remote_url}]",
- ]
-
- if data.review_url:
- review_status = f"Review Status: [{c('32', data.review_status)}]"
- review_link = f"Review URL: [{c('32', data.review_url)}]"
- else:
- review_status = f"Review Status: [{c('31', data.review_status)}]"
- review_link = f"Create Review URL: [{c('33', data.create_pr_url)}]"
-
- review = [review_status, review_link]
-
- commit = (
- f"Last Commit: [{data.commit_sha}]",
- f"Author: [{data.commit_author}]",
- f"Date: [{data.commit_date}]",
- "",
- f"{data.commit_message}"
- )
-
- return "\n".join([*header, "", *review, "", *commit])
-
- def _generate_ticket_comment(self, data: CommentData) -> str:
- """Generate the information for one repo formatted for a ticket comment.
-
- Args:
- data (CommentData): The data collated for one repo.
-
- Returns:
- str: A formatted ticket comment.
- """
- header = [
- f"Branch: [{data.branch}]",
- f"Directory: [{data.directory}]",
- f"Git: [{data.remote_url}]",
- ]
-
- if data.review_url:
- review_status = f"Review Status: [{data.review_status}]"
- review_link = f"Review URL: [{data.review_url}]"
- else:
- review_status = f"Review Status: [{data.review_status}]"
- review_link = f"Create Review URL: [{data.create_pr_url}]"
-
- review = [review_status, review_link]
-
- commit = (
- "",
- f"Last Commit: [{data.commit_sha}]",
- f"Author: [{data.commit_author}]",
- f"Date: [{data.commit_date}]",
- "",
- f"{data.commit_message}",
- ""
- )
+ TicketHostConfig().write(branch_prefix, ticket_cfg)
- return "\n".join([*header, "", *review, "", *commit])
+ logger.info("Added ticketing instance!")
diff --git a/src/sc/review/review_config.py b/src/sc/review/review_config.py
index 1ca3826..5e841a3 100644
--- a/src/sc/review/review_config.py
+++ b/src/sc/review/review_config.py
@@ -19,7 +19,7 @@
from .exceptions import ConfigError
from sc.config_manager import ConfigManager
-class TicketHostCfg(BaseModel):
+class TicketHostModel(BaseModel):
model_config = ConfigDict(extra='forbid')
url: str
@@ -31,56 +31,59 @@ class TicketHostCfg(BaseModel):
description: str | None = None
cert: str | None = None
-class GitInstanceCfg(BaseModel):
- model_config = ConfigDict(extra='forbid')
-
- url: str | None = None
- token: str
- provider: str
-
-class ReviewConfig:
+class TicketHostConfig:
def __init__(self):
self._ticket_config = ConfigManager('ticketing_instances')
- self._git_config = ConfigManager('git_instances')
- def get_ticketing_config(self) -> dict[str, TicketHostCfg]:
+ def get_config(self) -> dict[str, TicketHostModel]:
"""Return all ticketing instance configs keyed by identifier."""
- return {k: TicketHostCfg(**v) for k,v in self._ticket_config.get_config().items()}
+ return {k: TicketHostModel(**v) for k,v in self._ticket_config.get_config().items()}
- def get_ticket_host_identifiers(self) -> set[str]:
+ def get_identifiers(self) -> set[str]:
"""Return all configured ticketing instance identifiers."""
- return self._ticket_config.get_config().keys()
+ return set(self._ticket_config.get_config().keys())
- def get_ticket_host_data(self, identifier: str) -> TicketHostCfg:
+ 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 TicketHostCfg(**data)
+ return TicketHostModel(**data)
except ValidationError as e:
raise ConfigError(f"Invalid config for ticketing instance {identifier}: {e}")
- def write_ticketing_data(self, branch_prefix: str, ticket_data: TicketHostCfg):
+ 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)})
- def get_git_patterns(self) -> set[str]:
+class GitHostModel(BaseModel):
+ model_config = ConfigDict(extra='forbid')
+
+ url: str | None = None
+ token: str
+ provider: str
+
+class GitHostConfig:
+ def __init__(self):
+ self._git_config = ConfigManager('git_instances')
+
+ def get_patterns(self) -> set[str]:
"""Return all configured git URL patterns."""
return self._git_config.get_config().keys()
- def get_git_data(self, url_pattern: str) -> GitInstanceCfg:
+ def get(self, url_pattern: str) -> GitHostModel:
"""Return the git config for a specific URL pattern."""
data = self._git_config.get_config().get(url_pattern)
if not data:
raise ConfigError(f"Git config doesn't contain entry for {url_pattern}")
try:
- return GitInstanceCfg(**data)
+ return GitHostModel(**data)
except ValidationError as e:
raise ConfigError(f"Invalid config for git instance {url_pattern}: {e}")
- def write_git_data(self, pattern: str, git_config: GitInstanceCfg):
+ def write(self, pattern: str, git_config: GitHostModel):
"""Persist the config for a specific git host."""
self._git_config.update_config({pattern: git_config.model_dump(exclude_none=True)})
diff --git a/src/sc/review/ticket_service.py b/src/sc/review/ticket_service.py
new file mode 100644
index 0000000..8a6964c
--- /dev/null
+++ b/src/sc/review/ticket_service.py
@@ -0,0 +1,79 @@
+# 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
new file mode 100644
index 0000000..0499d72
--- /dev/null
+++ b/src/sc/review/ticket_updater.py
@@ -0,0 +1,95 @@
+# 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 .exceptions import TicketIdentifierNotFound
+from .git_host_service import GitHostService
+from .models import CodeReview, CommentData, RepoInfo, Ticket
+from .prompter import Prompter
+from .repo_source import RepoSource
+from .ticket_service import TicketService
+
+logger = logging.getLogger(__name__)
+
+class TicketUpdater:
+ def __init__(
+ self,
+ repo_source: RepoSource,
+ ticket_service: TicketService | None = None,
+ git_service: GitHostService | None = None,
+ prompter: Prompter | None = None
+ ):
+ self.repo_source = repo_source
+ self._ticket_service = ticket_service or TicketService()
+ self._git_service = git_service or GitHostService()
+ self._prompter = prompter or Prompter()
+
+ def run(self):
+ try:
+ identifier, ticket_num = self._ticket_service.match_branch(
+ self.repo_source.active_branch)
+ except TicketIdentifierNotFound as e:
+ logger.warning(e)
+ identifier, ticket_num = self._ticket_service.prompt_ticket()
+
+ ticket_instance, ticket = self._ticket_service.resolve(identifier, ticket_num)
+
+ comments = []
+ for repo_info in self.repo_source.get_repos():
+ create_cr_url = None
+ cr = self._git_service.get_git_review_data(repo_info)
+ if not cr:
+ create_cr_url = self._git_service.get_create_cr_url(repo_info)
+
+ comments.append(self._create_comment_data(repo_info, ticket, cr, create_cr_url))
+
+ logger.info(f"Ticket URL: [{ticket.url if ticket else 'None'}]")
+ logger.info("Ticket info: \n")
+ print(self._generate_combined_terminal_comment(comments))
+ print()
+
+ if self._prompter.yn("Update ticket?"):
+ ticket_comment = self._generate_combined_ticket_comment(comments)
+ self._ticket_service.update(ticket_instance, ticket, ticket_comment)
+
+ def _create_comment_data(
+ self,
+ repo_info: RepoInfo,
+ ticket: Ticket,
+ cr: CodeReview | None,
+ create_cr_url: str | None) -> CommentData:
+ review_status = str(cr.status) if cr else "Not Created"
+ review_url = cr.url if cr else None
+
+ return CommentData(
+ branch=repo_info.branch,
+ directory=repo_info.directory,
+ remote_url=repo_info.remote_url,
+ ticket_url=ticket.url,
+ review_status=review_status,
+ review_url=review_url,
+ create_cr_url=create_cr_url,
+ commit_sha=repo_info.commit_sha,
+ commit_author=repo_info.commit_author,
+ commit_date=repo_info.commit_date,
+ commit_message=repo_info.commit_message
+ )
+
+ def _generate_combined_terminal_comment(self, comments: list[CommentData]) -> str:
+ return f"\n{'-'*100}\n".join(c.to_terminal() for c in comments)
+
+ def _generate_combined_ticket_comment(self, comments: list[CommentData]) -> str:
+ return f"\n{'-'*100}\n".join(c.to_ticket() for c in comments)
+
diff --git a/src/sc/review/ticketing_instances/instances/jira_instance.py b/src/sc/review/ticketing_instances/instances/jira_instance.py
index cd00fd1..4695a22 100644
--- a/src/sc/review/ticketing_instances/instances/jira_instance.py
+++ b/src/sc/review/ticketing_instances/instances/jira_instance.py
@@ -19,7 +19,7 @@
from jira.exceptions import JIRAError
from sc.review.exceptions import PermissionsError, TicketNotFound
-from sc.review.ticket import Ticket
+from sc.review.models import Ticket
from .. import TicketingInstance
class JiraInstance(TicketingInstance):
diff --git a/src/sc/review/ticketing_instances/instances/redmine_instance.py b/src/sc/review/ticketing_instances/instances/redmine_instance.py
index 6f1f689..78bd9e9 100644
--- a/src/sc/review/ticketing_instances/instances/redmine_instance.py
+++ b/src/sc/review/ticketing_instances/instances/redmine_instance.py
@@ -19,7 +19,7 @@
from requests.exceptions import RequestException, SSLError
from sc.review.exceptions import PermissionsError, TicketingInstanceUnreachable, TicketNotFound
-from sc.review.ticket import Ticket
+from sc.review.models import Ticket
from .. import TicketingInstance
class RedmineInstance(TicketingInstance):
diff --git a/src/sc/review/ticketing_instances/ticketing_instance.py b/src/sc/review/ticketing_instances/ticketing_instance.py
index 0dd5e12..d0df9a9 100644
--- a/src/sc/review/ticketing_instances/ticketing_instance.py
+++ b/src/sc/review/ticketing_instances/ticketing_instance.py
@@ -14,7 +14,7 @@
from abc import ABC, abstractmethod
-from ..ticket import Ticket
+from ..models import Ticket
class TicketingInstance(ABC):
"""
diff --git a/src/sc/review_cli.py b/src/sc/review_cli.py
index 3035581..58725f5 100644
--- a/src/sc/review_cli.py
+++ b/src/sc/review_cli.py
@@ -14,23 +14,23 @@
import click
-from .review import main
+from .review import review
@click.group()
def cli():
pass
-@cli.command()
-def review():
+@cli.command(name="review")
+def update_ticket():
"""Add commit/PR information to your ticket."""
- main.review()
+ review.update_ticket()
@cli.command()
def add_git_instance():
"""Add a VCS instance for sc review."""
- main.add_git_instance()
+ review.add_git_instance()
@cli.command()
def add_ticketing_instance():
"""Add a ticketing instance for sc review."""
- main.add_ticketing_instance()
\ No newline at end of file
+ review.add_ticketing_instance()
\ No newline at end of file
diff --git a/tests/review/test_git_host_service.py b/tests/review/test_git_host_service.py
new file mode 100644
index 0000000..6a769b4
--- /dev/null
+++ b/tests/review/test_git_host_service.py
@@ -0,0 +1,103 @@
+import unittest
+from unittest.mock import MagicMock
+
+from sc.review.git_host_service import GitHostService, _match_remote_pattern
+from sc.review.exceptions import RemoteUrlNotFound
+
+
+class TestGitHostService(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_config = MagicMock()
+ self.mock_factory = MagicMock()
+ self.mock_strategy = MagicMock()
+
+ self.service = GitHostService(
+ git_config=self.mock_config,
+ factory=self.mock_factory,
+ branch_strategy=self.mock_strategy
+ )
+
+ def test_get_git_review_data(self):
+ repo_info = MagicMock(
+ remote_url="https://gitlab.com/org/repo",
+ repo_slug="org/repo",
+ branch="feature-1"
+ )
+
+ self.mock_config.get_patterns.return_value = ["gitlab.com"]
+ self.mock_config.get.return_value = MagicMock(
+ provider="gitlab",
+ token="token",
+ url="base"
+ )
+
+ mock_instance = MagicMock()
+ self.mock_factory.create.return_value = mock_instance
+ mock_instance.get_code_review.return_value = "review"
+
+ result = self.service.get_git_review_data(repo_info)
+
+ self.assertEqual(result, "review")
+ mock_instance.get_code_review.assert_called_once_with("org/repo", "feature-1")
+
+ def test_get_create_cr_url(self):
+ repo_info = MagicMock(
+ remote_url="https://github.com/org/repo",
+ repo_slug="org/repo",
+ branch="feature-1",
+ directory="/repo"
+ )
+
+ self.mock_config.get_patterns.return_value = ["github.com"]
+ self.mock_config.get.return_value = MagicMock(
+ provider="github",
+ token="token",
+ url="base"
+ )
+
+ mock_instance = MagicMock()
+ self.mock_factory.create.return_value = mock_instance
+
+ self.mock_strategy.get_target_branch.return_value = "main"
+ mock_instance.get_create_cr_url.return_value = "url"
+
+ result = self.service.get_create_cr_url(repo_info)
+
+ self.assertEqual(result, "url")
+ mock_instance.get_create_cr_url.assert_called_once_with(
+ "org/repo", "feature-1", "main"
+ )
+
+ def test_create_git_instance(self):
+ self.mock_config.get_patterns.return_value = ["github.com"]
+ git_data = MagicMock(provider="github", token="t", url="u")
+ self.mock_config.get.return_value = git_data
+
+ instance = MagicMock()
+ self.mock_factory.create.return_value = instance
+
+ result = self.service._create_git_instance("https://github.com/org/repo")
+
+ self.assertEqual(result, instance)
+ self.mock_factory.create.assert_called_once_with(
+ "github", token="t", base_url="u"
+ )
+
+ def test_match_remote_pattern_success(self):
+ result = _match_remote_pattern(
+ "https://github.com/org/repo",
+ ["gitlab.com", "github.com"]
+ )
+ self.assertEqual(result, "github.com")
+
+ def test_match_remote_pattern_failure(self):
+ with self.assertRaises(RemoteUrlNotFound):
+ _match_remote_pattern(
+ "https://bitbucket.org/org/repo",
+ ["gitlab.com", "github.com"]
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/review/test_manifest_repo_source.py b/tests/review/test_manifest_repo_source.py
new file mode 100644
index 0000000..9ca518b
--- /dev/null
+++ b/tests/review/test_manifest_repo_source.py
@@ -0,0 +1,84 @@
+import unittest
+from unittest.mock import MagicMock, patch
+from pathlib import Path
+
+from sc.review.repo_source.manifest_repo_source import ManifestRepoSource
+
+
+class TestManifestRepoSource(unittest.TestCase):
+ def setUp(self):
+ self.top_dir = Path("/fake/top")
+ self.source = ManifestRepoSource(self.top_dir)
+
+ @patch("sc.review.repo_source.manifest_repo_source.Repo")
+ def test_active_branch(self, mock_repo):
+ mock_repo.return_value.active_branch.name = "main"
+
+ branch = self.source.active_branch
+
+ self.assertEqual(branch, "main")
+ mock_repo.assert_called_with(self.source.manifest_dir)
+
+ @patch("sc.review.repo_source.manifest_repo_source.Repo")
+ def test_should_include_project_repo_true(self, mock_repo):
+ repo = MagicMock()
+ repo.head.is_detached = False
+ repo.active_branch.tracking_branch.return_value = "origin/main"
+
+ result = self.source._should_include_project_repo(repo)
+
+ self.assertTrue(result)
+
+ def test_should_include_project_repo_detached(self):
+ repo = MagicMock()
+ repo.head.is_detached = True
+
+ result = self.source._should_include_project_repo(repo)
+
+ self.assertFalse(result)
+
+ def test_should_include_project_repo_no_tracking(self):
+ repo = MagicMock()
+ repo.head.is_detached = False
+ repo.active_branch.tracking_branch.return_value = None
+
+ result = self.source._should_include_project_repo(repo)
+
+ self.assertFalse(result)
+
+ @patch("sc.review.repo_source.manifest_repo_source.ScManifest")
+ @patch("sc.review.repo_source.manifest_repo_source.Repo")
+ def test_get_project_repos_filters_correctly(self, mock_repo, mock_manifest):
+ # Mock manifest projects
+ proj1 = MagicMock(path="proj1")
+ proj2 = MagicMock(path="proj2")
+ mock_manifest.from_repo_root.return_value.projects = [proj1, proj2]
+
+ repo1 = MagicMock()
+ repo1.head.is_detached = False
+ repo1.active_branch.tracking_branch.return_value = "origin/main"
+
+ repo2 = MagicMock()
+ repo2.head.is_detached = True # excluded
+
+ mock_repo.side_effect = [repo1, repo2]
+
+ self.source._get_repo_info = MagicMock(side_effect=["info1"])
+
+ repos = self.source._get_project_repos()
+
+ self.assertEqual(repos, ["info1"])
+ self.source._get_repo_info.assert_called_once_with(repo1)
+
+ @patch("sc.review.repo_source.manifest_repo_source.Repo")
+ def test_get_repos_includes_manifest_repo(self, mock_repo):
+ mock_repo_instance = MagicMock()
+ mock_repo.return_value = mock_repo_instance
+
+ self.source._get_project_repos = MagicMock(return_value=["proj_info"])
+ self.source._get_repo_info = MagicMock(return_value="manifest_info")
+
+ repos = self.source.get_repos()
+
+ self.assertEqual(repos, ["proj_info", "manifest_info"])
+ self.source._get_repo_info.assert_called_with(mock_repo_instance)
diff --git a/tests/review/test_repo_info.py b/tests/review/test_repo_info.py
new file mode 100644
index 0000000..70be939
--- /dev/null
+++ b/tests/review/test_repo_info.py
@@ -0,0 +1,55 @@
+import unittest
+from datetime import datetime
+from pathlib import Path
+
+from sc.review.models import RepoInfo
+
+class TestRepoInfo(unittest.TestCase):
+ def setUp(self):
+ self.base_kwargs = dict(
+ branch="main",
+ directory=Path("."),
+ commit_sha="abc123",
+ commit_author="author",
+ commit_date=datetime.now(),
+ commit_message="msg",
+ )
+
+ def test_https_url(self):
+ repo = RepoInfo(
+ remote_url="https://github.com/org/repo.git",
+ **self.base_kwargs
+ )
+ self.assertEqual(repo.repo_slug, "org/repo")
+
+ def test_https_url_no_git_suffix(self):
+ repo = RepoInfo(
+ remote_url="https://github.com/org/repo",
+ **self.base_kwargs
+ )
+ self.assertEqual(repo.repo_slug, "org/repo")
+
+ def test_ssh_url(self):
+ repo = RepoInfo(
+ remote_url="git@github.com:org/repo.git",
+ **self.base_kwargs
+ )
+ self.assertEqual(repo.repo_slug, "org/repo")
+
+ def test_ssh_url_no_git_suffix(self):
+ repo = RepoInfo(
+ remote_url="git@github.com:org/repo",
+ **self.base_kwargs
+ )
+ self.assertEqual(repo.repo_slug, "org/repo")
+
+ def test_trailing_slash(self):
+ repo = RepoInfo(
+ remote_url="https://github.com/org/repo/",
+ **self.base_kwargs
+ )
+ self.assertEqual(repo.repo_slug, "org/repo")
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/review/test_ticket_service.py b/tests/review/test_ticket_service.py
new file mode 100644
index 0000000..0a90240
--- /dev/null
+++ b/tests/review/test_ticket_service.py
@@ -0,0 +1,73 @@
+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
new file mode 100644
index 0000000..7d0acc5
--- /dev/null
+++ b/tests/review/test_ticket_updater.py
@@ -0,0 +1,117 @@
+import unittest
+from unittest.mock import MagicMock, patch
+
+from sc.review.ticket_updater import TicketUpdater
+from sc.review.exceptions import TicketIdentifierNotFound
+from sc.review.models import CodeReview, RepoInfo
+
+
+class TestReview(unittest.TestCase):
+
+ def setUp(self):
+ self.repo_source = MagicMock()
+ self.ticket_service = MagicMock()
+ self.git_service = MagicMock()
+ self.prompter = MagicMock()
+
+ self.review = TicketUpdater(
+ repo_source=self.repo_source,
+ ticket_service=self.ticket_service,
+ git_service=self.git_service,
+ prompter=self.prompter
+ )
+
+ self.repo_info = RepoInfo(
+ branch="feature/test",
+ directory="dir",
+ remote_url="url",
+ commit_sha="sha",
+ commit_author="author",
+ commit_date="date",
+ commit_message="msg"
+ )
+
+ self.repo_source.get_repos.return_value = [self.repo_info]
+ self.repo_source.active_branch = "feature/test"
+
+ self.ticket = MagicMock(id=1, url="http://ticket")
+ self.ticket_service.resolve.return_value = ("instance", self.ticket)
+
+ @patch("builtins.print")
+ 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")
+ self.prompter.yn.return_value = True
+
+ self.review.run()
+
+ self.ticket_service.update.assert_called_once()
+ self.git_service.get_git_review_data.assert_called_once()
+ self.ticket_service.resolve.assert_called_once_with("ABC", "123")
+
+ @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.git_service.get_git_review_data.return_value = CodeReview(status=None, url=None)
+ self.prompter.yn.return_value = False
+
+ self.review.run()
+
+ self.ticket_service.prompt_ticket.assert_called_once()
+ self.ticket_service.update.assert_not_called()
+
+ @patch("builtins.print")
+ def test_run_git_failure_creates_url(self, mock_print):
+ self.ticket_service.match_branch.return_value = ("ABC", "123")
+ self.git_service.get_git_review_data.return_value = None
+ self.git_service.get_create_cr_url.return_value = "http://create"
+ self.prompter.yn.return_value = False
+
+ self.review.run()
+
+ self.git_service.get_create_cr_url.assert_called_once()
+
+ def test_create_comment_data(self):
+ cr = CodeReview(status="OPEN", url="http://cr")
+ ticket = MagicMock(url="http://ticket")
+
+ result = self.review._create_comment_data(self.repo_info, ticket, cr, None)
+
+ self.assertEqual(result.ticket_url, "http://ticket")
+ self.assertEqual(result.review_status, "OPEN")
+ self.assertEqual(result.review_url, "http://cr")
+
+ def test_create_comment_data_no_cr(self):
+ ticket = MagicMock(url="http://ticket")
+ result = self.review._create_comment_data(self.repo_info, ticket, None, "http://create")
+
+ self.assertEqual(result.ticket_url, "http://ticket")
+ self.assertEqual(result.review_status, "Not Created")
+ self.assertIsNone(result.review_url)
+ self.assertEqual(result.create_cr_url, "http://create")
+
+ def test_generate_combined_terminal_comment(self):
+ c1 = MagicMock()
+ c1.to_terminal.return_value = "A"
+ c2 = MagicMock()
+ c2.to_terminal.return_value = "B"
+
+ result = self.review._generate_combined_terminal_comment([c1, c2])
+
+ self.assertEqual(result, f"A\n{'-'*100}\nB")
+
+ def test_generate_combined_ticket_comment(self):
+ c1 = MagicMock()
+ c1.to_ticket.return_value = "A"
+ c2 = MagicMock()
+ c2.to_ticket.return_value = "B"
+
+ result = self.review._generate_combined_ticket_comment([c1, c2])
+
+ self.assertEqual(result, f"A\n{'-'*100}\nB")
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file