+
+
- Actions Log
+
+ {showPRMode ? 'Commit Diff' : 'Actions Log'}
+
+
-
Summary (by `{import.meta.env.VITE_LLM_MODEL}`)
-
-
-
-
-
+
+ {showPRMode
+ ? 'Pull Request Description'
+ : showReleaseMode
+ ? 'Release Notes'
+ : `Summary (by \`${import.meta.env.VITE_LLM_MODEL}\`)`
+ }
+
+ {!showPRMode && !showReleaseMode && (
+
+
+
+
+
+ )}
diff --git a/git_recap/providers/azure_fetcher.py b/git_recap/providers/azure_fetcher.py
index 62a5b29..42a8300 100644
--- a/git_recap/providers/azure_fetcher.py
+++ b/git_recap/providers/azure_fetcher.py
@@ -1,7 +1,7 @@
from azure.devops.connection import Connection
from msrest.authentication import BasicAuthentication
from datetime import datetime
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Optional
from git_recap.providers.base_fetcher import BaseFetcher
class AzureFetcher(BaseFetcher):
@@ -236,4 +236,81 @@ def fetch_releases(self) -> List[Dict[str, Any]]:
NotImplementedError: Always, since release fetching is not supported for AzureFetcher.
"""
# If Azure DevOps release fetching is supported in the future, implement logic here.
- raise NotImplementedError("Release fetching is not supported for Azure DevOps (AzureFetcher).")
\ No newline at end of file
+ raise NotImplementedError("Release fetching is not supported for Azure DevOps (AzureFetcher).")
+
+ def get_branches(self) -> List[str]:
+ """
+ Get all branches in the repository.
+
+ Returns:
+ List[str]: List of branch names.
+
+ Raises:
+ NotImplementedError: Always, since branch listing is not yet implemented for AzureFetcher.
+ """
+ # TODO: Implement get_branches() for Azure DevOps support
+ # This would use: git_client.get_branches(repository_id, project)
+ # and extract branch names from the returned objects
+ raise NotImplementedError("Branch listing is not yet implemented for Azure DevOps (AzureFetcher).")
+
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
+ """
+ Get branches that can receive a pull request from the source branch.
+
+ Validates that the source branch exists, filters out branches with existing
+ open PRs from source, excludes the source branch itself, and optionally
+ checks if source is ahead of target.
+
+ Args:
+ source_branch (str): The source branch name.
+
+ Returns:
+ List[str]: List of valid target branch names.
+
+ Raises:
+ NotImplementedError: Always, since PR target validation is not yet implemented for AzureFetcher.
+ """
+ # TODO: Implement get_valid_target_branches() for Azure DevOps support
+ # This would require:
+ # 1. Verify source_branch exists using git_client.get_branch()
+ # 2. Get all branches using get_branches()
+ # 3. Filter out source branch
+ # 4. Check for existing pull requests using git_client.get_pull_requests()
+ # 5. Filter out branches with existing open PRs from source
+ # 6. Optionally check branch policies and protection rules
+ raise NotImplementedError("Pull request target branch validation is not yet implemented for Azure DevOps (AzureFetcher).")
+
+ def create_pull_request(
+ self,
+ head_branch: str,
+ base_branch: str,
+ title: str,
+ body: str,
+ draft: bool = False,
+ reviewers: Optional[List[str]] = None,
+ assignees: Optional[List[str]] = None,
+ labels: Optional[List[str]] = None
+ ) -> Dict[str, Any]:
+ """
+ Create a pull request between two branches with optional metadata.
+
+ Args:
+ head_branch: Source branch for the PR.
+ base_branch: Target branch for the PR.
+ title: PR title.
+ body: PR description.
+ draft: Whether to create as draft PR (default: False).
+ reviewers: List of reviewer usernames (optional).
+ assignees: List of assignee usernames (optional).
+ labels: List of label names (optional).
+
+ Returns:
+ Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information.
+
+ Raises:
+ NotImplementedError: Always, since PR creation is not yet implemented for AzureFetcher.
+ """
+ # TODO: Implement create_pull_request() for Azure DevOps support
+ # This would use: git_client.create_pull_request() with appropriate parameters
+ # Would need to handle reviewers, work item links (assignees), labels, and draft status
+ raise NotImplementedError("Pull request creation is not yet implemented for Azure DevOps (AzureFetcher).")
\ No newline at end of file
diff --git a/git_recap/providers/base_fetcher.py b/git_recap/providers/base_fetcher.py
index 618ebb7..4741cda 100644
--- a/git_recap/providers/base_fetcher.py
+++ b/git_recap/providers/base_fetcher.py
@@ -90,6 +90,72 @@ def fetch_releases(self) -> List[Dict[str, Any]]:
"""
raise NotImplementedError("Release fetching is not implemented for this provider.")
+ @abstractmethod
+ def get_branches(self) -> List[str]:
+ """
+ Get all branches in the repository.
+
+ Returns:
+ List[str]: List of branch names.
+
+ Raises:
+ NotImplementedError: Subclasses must implement this method.
+ """
+ raise NotImplementedError("Subclasses must implement get_branches() to return all repository branches")
+
+ @abstractmethod
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
+ """
+ Get branches that can receive a pull request from the source branch.
+
+ Validates that the source branch exists, filters out branches with existing
+ open PRs from source, excludes the source branch itself, and optionally
+ checks if source is ahead of target.
+
+ Args:
+ source_branch (str): The source branch name.
+
+ Returns:
+ List[str]: List of valid target branch names.
+
+ Raises:
+ NotImplementedError: Subclasses must implement this method.
+ """
+ raise NotImplementedError("Subclasses must implement get_valid_target_branches() to return valid PR target branches for the given source branch")
+
+ @abstractmethod
+ def create_pull_request(
+ self,
+ head_branch: str,
+ base_branch: str,
+ title: str,
+ body: str,
+ draft: bool = False,
+ reviewers: Optional[List[str]] = None,
+ assignees: Optional[List[str]] = None,
+ labels: Optional[List[str]] = None
+ ) -> Dict[str, Any]:
+ """
+ Create a pull request between two branches with optional metadata.
+
+ Args:
+ head_branch: Source branch for the PR.
+ base_branch: Target branch for the PR.
+ title: PR title.
+ body: PR description.
+ draft: Whether to create as draft PR (default: False).
+ reviewers: List of reviewer usernames (optional).
+ assignees: List of assignee usernames (optional).
+ labels: List of label names (optional).
+
+ Returns:
+ Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information.
+
+ Raises:
+ NotImplementedError: Subclasses must implement this method.
+ """
+ raise NotImplementedError("Subclasses must implement create_pull_request() to create a pull request with the specified parameters")
+
def get_authored_messages(self) -> List[Dict[str, Any]]:
"""
Aggregates all commit, pull request, and issue entries into a single list,
diff --git a/git_recap/providers/github_fetcher.py b/git_recap/providers/github_fetcher.py
index 422150c..98f9cd9 100644
--- a/git_recap/providers/github_fetcher.py
+++ b/git_recap/providers/github_fetcher.py
@@ -1,7 +1,12 @@
from github import Github
+from github import GithubException
from datetime import datetime
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Optional
from git_recap.providers.base_fetcher import BaseFetcher
+import logging
+
+logger = logging.getLogger(__name__)
+
class GitHubFetcher(BaseFetcher):
"""
@@ -59,6 +64,32 @@ def fetch_commits(self) -> List[Dict[str, Any]]:
break
return entries
+ def fetch_branch_diff_commits(self, source_branch: str, target_branch: str) -> List[Dict[str, Any]]:
+ entries = []
+ processed_commits = set()
+ for repo in self.repos:
+ if self.repo_filter and repo.name not in self.repo_filter:
+ continue
+ try:
+ comparison = repo.compare(target_branch, source_branch)
+ for commit in comparison.commits:
+ commit_date = commit.commit.author.date
+ sha = commit.sha
+ if sha not in processed_commits:
+ entry = {
+ "type": "commit",
+ "repo": repo.name,
+ "message": commit.commit.message.strip(),
+ "timestamp": commit_date,
+ "sha": sha,
+ }
+ entries.append(entry)
+ processed_commits.add(sha)
+ except GithubException as e:
+ logger.error(f"Failed to compare branches in {repo.name}: {str(e)}")
+ continue
+ return entries
+
def fetch_pull_requests(self) -> List[Dict[str, Any]]:
entries = []
# Maintain a local set to skip duplicate commits already captured in a PR.
@@ -174,4 +205,204 @@ def fetch_releases(self) -> List[Dict[str, Any]]:
except Exception:
# If fetching releases fails for a repo, skip it (could be permissions or no releases)
continue
- return releases
\ No newline at end of file
+ return releases
+
+ def get_branches(self) -> List[str]:
+ """
+ Get all branches in the repository.
+ Returns:
+ List[str]: List of branch names.
+ Raises:
+ Exception: If API rate limits are exceeded or authentication fails.
+ """
+ logger.debug("Fetching branches from all accessible repositories")
+ try:
+ branches = []
+ for repo in self.repos:
+ if self.repo_filter and repo.name not in self.repo_filter:
+ continue
+ logger.debug(f"Fetching branches for repository: {repo.name}")
+ repo_branches = repo.get_branches()
+ for branch in repo_branches:
+ branches.append(branch.name)
+ logger.debug(f"Successfully fetched {len(branches)} branches")
+ return branches
+ except GithubException as e:
+ if e.status == 403:
+ logger.error(f"Rate limit exceeded or authentication failed: {str(e)}")
+ raise Exception(f"Failed to fetch branches: Rate limit exceeded or authentication failed - {str(e)}")
+ elif e.status == 401:
+ logger.error(f"Authentication failed: {str(e)}")
+ raise Exception(f"Failed to fetch branches: Authentication failed - {str(e)}")
+ else:
+ logger.error(f"GitHub API error while fetching branches: {str(e)}")
+ raise Exception(f"Failed to fetch branches: {str(e)}")
+ except Exception as e:
+ logger.error(f"Unexpected error while fetching branches: {str(e)}")
+ raise Exception(f"Failed to fetch branches: {str(e)}")
+
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
+ """
+ Get branches that can receive a pull request from the source branch.
+ Validates that the source branch exists, filters out branches with existing
+ open PRs from source, excludes the source branch itself, and optionally
+ checks if source is ahead of target.
+ Args:
+ source_branch (str): The source branch name.
+ Returns:
+ List[str]: List of valid target branch names.
+ Raises:
+ ValueError: If source branch does not exist.
+ Exception: If API errors occur during validation.
+ """
+ logger.debug(f"Validating target branches for source branch: {source_branch}")
+ try:
+ all_branches = self.get_branches()
+ if source_branch not in all_branches:
+ logger.error(f"Source branch '{source_branch}' does not exist")
+ raise ValueError(f"Source branch '{source_branch}' does not exist")
+ valid_targets = []
+ for repo in self.repos:
+ if self.repo_filter and repo.name not in self.repo_filter:
+ continue
+ logger.debug(f"Processing repository: {repo.name}")
+ repo_branches = [branch.name for branch in repo.get_branches()]
+ # Get existing open PRs from source branch
+ try:
+ open_prs = repo.get_pulls(state='open', head=source_branch)
+ except GithubException as e:
+ logger.error(f"GitHub API error while getting PRs: {str(e)}")
+ raise Exception(f"Failed to validate target branches: {str(e)}")
+ existing_pr_targets = set()
+ for pr in open_prs:
+ existing_pr_targets.add(pr.base.ref)
+ logger.debug(f"Found existing PR from {source_branch} to {pr.base.ref}")
+ for branch_name in repo_branches:
+ if branch_name == source_branch:
+ logger.debug(f"Excluding source branch: {branch_name}")
+ continue
+ if branch_name in existing_pr_targets:
+ logger.debug(f"Excluding branch with existing PR: {branch_name}")
+ continue
+ # Optionally check if source is ahead of target (performance cost)
+ valid_targets.append(branch_name)
+ logger.debug(f"Valid target branch: {branch_name}")
+ logger.debug(f"Found {len(valid_targets)} valid target branches")
+ return valid_targets
+ except ValueError:
+ raise
+ except GithubException as e:
+ logger.error(f"GitHub API error while validating target branches: {str(e)}")
+ raise Exception(f"Failed to validate target branches: {str(e)}")
+ except Exception as e:
+ logger.error(f"Unexpected error while validating target branches: {str(e)}")
+ raise Exception(f"Failed to validate target branches: {str(e)}")
+
+ def create_pull_request(
+ self,
+ head_branch: str,
+ base_branch: str,
+ title: str,
+ body: str,
+ draft: bool = False,
+ reviewers: Optional[List[str]] = None,
+ assignees: Optional[List[str]] = None,
+ labels: Optional[List[str]] = None
+ ) -> Dict[str, Any]:
+ """
+ Create a pull request between two branches with optional metadata.
+ Args:
+ head_branch: Source branch for the PR.
+ base_branch: Target branch for the PR.
+ title: PR title.
+ body: PR description.
+ draft: Whether to create as draft PR (default: False).
+ reviewers: List of reviewer usernames (optional).
+ assignees: List of assignee usernames (optional).
+ labels: List of label names (optional).
+ Returns:
+ Dict[str, Any]: Dictionary containing PR metadata or error information.
+ Raises:
+ ValueError: If branches don't exist or PR already exists.
+ """
+ logger.info(f"Creating pull request from {head_branch} to {base_branch}")
+ try:
+ all_branches = self.get_branches()
+ if head_branch not in all_branches:
+ logger.error(f"Head branch '{head_branch}' does not exist")
+ raise ValueError(f"Head branch '{head_branch}' does not exist")
+ if base_branch not in all_branches:
+ logger.error(f"Base branch '{base_branch}' does not exist")
+ raise ValueError(f"Base branch '{base_branch}' does not exist")
+ for repo in self.repos:
+ if self.repo_filter and repo.name not in self.repo_filter:
+ continue
+ logger.debug(f"Checking for existing PRs in repository: {repo.name}")
+ try:
+ existing_prs = repo.get_pulls(state='open', head=head_branch, base=base_branch)
+ except GithubException as e:
+ logger.error(f"GitHub API error while getting PRs: {str(e)}")
+ raise
+ if hasattr(existing_prs, "totalCount") and existing_prs.totalCount > 0:
+ logger.error(f"Pull request already exists from {head_branch} to {base_branch}")
+ raise ValueError(f"Pull request already exists from {head_branch} to {base_branch}")
+ elif isinstance(existing_prs, list) and len(existing_prs) > 0:
+ logger.error(f"Pull request already exists from {head_branch} to {base_branch}")
+ raise ValueError(f"Pull request already exists from {head_branch} to {base_branch}")
+ logger.info(f"Creating pull request in repository: {repo.name}")
+ try:
+ pr = repo.create_pull(
+ title=title,
+ body=body,
+ head=head_branch,
+ base=base_branch,
+ draft=draft
+ )
+ logger.info(f"Pull request created successfully: {pr.html_url}")
+ if reviewers and len(reviewers) > 0:
+ try:
+ logger.debug(f"Adding reviewers: {reviewers}")
+ pr.create_review_request(reviewers=reviewers)
+ logger.info(f"Successfully added reviewers: {reviewers}")
+ except GithubException as e:
+ logger.warning(f"Failed to add reviewers: {str(e)}")
+ if assignees and len(assignees) > 0:
+ try:
+ logger.debug(f"Adding assignees: {assignees}")
+ pr.add_to_assignees(*assignees)
+ logger.info(f"Successfully added assignees: {assignees}")
+ except GithubException as e:
+ logger.warning(f"Failed to add assignees: {str(e)}")
+ if labels and len(labels) > 0:
+ try:
+ logger.debug(f"Adding labels: {labels}")
+ pr.add_to_labels(*labels)
+ logger.info(f"Successfully added labels: {labels}")
+ except GithubException as e:
+ logger.warning(f"Failed to add labels: {str(e)}")
+ return {
+ "url": pr.html_url,
+ "number": pr.number,
+ "state": pr.state,
+ "success": True
+ }
+ except GithubException as e:
+ if e.status == 404:
+ logger.error(f"Branch not found: {str(e)}")
+ raise ValueError(f"Branch not found: {str(e)}")
+ elif e.status == 403:
+ logger.error(f"Permission denied: {str(e)}")
+ raise GithubException(e.status, f"Permission denied: {str(e)}", e.headers)
+ elif e.status == 422:
+ logger.error(f"Merge conflict or validation error: {str(e)}")
+ raise ValueError(f"Merge conflict or validation error: {str(e)}")
+ else:
+ logger.error(f"GitHub API error: {str(e)}")
+ raise
+ logger.error("No repository found to create pull request")
+ raise ValueError("No repository found to create pull request")
+ except (ValueError, GithubException):
+ raise
+ except Exception as e:
+ logger.error(f"Unexpected error while creating pull request: {str(e)}")
+ raise Exception(f"Failed to create pull request: {str(e)}")
\ No newline at end of file
diff --git a/git_recap/providers/gitlab_fetcher.py b/git_recap/providers/gitlab_fetcher.py
index 4fe4834..96c974c 100644
--- a/git_recap/providers/gitlab_fetcher.py
+++ b/git_recap/providers/gitlab_fetcher.py
@@ -1,6 +1,6 @@
import gitlab
from datetime import datetime
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Optional
from git_recap.providers.base_fetcher import BaseFetcher
class GitLabFetcher(BaseFetcher):
@@ -183,5 +183,67 @@ def fetch_releases(self) -> List[Dict[str, Any]]:
Raises:
NotImplementedError: Always, since release fetching is not supported for GitLabFetcher.
"""
- # If GitLab release fetching is supported in the future, implement logic here.
- raise NotImplementedError("Release fetching is not supported for GitLab (GitLabFetcher).")
\ No newline at end of file
+ raise NotImplementedError("Release fetching is not supported for GitLab (GitLabFetcher).")
+
+ def get_branches(self) -> List[str]:
+ """
+ Get all branches in the repository.
+
+ Returns:
+ List[str]: List of branch names.
+
+ Raises:
+ NotImplementedError: Always, since branch listing is not yet implemented for GitLabFetcher.
+ """
+ raise NotImplementedError("Branch listing is not yet implemented for GitLab (GitLabFetcher).")
+
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
+ """
+ Get branches that can receive a pull request from the source branch.
+
+ Validates that the source branch exists, filters out branches with existing
+ open PRs from source, excludes the source branch itself, and optionally
+ checks if source is ahead of target.
+
+ Args:
+ source_branch (str): The source branch name.
+
+ Returns:
+ List[str]: List of valid target branch names.
+
+ Raises:
+ NotImplementedError: Always, since PR target validation is not yet implemented for GitLabFetcher.
+ """
+ raise NotImplementedError("Pull request target branch validation is not yet implemented for GitLab (GitLabFetcher).")
+
+ def create_pull_request(
+ self,
+ head_branch: str,
+ base_branch: str,
+ title: str,
+ body: str,
+ draft: bool = False,
+ reviewers: Optional[List[str]] = None,
+ assignees: Optional[List[str]] = None,
+ labels: Optional[List[str]] = None
+ ) -> Dict[str, Any]:
+ """
+ Create a pull request (merge request) between two branches with optional metadata.
+
+ Args:
+ head_branch: Source branch for the PR.
+ base_branch: Target branch for the PR.
+ title: PR title.
+ body: PR description.
+ draft: Whether to create as draft PR (default: False).
+ reviewers: List of reviewer usernames (optional).
+ assignees: List of assignee usernames (optional).
+ labels: List of label names (optional).
+
+ Returns:
+ Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information.
+
+ Raises:
+ NotImplementedError: Always, since PR creation is not yet implemented for GitLabFetcher.
+ """
+ raise NotImplementedError("Pull request (merge request) creation is not yet implemented for GitLab (GitLabFetcher).")
\ No newline at end of file
diff --git a/git_recap/providers/url_fetcher.py b/git_recap/providers/url_fetcher.py
index ddb0f27..e4424c9 100644
--- a/git_recap/providers/url_fetcher.py
+++ b/git_recap/providers/url_fetcher.py
@@ -229,10 +229,67 @@ def fetch_releases(self) -> List[Dict[str, Any]]:
Raises:
NotImplementedError: Always, since release fetching is not supported for URLFetcher.
"""
- # If in the future, support for fetching releases from generic git repos is added,
- # implement logic here (e.g., parse tags and annotate with metadata).
raise NotImplementedError("Release fetching is not supported for generic Git URLs (URLFetcher).")
+ def get_branches(self) -> List[str]:
+ """
+ Get all branches in the repository.
+
+ Returns:
+ List[str]: List of branch names.
+
+ Raises:
+ NotImplementedError: Always, since branch listing is not yet implemented for URLFetcher.
+ """
+ raise NotImplementedError("Branch listing is not yet implemented for generic Git URLs (URLFetcher).")
+
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
+ """
+ Get branches that can receive a pull request from the source branch.
+
+ Args:
+ source_branch (str): The source branch name.
+
+ Returns:
+ List[str]: List of valid target branch names.
+
+ Raises:
+ NotImplementedError: Always, since PR target validation is not supported for URLFetcher.
+ """
+ raise NotImplementedError("Pull request target branch validation is not supported for generic Git URLs (URLFetcher).")
+
+ def create_pull_request(
+ self,
+ head_branch: str,
+ base_branch: str,
+ title: str,
+ body: str,
+ draft: bool = False,
+ reviewers: Optional[List[str]] = None,
+ assignees: Optional[List[str]] = None,
+ labels: Optional[List[str]] = None
+ ) -> Dict[str, Any]:
+ """
+ Create a pull request between two branches.
+
+ Args:
+ head_branch: Source branch for the PR.
+ base_branch: Target branch for the PR.
+ title: PR title.
+ body: PR description.
+ draft: Whether to create as draft PR (default: False).
+ reviewers: List of reviewer usernames (optional).
+ assignees: List of assignee usernames (optional).
+ labels: List of label names (optional).
+
+ Returns:
+ Dict[str, Any]: Dictionary containing PR metadata or error information.
+
+ Raises:
+ NotImplementedError: Always, since PR creation is not supported for URLFetcher.
+ """
+ raise NotImplementedError("Pull request creation is not supported for generic Git URLs (URLFetcher).")
+
def clear(self) -> None:
"""Clean up temporary directory."""
if self.temp_dir and os.path.exists(self.temp_dir):
diff --git a/setup.py b/setup.py
index 9227908..dff29f7 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@
setup(
name="git-recap",
- version="0.1.4",
+ version="0.1.5",
packages=find_packages(),
install_requires=[
"PyGithub==2.6.1",
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 35ce8f0..b5bacfe 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -1,6 +1,7 @@
import pytest
from datetime import datetime
from unittest.mock import Mock, patch
+from github import GithubException
from git_recap.utils import parse_entries_to_txt
def test_parse_entries_to_txt():
@@ -234,4 +235,396 @@ def test_fetch_releases_not_implemented_providers():
url_fetcher.fetch_releases()
except Exception:
# If URLFetcher can't be instantiated with dummy data, that's fine
- pass
\ No newline at end of file
+ pass
+
+
+class TestGitHubFetcherBranchOperations:
+ """
+ Unit tests for GitHub branch management and pull request creation functionality.
+ """
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_get_branches_returns_branch_list(self, mock_github_class):
+ """
+ Test that get_branches() returns a list of branch names from the repository.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+ mock_branch1 = Mock()
+ mock_branch2 = Mock()
+ mock_branch3 = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+
+ # Configure mock branches
+ mock_branch1.name = "main"
+ mock_branch2.name = "develop"
+ mock_branch3.name = "feature/new-ui"
+ mock_repo.get_branches.return_value = [mock_branch1, mock_branch2, mock_branch3]
+ mock_repo.name = "test-repo"
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+ branches = fetcher.get_branches()
+
+ # Assertions
+ assert isinstance(branches, list)
+ assert len(branches) == 3
+ assert "main" in branches
+ assert "develop" in branches
+ assert "feature/new-ui" in branches
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_get_valid_target_branches_filters_correctly(self, mock_github_class):
+ """
+ Test that get_valid_target_branches() correctly filters branches.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+ mock_repo.name = "test-repo"
+
+ # Configure mock branches
+ mock_branch1 = Mock()
+ mock_branch1.name = "main"
+ mock_branch2 = Mock()
+ mock_branch2.name = "develop"
+ mock_branch3 = Mock()
+ mock_branch3.name = "feature-branch"
+ mock_branch4 = Mock()
+ mock_branch4.name = "hotfix"
+
+ mock_repo.get_branches.return_value = [mock_branch1, mock_branch2, mock_branch3, mock_branch4]
+
+ # Mock existing PR from feature-branch to develop
+ mock_pr = Mock()
+ mock_pr.head.ref = "feature-branch"
+ mock_pr.base.ref = "develop"
+ mock_repo.get_pulls.return_value = [mock_pr]
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+ valid_targets = fetcher.get_valid_target_branches("feature-branch")
+
+ # Assertions
+ assert isinstance(valid_targets, list)
+ # Should exclude source branch (feature-branch) and branch with existing PR (develop)
+ assert "feature-branch" not in valid_targets
+ assert "develop" not in valid_targets
+ assert "main" in valid_targets
+ assert "hotfix" in valid_targets
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_get_valid_target_branches_raises_on_invalid_source(self, mock_github_class):
+ """
+ Test that get_valid_target_branches() raises ValueError for non-existent source branch.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+ mock_repo.name = "test-repo"
+
+ # Configure mock branches (without the source branch)
+ mock_branch1 = Mock()
+ mock_branch1.name = "main"
+ mock_branch2 = Mock()
+ mock_branch2.name = "develop"
+
+ mock_repo.get_branches.return_value = [mock_branch1, mock_branch2]
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+
+ # Should raise ValueError for non-existent source branch
+ with pytest.raises(ValueError) as exc_info:
+ fetcher.get_valid_target_branches("non-existent-branch")
+
+ assert "does not exist" in str(exc_info.value)
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_create_pull_request_success(self, mock_github_class):
+ """
+ Test successful pull request creation with all metadata.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+ mock_pr = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+ mock_repo.name = "test-repo"
+
+ # Configure mock branches
+ mock_branch1 = Mock()
+ mock_branch1.name = "main"
+ mock_branch2 = Mock()
+ mock_branch2.name = "feature-branch"
+ mock_repo.get_branches.return_value = [mock_branch1, mock_branch2]
+
+ # Mock no existing PRs
+ mock_repo.get_pulls.return_value = []
+
+ # Configure mock PR creation
+ mock_pr.html_url = "https://github.com/test/test-repo/pull/1"
+ mock_pr.number = 1
+ mock_pr.state = "open"
+ mock_repo.create_pull.return_value = mock_pr
+
+ # Mock reviewer/assignee/label methods
+ mock_pr.create_review_request = Mock()
+ mock_pr.add_to_assignees = Mock()
+ mock_pr.add_to_labels = Mock()
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+ result = fetcher.create_pull_request(
+ head_branch="feature-branch",
+ base_branch="main",
+ title="New Feature",
+ body="Description of new feature",
+ reviewers=["reviewer1"],
+ assignees=["assignee1"],
+ labels=["enhancement"]
+ )
+
+ # Assertions
+ assert result["success"] is True
+ assert result["url"] == "https://github.com/test/test-repo/pull/1"
+ assert result["number"] == 1
+ assert result["state"] == "open"
+
+ # Verify methods were called
+ mock_repo.create_pull.assert_called_once()
+ mock_pr.create_review_request.assert_called_once_with(reviewers=["reviewer1"])
+ mock_pr.add_to_assignees.assert_called_once_with("assignee1")
+ mock_pr.add_to_labels.assert_called_once_with("enhancement")
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_create_pull_request_handles_branch_not_found(self, mock_github_class):
+ """
+ Test that create_pull_request() handles branch not found errors.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+ mock_repo.name = "test-repo"
+
+ # Configure mock branches (only main exists)
+ mock_branch1 = Mock()
+ mock_branch1.name = "main"
+ mock_repo.get_branches.return_value = [mock_branch1]
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+
+ # Should raise ValueError for non-existent branch
+ with pytest.raises(ValueError) as exc_info:
+ fetcher.create_pull_request(
+ head_branch="non-existent",
+ base_branch="main",
+ title="Test PR",
+ body="Test"
+ )
+
+ assert "does not exist" in str(exc_info.value)
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_create_pull_request_handles_existing_pr(self, mock_github_class):
+ """
+ Test that create_pull_request() handles existing PR scenario.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+ mock_repo.name = "test-repo"
+
+ # Configure mock branches
+ mock_branch1 = Mock()
+ mock_branch1.name = "main"
+ mock_branch2 = Mock()
+ mock_branch2.name = "feature-branch"
+ mock_repo.get_branches.return_value = [mock_branch1, mock_branch2]
+
+ # Mock existing PR
+ mock_pr = Mock()
+ mock_pr.head.ref = "feature-branch"
+ mock_pr.base.ref = "main"
+ mock_repo.get_pulls.return_value = [mock_pr]
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+
+ # Should raise ValueError for existing PR
+ with pytest.raises(ValueError) as exc_info:
+ fetcher.create_pull_request(
+ head_branch="feature-branch",
+ base_branch="main",
+ title="Test PR",
+ body="Test"
+ )
+
+ assert "already exists" in str(exc_info.value)
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_create_pull_request_handles_github_exception(self, mock_github_class):
+ """
+ Test that create_pull_request() handles GithubException errors appropriately.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+ mock_repo.name = "test-repo"
+
+ # Configure mock branches
+ mock_branch1 = Mock()
+ mock_branch1.name = "main"
+ mock_branch2 = Mock()
+ mock_branch2.name = "feature-branch"
+ mock_repo.get_branches.return_value = [mock_branch1, mock_branch2]
+
+ # Mock no existing PRs
+ mock_repo.get_pulls.return_value = []
+
+ # Mock create_pull to raise GithubException
+ mock_repo.create_pull.side_effect = GithubException(403, "Permission denied", None)
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+
+ # Should raise GithubException
+ with pytest.raises(GithubException):
+ fetcher.create_pull_request(
+ head_branch="feature-branch",
+ base_branch="main",
+ title="Test PR",
+ body="Test"
+ )
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_get_branches_handles_api_errors(self, mock_github_class):
+ """
+ Test that get_branches() handles API errors gracefully.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+ mock_repo.name = "test-repo"
+
+ # Mock get_branches to raise GithubException
+ mock_repo.get_branches.side_effect = GithubException(403, "Rate limit exceeded", None)
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+
+ # Should raise Exception with descriptive message
+ with pytest.raises(Exception) as exc_info:
+ fetcher.get_branches()
+
+ assert "Failed to fetch branches" in str(exc_info.value)
+
+ @patch('git_recap.providers.github_fetcher.Github')
+ def test_get_valid_target_branches_handles_api_errors(self, mock_github_class):
+ """
+ Test that get_valid_target_branches() handles API errors gracefully.
+ """
+ from git_recap.providers.github_fetcher import GitHubFetcher
+
+ # Create mock objects
+ mock_github = Mock()
+ mock_user = Mock()
+ mock_repo = Mock()
+
+ # Configure the mock hierarchy
+ mock_github_class.return_value = mock_github
+ mock_github.get_user.return_value = mock_user
+ mock_user.login = "testuser"
+ mock_user.get_repos.return_value = [mock_repo]
+ mock_repo.name = "test-repo"
+
+ # Configure mock branches
+ mock_branch1 = Mock()
+ mock_branch1.name = "main"
+ mock_branch2 = Mock()
+ mock_branch2.name = "feature-branch"
+ mock_repo.get_branches.return_value = [mock_branch1, mock_branch2]
+
+ # Mock get_pulls to raise GithubException
+ mock_repo.get_pulls.side_effect = GithubException(500, "Internal server error", None)
+
+ # Create GitHubFetcher instance and test
+ fetcher = GitHubFetcher(pat="dummy_token")
+
+ # Should raise Exception with descriptive message
+ with pytest.raises(Exception) as exc_info:
+ fetcher.get_valid_target_branches("feature-branch")
+
+ assert "Failed to validate target branches" in str(exc_info.value)
\ No newline at end of file