diff --git a/setup.py b/setup.py index 7bdaf92..7d0526d 100644 --- a/setup.py +++ b/setup.py @@ -30,12 +30,14 @@ def read_version(): package_dir={"": "src"}, install_requires=[ 'Click>=8', - 'pyyaml~=6.0', - 'rich>=14', - 'requests==2.31.0', # Docker SDK breaks on 2.32.0 'docker~=7.0', 'gitpython>=3', + 'jira>=3.10', 'pydantic>=2', + 'python-redmine>=2.5', + 'pyyaml~=6.0', + 'rich>=14', + 'requests==2.31.0', # Docker SDK breaks on 2.32.0 'repo_library @ git+https://github.com/rdkcentral/sc-repo-library.git@master', 'git_flow_library @ git+https://github.com/rdkcentral/sc-git-flow-library.git@master', 'sc_manifest_parser @ git+https://github.com/rdkcentral/sc-manifest-parser.git@main' diff --git a/src/sc/cli.py b/src/sc/cli.py index 6e595c5..f4028ce 100755 --- a/src/sc/cli.py +++ b/src/sc/cli.py @@ -20,7 +20,7 @@ import click -from . import branching_cli, clone_cli, docker_cli, sc_logging +from . import branching_cli, clone_cli, docker_cli, review_cli, sc_logging CONFIG_DIR = Path(Path.home(), '.sc_config') CONFIG_PATH = Path(CONFIG_DIR, 'config.yaml') @@ -45,6 +45,7 @@ def entry_point(): add_commands_under_cli(branching_cli.cli) add_commands_under_cli(clone_cli.cli) add_commands_under_cli(docker_cli.cli) + add_commands_under_cli(review_cli.cli) cli() diff --git a/src/sc/clone_cli.py b/src/sc/clone_cli.py index 06d55e9..7633939 100755 --- a/src/sc/clone_cli.py +++ b/src/sc/clone_cli.py @@ -1,4 +1,19 @@ #!/usr/bin/env python3 +# +# 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 click from sc.clone import SCClone @@ -17,22 +32,22 @@ def cli(): @click.option('-v', '--verify', is_flag=True, help='RDK projects only, run post-sync-hooks without prompts.') @click.pass_context def clone( - ctx, - project: str | None, - directory: str | None, + ctx, + project: str | None, + directory: str | None, rev: str | None, no_tags: bool, - manifest: str | None, + manifest: str | None, force: bool, verify: bool, ): """Clone groups of repositories from a config.""" if project: SCClone().clone( - project_name=project, - directory=directory, + project_name=project, + directory=directory, rev=rev, - no_tags=no_tags, + no_tags=no_tags, manifest=manifest, force_overwrite=force, verify=verify, diff --git a/src/sc/review/__init__.py b/src/sc/review/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sc/review/code_review.py b/src/sc/review/code_review.py new file mode 100644 index 0000000..fe38fac --- /dev/null +++ b/src/sc/review/code_review.py @@ -0,0 +1,29 @@ +# 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 enum import Enum + +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 diff --git a/src/sc/review/exceptions.py b/src/sc/review/exceptions.py new file mode 100644 index 0000000..0790c67 --- /dev/null +++ b/src/sc/review/exceptions.py @@ -0,0 +1,64 @@ +# 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 ReviewException(Exception): + pass + +class TicketNotFound(ReviewException): + """Raised when a ticket cannot be found. + + Args: + ticket_url (str): URL of the ticket not found. + """ + def __init__(self, ticket_url: str): + super().__init__(f'Ticket not found at url: {ticket_url}') + +class TicketingInstanceUnreachable(ReviewException): + """Raised when a ticketing instance is unreachable. + + Args: + instance_url (str): The URL of the unreachable instance. + additional_info (str): Info on why the instance was unreachable, defaults to ''. + """ + def __init__(self, instance_url: str, additional_info: str = ''): + super().__init__( + 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): + """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/review/git_instances/__init__.py b/src/sc/review/git_instances/__init__.py new file mode 100644 index 0000000..3d289e5 --- /dev/null +++ b/src/sc/review/git_instances/__init__.py @@ -0,0 +1,2 @@ +from .git_factory import GitFactory +from .git_instance import GitInstance \ No newline at end of file diff --git a/src/sc/review/git_instances/git_factory.py b/src/sc/review/git_instances/git_factory.py new file mode 100644 index 0000000..ab7cbbd --- /dev/null +++ b/src/sc/review/git_instances/git_factory.py @@ -0,0 +1,33 @@ +# 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 .instances import GithubInstance, GitlabInstance +from .git_instance import GitInstance + +class GitFactory: + _registry = { + "github": GithubInstance, + "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: + try: + return cls._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 new file mode 100644 index 0000000..a30551c --- /dev/null +++ b/src/sc/review/git_instances/git_instance.py @@ -0,0 +1,68 @@ +# 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 ..code_review import CodeReview + +class GitInstance(ABC): + def __init__(self, token: str, base_url: str | None): + self._token = token + self.base_url = base_url.rstrip("/") if base_url else None + + @abstractmethod + def validate_connection(self) -> bool: + """Abstract Method: + Validates if connection to the git instance is successful. + + Raises: + ConnectionError: If the connection is unsuccessful. + + Returns: + bool: True if connection is successful. + """ + pass + + @abstractmethod + def get_code_review(self, repo: str, source_branch: str) -> CodeReview: + """Get information about a branches code review. + + Args: + repo (str): An identifier of the repo in the instance e.g "org/repo". + source_branch (str): The branch the code review is made from. + + Returns: + CodeReview: dataclass with information about the code review. + """ + pass + + @abstractmethod + def get_create_cr_url( + self, + repo: str, + source_branch: str, + target_branch: str = "develop" + ) -> str: + """Get the URL to create a code review for a repo and branch. + + Args: + repo (str): An identifier of the repo in the instance e.g "org/repo". + source_branch (str): The branch the code review will be made from. + target_branch (str, optional): The branch the code review will + merge into. Defaults to "develop". + + Returns: + str: The URL to create a code review. + """ + pass diff --git a/src/sc/review/git_instances/instances/__init__.py b/src/sc/review/git_instances/instances/__init__.py new file mode 100644 index 0000000..e8c8830 --- /dev/null +++ b/src/sc/review/git_instances/instances/__init__.py @@ -0,0 +1,2 @@ +from .github_instance import GithubInstance +from .gitlab_instance import GitlabInstance \ No newline at end of file diff --git a/src/sc/review/git_instances/instances/github_instance.py b/src/sc/review/git_instances/instances/github_instance.py new file mode 100644 index 0000000..d909770 --- /dev/null +++ b/src/sc/review/git_instances/instances/github_instance.py @@ -0,0 +1,97 @@ +# 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 requests + +from sc.review.code_review import CRStatus, CodeReview +from ..git_instance import GitInstance + +class GithubInstance(GitInstance): + def __init__(self, token: str, base_url: str | None): + super().__init__(token, base_url or "https://api.github.com") + + def _headers(self) -> dict: + return { + "Accept": "application/vnd.github.v3+json", + "Authorization": f"Bearer {self._token}" + } + + def validate_connection(self) -> bool: + url = f"{self.base_url}/user" + try: + r = requests.get(url, headers=self._headers(), timeout=10) + r.raise_for_status() + return True + except requests.exceptions.Timeout as e: + raise ConnectionError("GitHub API request timed out.") from e + except requests.exceptions.HTTPError as e: + status = e.response.status_code + if status in (401, 403): + raise ConnectionError("Invalid GitHub token.") from e + raise ConnectionError(f"GitHub API error: {status}") from e + except requests.exceptions.ConnectionError as e: + raise ConnectionError("Network connection to GitHub failed.") from e + + def get_code_review(self, repo: str, source_branch: str) -> CodeReview | None: + """Get information about a code review. + + Args: + repo (str): An identifier for the repo e.g. org/repo + source_branch (str): The source branch of review. + + Raises: + RuntimeError: If an error occurs. + + Returns: + CodeReview | None: An object describing a code review. + """ + url = f"{self.base_url}/repos/{repo}/pulls" + owner = repo.split("/")[0] + params = {"state": "all", "head": f"{owner}:{source_branch}"} + + try: + r = requests.get(url, headers=self._headers(), params=params, timeout=10) + r.raise_for_status() + prs = r.json() + except requests.Timeout as e: + raise RuntimeError("GitHub request timed out") from e + except requests.HTTPError as e: + raise RuntimeError( + f"GitHub API error {e.response.status_code}: {e.response.text}" + ) from e + except ValueError as e: # JSON decode error + raise RuntimeError("Invalid JSON from GitHub API") from e + except requests.RequestException as e: + raise RuntimeError("GitHub request failed") from e + + if not prs: + return None + pr = prs[0] + # GitHub marks merged PRs as state="closed", merged=True + if pr.get("merged"): + status = CRStatus.MERGED + elif pr["state"] == "open": + status = CRStatus.OPEN + else: + status = CRStatus.CLOSED + + return CodeReview(url=pr["html_url"], status=status) + + def get_create_cr_url( + self, + repo: str, + source_branch: str, + target_branch: str = "develop" + ) -> str: + return f"https://github.com/{repo}/compare/{target_branch}...{source_branch}" diff --git a/src/sc/review/git_instances/instances/gitlab_instance.py b/src/sc/review/git_instances/instances/gitlab_instance.py new file mode 100644 index 0000000..c414a9f --- /dev/null +++ b/src/sc/review/git_instances/instances/gitlab_instance.py @@ -0,0 +1,107 @@ +# 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 requests +import urllib.parse + +from sc.review.code_review import CodeReview, CRStatus +from ..git_instance import GitInstance + +class GitlabInstance(GitInstance): + def __init__(self, token: str, base_url: str): + super().__init__(token, base_url) + + def _headers(self) -> dict[str, str]: + return {"Private-Token": self._token} + + def validate_connection(self) -> bool: + url = f"{self.base_url}/api/v4/user" + try: + r = requests.get(url, headers=self._headers(), timeout=10, verify=False) + r.raise_for_status() + return True + except requests.Timeout as e: + raise ConnectionError( + f"GitLab API request timed out for {self.base_url}") from e + except requests.HTTPError as e: + status = e.response.status_code + if status == 401 or status == 403: + raise ConnectionError( + "Invalid GitLab token or insufficient permissions for " + f"{self.base_url}" + ) from e + raise ConnectionError( + f"GitLab API error for {self.base_url}: {status}") from e + except requests.ConnectionError as e: + 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: + """Get information about a code review. + + Args: + repo (str): An identifier for the repo e.g. org/repo + source_branch (str): The source branch of review. + + Raises: + RuntimeError: If an error occurs. + + Returns: + CodeReview | None: An object describing a code review. + """ + safe_repo = urllib.parse.quote(repo, safe='') + url = f"{self.base_url}/api/v4/projects/{safe_repo}/merge_requests" + params = {"state": "all", "source_branch": source_branch} + try: + r = requests.get( + url, headers=self._headers(), params=params, timeout=10, verify=False) + r.raise_for_status() + prs = r.json() + except requests.Timeout as e: + raise RuntimeError(f"Gitlab request timed out for {self.base_url}") from e + except requests.HTTPError as e: + raise RuntimeError( + f"Gitlab API error {e.response.status_code}: {e.response.text}" + ) from e + except ValueError as e: # JSON decode error + raise RuntimeError(f"Invalid JSON from Gitlab API for {self.base_url}") from e + except requests.RequestException as e: + raise RuntimeError(f"Gitlab request failed for {self.base_url}") from e + + if not prs: + return None + pr = prs[0] + + state = pr["state"] + if state == "merged": + status = CRStatus.MERGED + elif state == "opened": + status = CRStatus.OPEN + else: + status = CRStatus.CLOSED + + return CodeReview(url=pr["web_url"], status=status) + + def get_create_cr_url( + self, + repo: str, + source_branch: str, + target_branch: str="develop" + ): + params = { + "merge_request[source_branch]": source_branch, + "merge_request[target_branch]": target_branch, + } + query = urllib.parse.urlencode(params) + return f"{self.base_url}/{repo}/-/merge_requests/new?{query}" diff --git a/src/sc/review/main.py b/src/sc/review/main.py new file mode 100644 index 0000000..9110798 --- /dev/null +++ b/src/sc/review/main.py @@ -0,0 +1,160 @@ +# 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/review.py b/src/sc/review/review.py new file mode 100644 index 0000000..35f6779 --- /dev/null +++ b/src/sc/review/review.py @@ -0,0 +1,363 @@ +# 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 dataclasses import dataclass +from datetime import datetime +import logging +from pathlib import Path +import re +from urllib import parse + +from git import Repo + +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 + +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 _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' + + 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) + + 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}",
+            "
" + ) + + return "\n".join([*header, "", *review, "", *commit]) diff --git a/src/sc/review/review_config.py b/src/sc/review/review_config.py new file mode 100644 index 0000000..1ca3826 --- /dev/null +++ b/src/sc/review/review_config.py @@ -0,0 +1,86 @@ +# 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 typing import Literal + +from pydantic import BaseModel, ConfigDict, ValidationError + +from .exceptions import ConfigError +from sc.config_manager import ConfigManager + +class TicketHostCfg(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 GitInstanceCfg(BaseModel): + model_config = ConfigDict(extra='forbid') + + url: str | None = None + token: str + provider: str + +class ReviewConfig: + def __init__(self): + self._ticket_config = ConfigManager('ticketing_instances') + self._git_config = ConfigManager('git_instances') + + def get_ticketing_config(self) -> dict[str, TicketHostCfg]: + """Return all ticketing instance configs keyed by identifier.""" + return {k: TicketHostCfg(**v) for k,v in self._ticket_config.get_config().items()} + + def get_ticket_host_identifiers(self) -> set[str]: + """Return all configured ticketing instance identifiers.""" + return self._ticket_config.get_config().keys() + + def get_ticket_host_data(self, identifier: str) -> TicketHostCfg: + """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) + 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): + """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]: + """Return all configured git URL patterns.""" + return self._git_config.get_config().keys() + + def get_git_data(self, url_pattern: str) -> GitInstanceCfg: + """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) + 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): + """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.py b/src/sc/review/ticket.py new file mode 100644 index 0000000..36c3e47 --- /dev/null +++ b/src/sc/review/ticket.py @@ -0,0 +1,25 @@ +# 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 + +@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 diff --git a/src/sc/review/ticketing_instances/__init__.py b/src/sc/review/ticketing_instances/__init__.py new file mode 100644 index 0000000..f5a678f --- /dev/null +++ b/src/sc/review/ticketing_instances/__init__.py @@ -0,0 +1,2 @@ +from .ticketing_instance import TicketingInstance +from .ticket_instance_factory import TicketingInstanceFactory \ No newline at end of file diff --git a/src/sc/review/ticketing_instances/instances/__init__.py b/src/sc/review/ticketing_instances/instances/__init__.py new file mode 100644 index 0000000..33ad7ae --- /dev/null +++ b/src/sc/review/ticketing_instances/instances/__init__.py @@ -0,0 +1,2 @@ +from .jira_instance import JiraInstance +from .redmine_instance import RedmineInstance \ No newline at end of file diff --git a/src/sc/review/ticketing_instances/instances/jira_instance.py b/src/sc/review/ticketing_instances/instances/jira_instance.py new file mode 100644 index 0000000..cd00fd1 --- /dev/null +++ b/src/sc/review/ticketing_instances/instances/jira_instance.py @@ -0,0 +1,138 @@ +# 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 requests.exceptions import RequestException +from typing import Literal +import urllib3 + +from jira import JIRA +from jira.exceptions import JIRAError + +from sc.review.exceptions import PermissionsError, TicketNotFound +from sc.review.ticket import Ticket +from .. import TicketingInstance + +class JiraInstance(TicketingInstance): + """A class to handle operations on Jira tickets. + + Args: + url (str): URL of Jira instance. + token (str): Token to authenticate with. + auth_type (str): Either token for token auth or basic for basic auth. Basic + auth takes an email and password, token takes a token. + username (str): The username if using basic auth. + cert (str): The cert. + + Raises: + TicketingInstanceUnreachable: If the ticketing instance cannot be connected to. + """ + + def __init__( + self, + url: str, + token: str, + auth_type: Literal["token", "basic"] = "token", + username: str | None = None, + cert: str | None = None + ): + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self.url = url + options = {} + if cert: + options["client_cert"] = cert + try: + if auth_type == "token": + self._instance = JIRA( + server=url, + token_auth=token, + options=options + ) + elif auth_type == "basic": + self._instance = JIRA( + server=url, + basic_auth=(username, token), + options=options + ) + except JIRAError as e: + if e.status_code in (401, 403): + raise ConnectionError( + f"Invalid Jira credentials or insufficient permissions " + f"for instance {url}. {e.text}" + ) from e + raise ConnectionAbortedError( + f"Jira API error for instance {url}: HTTP {e.status_code}") from e + + except RequestException as e: + raise ConnectionError(f"Unable to reach Jira server {url}.") from e + + @property + def engine(self) -> str: + return 'jira' + + def read_ticket(self, ticket_id: str) -> Ticket: + """Reads the contents of the ticket and puts the dictionary in to contents + + Args: + ticket_id (str): The id of the ticket to read. Defaults to None + """ + try: + issue = self._instance.issue(ticket_id) + except JIRAError as e: + if 'permission' in e.text: + raise PermissionsError( + e.url, 'Please contact the dev-support team') + else: + raise TicketNotFound(e.url) + + f = issue.fields + assignee = getattr(f.assignee, "name", None) + author = getattr(f.reporter, "name", None) + + comments = getattr(f.comment, "comments", None) + + status = f.status.name + title = f.summary + + versions = getattr(f, "fixVersions", []) + target_version = ", ".join(v.name for v in versions) + + ticket_url = f'{self.url}/browse/{ticket_id}' + + return Ticket( + url=ticket_url, + assignee=assignee, + author=author, + comments=comments, + id=ticket_id, + status=status, + target_version=target_version, + title=title, + ) + + def add_comment_to_ticket(self, ticket_id: str, comment_message: str): + """Adds a comment to the ticket + + Args: + ticket_id (str): The ticket id to add the comment to. + comment_message (str): The body of the comment. + """ + comment = self._convert_from_html(comment_message) + self._instance.add_comment(ticket_id, body=comment) + + def _convert_from_html(self, string: str) -> str: + string = string.replace('

', '}') + string = string.replace('

', '{color}') + string = string.replace('
', '{noformat}')
+        string = string.replace('
', '{noformat}') + return string diff --git a/src/sc/review/ticketing_instances/instances/redmine_instance.py b/src/sc/review/ticketing_instances/instances/redmine_instance.py new file mode 100644 index 0000000..6f1f689 --- /dev/null +++ b/src/sc/review/ticketing_instances/instances/redmine_instance.py @@ -0,0 +1,165 @@ +# 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 urllib3 + +from redminelib import Redmine +from redminelib.resources import Issue +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.ticket import Ticket +from .. import TicketingInstance + +class RedmineInstance(TicketingInstance): + """ + A class to handle operations on Redmine tickets. + """ + + def __init__( + self, + url: str, + token: str, + verify_ssl: bool = False + ): + if not verify_ssl: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + self._instance = Redmine( + url, + key=token, + requests={'verify': verify_ssl} + ) + self._validate_connection() + + @property + def engine(self) -> str: + return 'redmine' + + def read_ticket(self, ticket_id: str) -> Ticket: + """Read the information from a ticket and put its contents in this object contents dict + + Args: + ticket_id (str): The ticket number to read. + Raises: + TicketNotFound: Ticket not found on the redmine instance + PermissionsError: User does not have permission to access the ticket on the instances + TicketingInstanceUnreachable: The redmine instance is unreachable + """ + ticket_url = self._instance.url + '/issues/' + ticket_id + try: + issue: Issue = self._instance.issue.get(ticket_id, include=['journals']) + except ResourceNotFoundError as e: + raise TicketNotFound(ticket_url) from e + except (AuthError, ForbiddenError) as e: + raise PermissionsError( + ticket_url, + 'Please contact the dev-support team') from e + except SSLError as e: + raise TicketingInstanceUnreachable( + ticket_url, + additional_info=''.join(str(arg) for arg in e.args)) + + issue_contents = issue.__dict__ + + author = issue_contents.get("author", {}).get("name") + assignee = issue_contents.get("assigned_to", {}).get("name") + comments = issue_contents.get("journals") + status = issue_contents.get("status", {}).get("name") + target_version = issue_contents.get("fixed_version", {}).get("name") + title = issue_contents.get("subject") + + return Ticket( + ticket_url, + author=author, + assignee=assignee, + comments=comments, + id=ticket_id, + status=status, + title=title, + target_version=target_version + ) + + def add_comment_to_ticket(self, ticket_id: str, comment_message: str): + """Add a comment to a ticket on the redmine instance + + Args: + comment_message (str): The message to add as a comment + ticket_id (str): The ticket number to add the comment to. + """ + self._update_ticket(ticket_id, notes=self._convert_html_colours(comment_message)) + + def _update_ticket(self, ticket_id: str, **kwargs): + """Writes the changed fields from the kwargs, back to the ticket + + Raises: + TicketNotFound: Ticket not found on the redmine instance + PermissionsError: User does not have permission to access the ticket on the instances + TicketingInstanceUnreachable: The redmine instance is unreachable + """ + issue_url = f'{self._instance.url}/issues/{ticket_id}' + try: + self._instance.issue.update(ticket_id, **kwargs) + except ResourceNotFoundError as exception: + raise TicketNotFound(issue_url) from exception + except ForbiddenError as exception: + raise PermissionsError( + issue_url, 'Please contact the dev-support team') from exception + except SSLError as exception: + raise TicketingInstanceUnreachable( + issue_url, + additional_info=''.join(str(arg) for arg in exception.args)) + ticket = self.read_ticket(ticket_id) + return ticket + + def _validate_connection(self) -> bool: + """Check if the Redmine instance and API key are valid. + + Raises: + ConnectionError: If the connection is invalid. + + Returns: + bool: True if connection is valid. + """ + try: + self._instance.auth() + return True + + except (AuthError, ForbiddenError) as e: + raise ConnectionError( + "Invalid Redmine API key or insufficient permissions for " + f"{self._instance.url}." + ) from e + + except BaseRedmineError as e: + raise ConnectionError( + f"Redmine API error at {self._instance.url}: {e}") from e + + except RequestException as e: + raise ConnectionError( + f"Failed to reach Redmine server at {self._instance.url}") from e + + def _convert_html_colours(self, string: str) -> str: + """Convert a html colour tags to redmine formatted colour tags. + + Args: + string (str): html formatted string. + + Returns: + str: Input string with html colour tags converted to redmine colour tags. + """ + string = string.replace('

', '}') + string = string.replace('

', r'%') + return string diff --git a/src/sc/review/ticketing_instances/ticket_instance_factory.py b/src/sc/review/ticketing_instances/ticket_instance_factory.py new file mode 100644 index 0000000..9c27665 --- /dev/null +++ b/src/sc/review/ticketing_instances/ticket_instance_factory.py @@ -0,0 +1,66 @@ +# 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 typing import Literal + +from sc.review.exceptions import TicketIdentifierNotFound +from .instances import JiraInstance, RedmineInstance +from .ticketing_instance import TicketingInstance + +class TicketingInstanceFactory: + @classmethod + def create( + cls, + provider: str, + url: str, + token: str, + auth_type: Literal["token", "basic"] = "token", + username: str | None = None, + cert: str | None = None + ) -> TicketingInstance: + """Ticketing instance factory method. + + Args: + provider (str): Which ticketing provider. jira or redmine supported. + url (str): URL to ticketing provider. + token (str): The token or password for the provider. + auth_type (Literal["token", "basic"], optional): Auth method. + Defaults to "token". + username (str | None, optional): Username for basic auth. Defaults to None. + cert (str | None, optional): Path to a cert. Defaults to None. + + Raises: + TicketIdentifierNotFound: Provider isn't supported. + ConnectionError: Fail to connect or validate with provider. + + Returns: + TicketingInstance + """ + if provider == "redmine": + return RedmineInstance( + url, + token=token + ) + elif provider == "jira": + return JiraInstance( + url, + token=token, + auth_type=auth_type, + username=username, + cert=cert + ) + else: + raise TicketIdentifierNotFound( + f"Provider {provider} is not supported." + " Supported providers are: redmine, jira" + ) diff --git a/src/sc/review/ticketing_instances/ticketing_instance.py b/src/sc/review/ticketing_instances/ticketing_instance.py new file mode 100644 index 0000000..0dd5e12 --- /dev/null +++ b/src/sc/review/ticketing_instances/ticketing_instance.py @@ -0,0 +1,55 @@ +# 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 ..ticket import Ticket + +class TicketingInstance(ABC): + """ + A class to handle the ticket(s) that the source control commands are + operating on. + """ + @property + @abstractmethod + def engine(self) -> str: + pass + + @abstractmethod + def read_ticket(self, ticket_id: str) -> Ticket: + """Abstract Method: + Read the ticket and return it as a ticket object + + Args: + ticket_id (str): The id of the ticket to update. + Returns: + ticket (Ticket): ticket object. + """ + pass + + @abstractmethod + def add_comment_to_ticket(self, ticket_id: str, comment_message: str): + """Abstract Method: + Adds a comment to the ticket on the ticketing instance. + Reads the new ticket with the new comment. + Returns new ticket object with comment added + + Args: + ticket_id (str): The id of the ticket to update. + comment_message (str): The comment to add to the ticket. + + Returns: + ticket (Ticket): New ticket object with comment added + """ + pass diff --git a/src/sc/review_cli.py b/src/sc/review_cli.py new file mode 100644 index 0000000..3035581 --- /dev/null +++ b/src/sc/review_cli.py @@ -0,0 +1,36 @@ +# 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 click + +from .review import main + +@click.group() +def cli(): + pass + +@cli.command() +def review(): + """Add commit/PR information to your ticket.""" + main.review() + +@cli.command() +def add_git_instance(): + """Add a VCS instance for sc review.""" + main.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 diff --git a/src/sc/sc_logging.py b/src/sc/sc_logging.py index 289c4e1..dc5a47a 100644 --- a/src/sc/sc_logging.py +++ b/src/sc/sc_logging.py @@ -1,6 +1,20 @@ +# 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 rich.logging import RichHandler +from rich.logging import RichHandler APP_LOGGER_NAME = "sc" @@ -37,4 +51,3 @@ def format(self, record): else: self._style._fmt = self.DEFAULT_FMT return super().format(record) - \ No newline at end of file