Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 2 additions & 1 deletion src/sc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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()

Expand Down
29 changes: 22 additions & 7 deletions src/sc/clone_cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down
Empty file added src/sc/review/__init__.py
Empty file.
29 changes: 29 additions & 0 deletions src/sc/review/code_review.py
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions src/sc/review/exceptions.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RemoteUrlNotFound exception class is missing a pass statement or docstring body. According to PEP 257, if a class has only a docstring and no methods, it should still have a pass statement for clarity, or the docstring should be on the same line as the class definition if it's a one-liner.

Suggested change
"""
"""
pass

Copilot uses AI. Check for mistakes.
pass
2 changes: 2 additions & 0 deletions src/sc/review/git_instances/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .git_factory import GitFactory
from .git_instance import GitInstance
33 changes: 33 additions & 0 deletions src/sc/review/git_instances/git_factory.py
Original file line number Diff line number Diff line change
@@ -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:
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter order in GitFactory.create() is inconsistent with its usage. The factory method defines parameters as (name, token, base_url), but it's called in review.py line 158 with keyword arguments in a different order. While this works with keyword arguments, consider aligning the parameter order with common usage patterns for better consistency, or document why the order differs.

Suggested change
def create(cls, name: str, token: str, base_url: str | None) -> GitInstance:
def create(cls, name: str, token: str, base_url: str | None) -> GitInstance:
"""
Create a GitInstance for the given provider name.
Parameters
----------
name : str
The provider name (e.g. "github", "gitlab").
token : str
The access token used to authenticate with the provider.
base_url : str | None
Optional base URL for self-hosted or custom deployments.
Notes
-----
This method is intended to be called with keyword arguments for
clarity, e.g.:
GitFactory.create(name="github", base_url="https://github.com", token="...")
The parameter order in the signature is kept for backwards
compatibility. Callers should not rely on positional arguments.
"""

Copilot uses AI. Check for mistakes.
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!")
68 changes: 68 additions & 0 deletions src/sc/review/git_instances/git_instance.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/sc/review/git_instances/instances/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .github_instance import GithubInstance
from .gitlab_instance import GitlabInstance
97 changes: 97 additions & 0 deletions src/sc/review/git_instances/instances/github_instance.py
Original file line number Diff line number Diff line change
@@ -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]
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential IndexError. If the repo parameter doesn't contain a "/" (e.g., malformed input), the split will return a list with only one element, and accessing index [0] will work, but this assumes the repo format is always "owner/repo". Consider adding validation or error handling to ensure the repo string is in the expected format before splitting.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code splits repo on "/" and assumes index 0 is the owner, but doesn't validate that the split actually produced at least 2 parts. If repo doesn't contain a "/", this will still work but the owner will be the entire repo string. If repo is empty or malformed, this could produce incorrect results. Consider adding validation or documenting the expected format.

Suggested change
owner = repo.split("/")[0]
repo = repo.strip()
if not repo or "/" not in repo:
raise ValueError("Invalid GitHub repository format. Expected 'owner/name'.")
owner, _ = repo.split("/", 1)

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repo.split("/")[0] call can raise an IndexError if the repo string doesn't contain a "/" character. Consider adding validation or error handling for malformed repository identifiers.

Copilot uses AI. Check for mistakes.
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
Comment on lines 70 to 72
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable r is referenced in the error handling blocks (lines 58-59) but may not be defined if the exception occurs during the requests.get() call itself (line 52). This will cause a NameError. Move the status_code and text access into a try-except block or check if r is defined before using it.

Suggested change
raise RuntimeError(
f"GitHub API error {r.status_code}: {r.text}"
) from e
response = getattr(e, "response", None)
status = getattr(response, "status_code", "unknown")
text = getattr(response, "text", "")
raise RuntimeError(f"GitHub API error {status}: {text}") from e

Copilot uses AI. Check for mistakes.
except ValueError as e: # JSON decode error
raise RuntimeError("Invalid JSON from GitHub API") from e
except requests.RequestException as e:
Comment on lines 67 to 75
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception handling catches specific requests.Timeout and requests.HTTPError exceptions, but these don't exist in the requests library. The correct exception classes are requests.exceptions.Timeout and requests.exceptions.HTTPError. This will cause an AttributeError at runtime when these exceptions are raised.

Suggested change
except requests.Timeout as e:
raise RuntimeError("GitHub request timed out") from e
except requests.HTTPError as e:
raise RuntimeError(
f"GitHub API error {r.status_code}: {r.text}"
) from e
except ValueError as e: # JSON decode error
raise RuntimeError("Invalid JSON from GitHub API") from e
except requests.RequestException as e:
except requests.exceptions.Timeout as e:
raise RuntimeError("GitHub request timed out") from e
except requests.exceptions.HTTPError as e:
raise RuntimeError(
f"GitHub API error {r.status_code}: {r.text}"
) from e
except ValueError as e: # JSON decode error
raise RuntimeError("Invalid JSON from GitHub API") from e
except requests.exceptions.RequestException as e:

Copilot uses AI. Check for mistakes.
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}"
Loading