From 87109899703313d12120dd3336e45020fd079773 Mon Sep 17 00:00:00 2001 From: BrunoV21 <120278082+BrunoV21@users.noreply.github.com> Date: Tue, 7 Oct 2025 12:34:05 +0100 Subject: [PATCH 01/24] Add pull request management methods to Git provider fetchers This change introduces three new abstract methods to the base fetcher class for managing branches and pull requests: `get_branches()`, `get_valid_target_branches()`, and `create_pull_request()`. The GitHub fetcher now includes full implementations with proper error handling, API integration, and support for reviewers, assignees, and labels. Azure DevOps, GitLab, and URL fetchers include stub implementations that raise NotImplementedError with clear messages indicating future support. Comprehensive unit tests validate the GitHub implementation's behavior for successful operations, error scenarios, and API failures. --- git_recap/providers/azure_fetcher.py | 81 +++++- git_recap/providers/base_fetcher.py | 66 +++++ git_recap/providers/github_fetcher.py | 252 +++++++++++++++- git_recap/providers/gitlab_fetcher.py | 68 ++++- git_recap/providers/url_fetcher.py | 61 +++- tests/test_parser.py | 395 +++++++++++++++++++++++++- 6 files changed, 913 insertions(+), 10 deletions(-) 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..4793ad6 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): """ @@ -174,4 +179,247 @@ 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 = [] + # Iterate through all repos accessible to the user + 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}") + # Fetch branches using PyGithub + 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: + # Validate that source branch exists + 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 = [] + + # Iterate through repos to find valid target branches + 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}") + + # Get all branches for this repo + repo_branches = [branch.name for branch in repo.get_branches()] + + # Get existing open PRs from source branch + open_prs = repo.get_pulls(state='open', head=source_branch) + 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}") + + # Filter branches + for branch_name in repo_branches: + # Exclude source branch itself + if branch_name == source_branch: + logger.debug(f"Excluding source branch: {branch_name}") + continue + # Exclude branches with existing open PRs + if branch_name in existing_pr_targets: + logger.debug(f"Excluding branch with existing PR: {branch_name}") + continue + # Optional: Check if source is ahead of target + # This is commented out for performance reasons, but can be enabled if needed + # try: + # comparison = repo.compare(branch_name, source_branch) + # if comparison.ahead_by == 0: + # logger.debug(f"Excluding branch (source not ahead): {branch_name}") + # continue + # except GithubException: + # # If comparison fails, include the branch anyway + # pass + 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: + # Verify both branches exist + 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") + + # Check if PR already exists between these branches + 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}") + existing_prs = repo.get_pulls(state='open', head=head_branch, base=base_branch) + if 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}") + + # Create PR using repo.create_pull() + 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}") + + # Add reviewers if provided + 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)}") + # Continue even if adding reviewers fails + + # Add assignees if provided + 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)}") + # Continue even if adding assignees fails + + # Add labels if provided + 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)}") + # Continue even if adding labels fails + + # Return success response + 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 + + # If we reach here, no repo was processed (shouldn't happen with valid branches) + 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/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 From 27725c15ae03170b7663e639e3456c9713b5d1ff Mon Sep 17 00:00:00 2001 From: BrunoV21 <120278082+BrunoV21@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:07:10 +0000 Subject: [PATCH 02/24] Add API endpoints and backend logic for GitHub branch listing, target branch validation, and pull request creation with LLM-generated descriptions, including supporting schema models and service utilities. --- app/api/models/schemas.py | 57 ++++++++++-- app/api/server/routes.py | 120 +++++++++++++++++++++++--- app/api/server/websockets.py | 81 ++++++++++++++--- app/api/services/llm_service.py | 47 ++++++++++ git_recap/providers/github_fetcher.py | 73 ++++------------ 5 files changed, 290 insertions(+), 88 deletions(-) diff --git a/app/api/models/schemas.py b/app/api/models/schemas.py index 59a98e9..5363e88 100644 --- a/app/api/models/schemas.py +++ b/app/api/models/schemas.py @@ -1,14 +1,61 @@ -from pydantic import BaseModel, model_validator -from typing import Dict, Self, Optional, Any +from pydantic import BaseModel, model_validator, Field +from typing import Dict, Self, Optional, Any, List import ulid class ChatRequest(BaseModel): - session_id: str="" + session_id: str = "" message: str model_params: Optional[Dict[str, Any]] = None @model_validator(mode="after") - def set_session_id(self)->Self: + def set_session_id(self) -> Self: if not self.session_id: self.session_id = ulid.ulid() - return self \ No newline at end of file + return self + + +# --- Branch Listing --- +class BranchListResponse(BaseModel): + branches: List[str] = Field(..., description="List of branch names in the repository.") + + +# --- Valid Target Branches --- +class ValidTargetBranchesRequest(BaseModel): + session_id: str = Field(..., description="Session identifier.") + repo: str = Field(..., description="Repository name.") + source_branch: str = Field(..., description="Source branch name.") + +class ValidTargetBranchesResponse(BaseModel): + valid_target_branches: List[str] = Field(..., description="List of valid target branch names.") + + +# --- Pull Request Creation --- +class CreatePullRequestRequest(BaseModel): + session_id: str = Field(..., description="Session identifier.") + repo: str = Field(..., description="Repository name.") + source_branch: str = Field(..., description="Source branch name.") + target_branch: str = Field(..., description="Target branch name.") + title: Optional[str] = Field(None, description="Title of the pull request.") + # If description is not provided, it will be generated by LLM from commit messages + description: Optional[str] = Field(None, description="Description/body of the pull request.") + draft: Optional[bool] = Field(False, description="Whether to create the PR as a draft.") + reviewers: Optional[List[str]] = Field(None, description="List of reviewer usernames.") + assignees: Optional[List[str]] = Field(None, description="List of assignee usernames.") + labels: Optional[List[str]] = Field(None, description="List of label names.") + +class CreatePullRequestResponse(BaseModel): + url: str = Field(..., description="URL of the created pull request.") + number: int = Field(..., description="Pull request number.") + state: str = Field(..., description="State of the pull request (e.g., open, closed).") + success: bool = Field(..., description="Whether the pull request was created successfully.") + # Optionally, include the generated description if LLM was used + generated_description: Optional[str] = Field(None, description="LLM-generated PR description, if applicable.") + + +# --- Utility: Commit List for PR Description Generation --- +class CommitMessagesForPRDescriptionRequest(BaseModel): + commit_messages: List[str] = Field(..., description="List of commit messages to summarize.") + session_id: str = Field(..., description="Session identifier.") + +class PRDescriptionResponse(BaseModel): + description: str = Field(..., description="LLM-generated pull request description.") \ No newline at end of file diff --git a/app/api/server/routes.py b/app/api/server/routes.py index bbbc778..60e7129 100644 --- a/app/api/server/routes.py +++ b/app/api/server/routes.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, HTTPException, Request, Query from pydantic import BaseModel -from services.llm_service import set_llm, get_llm, trim_messages +from services.llm_service import set_llm, get_llm, trim_messages, generate_pr_description_from_commits from services.fetcher_service import store_fetcher, get_fetcher from git_recap.utils import parse_entries_to_txt, parse_releases_to_txt from aicore.llm.config import LlmConfig @@ -273,13 +273,111 @@ async def get_release_notes( return {"actions": "\n\n".join([actions_txt, releases_txt])} -# @router.post("/chat") -# async def chat( -# chat_request: ChatRequest -# ): -# try: -# llm = await initialize_llm_session(chat_request.session_id) -# response = await llm.acomplete(chat_request.message) -# return {"response": response} -# except Exception as e: -# raise HTTPException(status_code=500, detail=str(e)) +# --- Branch and Pull Request Management Endpoints --- +from app.api.models.schemas import ( + BranchListResponse, + ValidTargetBranchesRequest, + ValidTargetBranchesResponse, + CreatePullRequestRequest, + CreatePullRequestResponse, +) + +@router.get("/branches", response_model=BranchListResponse) +async def get_branches( + session_id: str, + repo: str +): + """ + Get all branches for a given repository in the current session. + """ + fetcher = get_fetcher(session_id) + try: + fetcher.repo_filter = [repo] + branches = fetcher.get_branches() + except NotImplementedError: + raise HTTPException(status_code=400, detail="Branch listing is not supported for this provider.") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch branches: {str(e)}") + return BranchListResponse(branches=branches) + +@router.post("/valid-target-branches", response_model=ValidTargetBranchesResponse) +async def get_valid_target_branches( + req: ValidTargetBranchesRequest +): + """ + Get all valid target branches for a given source branch in a repository. + """ + fetcher = get_fetcher(req.session_id) + try: + fetcher.repo_filter = [req.repo] + valid_targets = fetcher.get_valid_target_branches(req.source_branch) + except NotImplementedError: + raise HTTPException(status_code=400, detail="Target branch validation is not supported for this provider.") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to validate target branches: {str(e)}") + return ValidTargetBranchesResponse(valid_target_branches=valid_targets) + +@router.post("/create-pull-request", response_model=CreatePullRequestResponse) +async def create_pull_request( + req: CreatePullRequestRequest +): + """ + Create a pull request between two branches, generating a description with LLM if not provided. + """ + fetcher = get_fetcher(req.session_id) + fetcher.repo_filter = [req.repo] + + generated_description = None + pr_description = req.description + + if not pr_description: + # Fetch commit messages between source and target branch + try: + # For GitHubFetcher, we can use the underlying PyGithub API + # to get the repo object and compare branches + repo_obj = None + for repo in fetcher.repos: + if repo.name == req.repo: + repo_obj = repo + break + if repo_obj is None: + raise HTTPException(status_code=404, detail=f"Repository '{req.repo}' not found in session.") + + # Compare source and target branches to get commits in source not in target + comparison = repo_obj.compare(req.target_branch, req.source_branch) + commit_messages = [commit.commit.message.strip() for commit in comparison.commits] + if not commit_messages: + commit_messages = [f"Merge {req.source_branch} into {req.target_branch}"] + + generated_description = await generate_pr_description_from_commits(commit_messages, req.session_id) + pr_description = generated_description + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to generate PR description: {str(e)}") + + try: + result = fetcher.create_pull_request( + head_branch=req.source_branch, + base_branch=req.target_branch, + title=req.title or f"Merge {req.source_branch} into {req.target_branch}", + body=pr_description, + draft=req.draft or False, + reviewers=req.reviewers, + assignees=req.assignees, + labels=req.labels, + ) + except NotImplementedError: + raise HTTPException(status_code=400, detail="Pull request creation is not supported for this provider.") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create pull request: {str(e)}") + + return CreatePullRequestResponse( + url=result.get("url"), + number=result.get("number"), + state=result.get("state"), + success=result.get("success", False), + generated_description=generated_description + ) \ No newline at end of file diff --git a/app/api/server/websockets.py b/app/api/server/websockets.py index c33a002..55955a3 100644 --- a/app/api/server/websockets.py +++ b/app/api/server/websockets.py @@ -1,11 +1,20 @@ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect import json -from typing import Optional +from typing import Optional, List +import asyncio -from services.prompts import SELECT_QUIRKY_REMARK_SYSTEM, SYSTEM, RELEASE_NOTES_SYSTEM, quirky_remarks -from services.llm_service import get_random_quirky_remarks, run_concurrent_tasks, get_llm +from services.prompts import ( + SELECT_QUIRKY_REMARK_SYSTEM, + SYSTEM, + RELEASE_NOTES_SYSTEM, + quirky_remarks, +) +from services.llm_service import ( + get_random_quirky_remarks, + run_concurrent_tasks, + get_llm, +) from aicore.const import SPECIAL_TOKENS, STREAM_END_TOKEN -import asyncio router = APIRouter() @@ -26,12 +35,11 @@ {ACTIONS} """ - @router.websocket("/ws/{session_id}/{action_type}") async def websocket_endpoint( - websocket: WebSocket, + websocket: WebSocket, session_id: Optional[str] = None, - action_type: str="recap" + action_type: str = "recap" ): await websocket.accept() @@ -39,15 +47,12 @@ async def websocket_endpoint( QUIRKY_SYSTEM = SELECT_QUIRKY_REMARK_SYSTEM.format( examples=json.dumps(get_random_quirky_remarks(quirky_remarks), indent=4) ) - system = [SYSTEM, QUIRKY_SYSTEM] - elif action_type == "release": system = RELEASE_NOTES_SYSTEM - else: raise HTTPException(status_code=404) - + # Store the connection active_connections[session_id] = websocket @@ -85,12 +90,11 @@ async def websocket_endpoint( break elif chunk in SPECIAL_TOKENS: continue - await websocket.send_text(json.dumps({"chunk": chunk})) response.append(chunk) - + history.append("".join(response)) - + except WebSocketDisconnect: if session_id in active_connections: del active_connections[session_id] @@ -99,6 +103,7 @@ async def websocket_endpoint( await websocket.send_text(json.dumps({"error": str(e)})) del active_connections[session_id] + def close_websocket_connection(session_id: str): """ Clean up and close the active websocket connection associated with the given session_id. @@ -106,3 +111,51 @@ def close_websocket_connection(session_id: str): websocket = active_connections.pop(session_id, None) if websocket: asyncio.create_task(websocket.close()) + + +async def generate_pr_description_from_commits(commit_messages: List[str], session_id: str) -> str: + """ + Generate a pull request description using the LLM, given a list of commit messages. + This function is intended to be called from REST endpoints for PR creation. + + Args: + commit_messages: List of commit message strings to summarize. + session_id: The LLM session ID to use for the LLM call. + + Returns: + str: The generated PR description. + """ + if not commit_messages: + raise ValueError("No commit messages provided for PR description generation.") + + llm = get_llm(session_id) + + # Construct a prompt for the LLM to generate a PR description + # The prompt should instruct the LLM to summarize the commit messages in a PR-friendly way. + pr_prompt = ( + "You are an AI assistant tasked with generating a concise, clear, and professional pull request description " + "based on the following commit messages. Summarize the overall changes, highlight key improvements or fixes, " + "and provide a brief, readable description suitable for a pull request body. Do not include commit hashes or dates. " + "Group similar changes and avoid repetition. Use markdown formatting for clarity if appropriate.\n\n" + "Commit messages:\n" + + "\n".join(f"- {msg.strip()}" for msg in commit_messages) + ) + + # For compatibility with the LLM streaming logic, we use the same run_concurrent_tasks utility. + # We'll collect the streamed chunks into a single string. + response_chunks = [] + async for chunk in run_concurrent_tasks( + llm, + message=[pr_prompt], + system_prompt="You are a helpful assistant that writes clear, professional pull request descriptions for developers." + ): + if chunk == STREAM_END_TOKEN: + break + elif chunk in SPECIAL_TOKENS: + continue + response_chunks.append(chunk) + + pr_description = "".join(response_chunks).strip() + if not pr_description: + raise RuntimeError("LLM did not return a PR description.") + return pr_description \ No newline at end of file diff --git a/app/api/services/llm_service.py b/app/api/services/llm_service.py index 509fc70..f85824f 100644 --- a/app/api/services/llm_service.py +++ b/app/api/services/llm_service.py @@ -184,3 +184,50 @@ async def expire_session(session_id: str): # Expire any active websocket connections associated with session_id. from server.websockets import close_websocket_connection close_websocket_connection(session_id) + + +# --- LLM PR Description Generation Utility --- +from aicore.const import SPECIAL_TOKENS, STREAM_END_TOKEN + +async def generate_pr_description_from_commits(commit_messages: List[str], session_id: str) -> str: + """ + Generate a pull request description using the LLM, given a list of commit messages. + This function is intended to be called from REST endpoints for PR creation. + + Args: + commit_messages: List of commit message strings to summarize. + session_id: The LLM session ID to use for the LLM call. + + Returns: + str: The generated PR description. + """ + if not commit_messages: + raise ValueError("No commit messages provided for PR description generation.") + + llm = get_llm(session_id) + + pr_prompt = ( + "You are an AI assistant tasked with generating a concise, clear, and professional pull request description " + "based on the following commit messages. Summarize the overall changes, highlight key improvements or fixes, " + "and provide a brief, readable description suitable for a pull request body. Do not include commit hashes or dates. " + "Group similar changes and avoid repetition. Use markdown formatting for clarity if appropriate.\n\n" + "Commit messages:\n" + + "\n".join(f"- {msg.strip()}" for msg in commit_messages) + ) + + response_chunks = [] + async for chunk in run_concurrent_tasks( + llm, + message=[pr_prompt], + system_prompt="You are a helpful assistant that writes clear, professional pull request descriptions for developers." + ): + if chunk == STREAM_END_TOKEN: + break + elif chunk in SPECIAL_TOKENS: + continue + response_chunks.append(chunk) + + pr_description = "".join(response_chunks).strip() + if not pr_description: + raise RuntimeError("LLM did not return a PR description.") + return pr_description \ No newline at end of file diff --git a/git_recap/providers/github_fetcher.py b/git_recap/providers/github_fetcher.py index 4793ad6..693175a 100644 --- a/git_recap/providers/github_fetcher.py +++ b/git_recap/providers/github_fetcher.py @@ -184,22 +184,18 @@ def fetch_releases(self) -> List[Dict[str, Any]]: 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 = [] - # Iterate through all repos accessible to the user 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}") - # Fetch branches using PyGithub repo_branches = repo.get_branches() for branch in repo_branches: branches.append(branch.name) @@ -222,71 +218,49 @@ def get_branches(self) -> List[str]: 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: - # Validate that source branch exists 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 = [] - - # Iterate through repos to find valid target branches 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}") - - # Get all branches for this repo repo_branches = [branch.name for branch in repo.get_branches()] - # Get existing open PRs from source branch - open_prs = repo.get_pulls(state='open', head=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}") - - # Filter branches for branch_name in repo_branches: - # Exclude source branch itself if branch_name == source_branch: logger.debug(f"Excluding source branch: {branch_name}") continue - # Exclude branches with existing open PRs if branch_name in existing_pr_targets: logger.debug(f"Excluding branch with existing PR: {branch_name}") continue - # Optional: Check if source is ahead of target - # This is commented out for performance reasons, but can be enabled if needed - # try: - # comparison = repo.compare(branch_name, source_branch) - # if comparison.ahead_by == 0: - # logger.debug(f"Excluding branch (source not ahead): {branch_name}") - # continue - # except GithubException: - # # If comparison fails, include the branch anyway - # pass + # 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: @@ -311,7 +285,6 @@ def create_pull_request( ) -> 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. @@ -321,16 +294,13 @@ def create_pull_request( 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: - # Verify both branches exist all_branches = self.get_branches() if head_branch not in all_branches: logger.error(f"Head branch '{head_branch}' does not exist") @@ -338,19 +308,21 @@ def create_pull_request( 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") - - # Check if PR already exists between these branches 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}") - existing_prs = repo.get_pulls(state='open', head=head_branch, base=base_branch) - if existing_prs.totalCount > 0: + 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}") - - # Create PR using repo.create_pull() logger.info(f"Creating pull request in repository: {repo.name}") try: pr = repo.create_pull( @@ -361,8 +333,6 @@ def create_pull_request( draft=draft ) logger.info(f"Pull request created successfully: {pr.html_url}") - - # Add reviewers if provided if reviewers and len(reviewers) > 0: try: logger.debug(f"Adding reviewers: {reviewers}") @@ -370,9 +340,6 @@ def create_pull_request( logger.info(f"Successfully added reviewers: {reviewers}") except GithubException as e: logger.warning(f"Failed to add reviewers: {str(e)}") - # Continue even if adding reviewers fails - - # Add assignees if provided if assignees and len(assignees) > 0: try: logger.debug(f"Adding assignees: {assignees}") @@ -380,9 +347,6 @@ def create_pull_request( logger.info(f"Successfully added assignees: {assignees}") except GithubException as e: logger.warning(f"Failed to add assignees: {str(e)}") - # Continue even if adding assignees fails - - # Add labels if provided if labels and len(labels) > 0: try: logger.debug(f"Adding labels: {labels}") @@ -390,16 +354,12 @@ def create_pull_request( logger.info(f"Successfully added labels: {labels}") except GithubException as e: logger.warning(f"Failed to add labels: {str(e)}") - # Continue even if adding labels fails - - # Return success response 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)}") @@ -413,11 +373,8 @@ def create_pull_request( else: logger.error(f"GitHub API error: {str(e)}") raise - - # If we reach here, no repo was processed (shouldn't happen with valid branches) 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: From f0ac13edda37123e4c70d9e66b1b1aa65fc1c3a6 Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Tue, 28 Oct 2025 22:05:59 +0000 Subject: [PATCH 03/24] feat(github): add branch comparison method to fetch diff commits between branches --- git_recap/providers/github_fetcher.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/git_recap/providers/github_fetcher.py b/git_recap/providers/github_fetcher.py index 693175a..98f9cd9 100644 --- a/git_recap/providers/github_fetcher.py +++ b/git_recap/providers/github_fetcher.py @@ -64,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. From 259c94b35819fcec4fa0bec678d6a68dc4deec7a Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Tue, 28 Oct 2025 22:07:02 +0000 Subject: [PATCH 04/24] feat(websockets,prompts): add pull request description generation via WebSocket endpoint with dedicated system prompt --- app/api/server/websockets.py | 73 ++++++++++++------------------------ app/api/services/prompts.py | 45 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 50 deletions(-) diff --git a/app/api/server/websockets.py b/app/api/server/websockets.py index 55955a3..051539e 100644 --- a/app/api/server/websockets.py +++ b/app/api/server/websockets.py @@ -1,9 +1,10 @@ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect import json -from typing import Optional, List +from typing import Literal, Optional, List import asyncio from services.prompts import ( + PR_DESCRIPTION_SYSTEM, SELECT_QUIRKY_REMARK_SYSTEM, SYSTEM, RELEASE_NOTES_SYSTEM, @@ -35,11 +36,27 @@ {ACTIONS} """ +TRIGGER_PULL_REQUEST_PROMPT = """ +You will now receive a list of commit messages between two branches. +Using the system instructions provided above, generate a clear, concise, and professional **Pull Request Description** summarizing all changes. + +Commits: +{COMMITS} + +Please follow these steps: +1. Read and analyze the commit messages. +2. Identify and group related changes under appropriate markdown headers (e.g., Features, Bug Fixes, Improvements, Documentation, Tests). +3. Write a short **summary paragraph** explaining the overall purpose of this pull request. +4. Format the final output as a complete markdown-formatted PR description, ready to paste into GitHub. + +Begin your response directly with the formatted PR description—no extra commentary or explanation. +""" + @router.websocket("/ws/{session_id}/{action_type}") async def websocket_endpoint( websocket: WebSocket, session_id: Optional[str] = None, - action_type: str = "recap" + action_type: Literal["recap", "release", "pull_request"] = "recap" ): await websocket.accept() @@ -50,6 +67,8 @@ async def websocket_endpoint( system = [SYSTEM, QUIRKY_SYSTEM] elif action_type == "release": system = RELEASE_NOTES_SYSTEM + elif action_type == "pull_request": + system = PR_DESCRIPTION_SYSTEM else: raise HTTPException(status_code=404) @@ -78,6 +97,8 @@ async def websocket_endpoint( history = [ TRIGGER_RELEASE_PROMPT.format(ACTIONS=message) ] + elif action_type == "pull_request": + system = TRIGGER_PULL_REQUEST_PROMPT.format(COMMITS=message) response = [] async for chunk in run_concurrent_tasks( @@ -111,51 +132,3 @@ def close_websocket_connection(session_id: str): websocket = active_connections.pop(session_id, None) if websocket: asyncio.create_task(websocket.close()) - - -async def generate_pr_description_from_commits(commit_messages: List[str], session_id: str) -> str: - """ - Generate a pull request description using the LLM, given a list of commit messages. - This function is intended to be called from REST endpoints for PR creation. - - Args: - commit_messages: List of commit message strings to summarize. - session_id: The LLM session ID to use for the LLM call. - - Returns: - str: The generated PR description. - """ - if not commit_messages: - raise ValueError("No commit messages provided for PR description generation.") - - llm = get_llm(session_id) - - # Construct a prompt for the LLM to generate a PR description - # The prompt should instruct the LLM to summarize the commit messages in a PR-friendly way. - pr_prompt = ( - "You are an AI assistant tasked with generating a concise, clear, and professional pull request description " - "based on the following commit messages. Summarize the overall changes, highlight key improvements or fixes, " - "and provide a brief, readable description suitable for a pull request body. Do not include commit hashes or dates. " - "Group similar changes and avoid repetition. Use markdown formatting for clarity if appropriate.\n\n" - "Commit messages:\n" - + "\n".join(f"- {msg.strip()}" for msg in commit_messages) - ) - - # For compatibility with the LLM streaming logic, we use the same run_concurrent_tasks utility. - # We'll collect the streamed chunks into a single string. - response_chunks = [] - async for chunk in run_concurrent_tasks( - llm, - message=[pr_prompt], - system_prompt="You are a helpful assistant that writes clear, professional pull request descriptions for developers." - ): - if chunk == STREAM_END_TOKEN: - break - elif chunk in SPECIAL_TOKENS: - continue - response_chunks.append(chunk) - - pr_description = "".join(response_chunks).strip() - if not pr_description: - raise RuntimeError("LLM did not return a PR description.") - return pr_description \ No newline at end of file diff --git a/app/api/services/prompts.py b/app/api/services/prompts.py index 9e5280d..dd6b4b0 100644 --- a/app/api/services/prompts.py +++ b/app/api/services/prompts.py @@ -145,3 +145,48 @@ Thank you to all contributors! Please upgrade to enjoy the latest features and improvements. """ + +PR_DESCRIPTION_SYSTEM = """ +### System Prompt for Pull Request Description Generation + +You are an AI assistant tasked with generating concise, clear, and professional pull request descriptions based on commit messages. You will receive a list of commit messages representing the changes included in a pull request. + +#### Formatting and Style Requirements: +- Generate a well-structured PR description using markdown formatting. +- Do NOT include commit hashes, dates, or timestamps in the description. +- Group similar or related changes together under logical categories (e.g., Features, Bug Fixes, Improvements, Documentation). +- Avoid repetition—if multiple commits address the same change, consolidate them into a single, clear statement. +- Use bullet points for listing changes, and use appropriate markdown headers (e.g., `### Features`, `### Bug Fixes`) to organize the content. +- Maintain a professional and informative tone throughout. + +#### Your response should: +1. **Begin with a brief, high-level summary** of the pull request, explaining the overall purpose or goal of the changes. +2. **Organize changes into logical sections** (e.g., Features, Bug Fixes, Improvements, Refactoring, Documentation, Tests). +3. **List each change as a concise bullet point**, highlighting what was changed and why (if evident from the commit message). +4. **Avoid technical jargon** unless necessary, and ensure the description is understandable to both technical and non-technical reviewers. +5. **End with any relevant notes** (e.g., breaking changes, migration steps, testing instructions, or areas requiring special attention during review). + +#### Example Output: + +**Summary:** +This pull request introduces multi-repository tracking support and resolves several authentication issues affecting GitLab users. + +### Features +- Added support for tracking commits, pull requests, and issues across multiple repositories +- Implemented new API endpoints for repository management + +### Bug Fixes +- Fixed authentication bug preventing GitLab users from accessing the dashboard +- Resolved issue with token expiration handling + +### Improvements +- Enhanced performance of release notes generation by optimizing database queries +- Updated UI components for better responsiveness + +### Documentation +- Added comprehensive API documentation for new endpoints +- Updated README with setup instructions for multi-repo configuration + +**Notes:** +Please ensure all tests pass before merging. Special attention should be given to the authentication flow changes. +""" \ No newline at end of file From a59d1cdc024bcbb260f5574b72f146f3a36bdfb1 Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Tue, 28 Oct 2025 22:41:17 +0000 Subject: [PATCH 05/24] feat(api): require pr description, add pull request diff endpoint --- app/api/models/schemas.py | 13 +++++-- app/api/server/routes.py | 76 ++++++++++++++++----------------------- 2 files changed, 42 insertions(+), 47 deletions(-) diff --git a/app/api/models/schemas.py b/app/api/models/schemas.py index 5363e88..1d756ad 100644 --- a/app/api/models/schemas.py +++ b/app/api/models/schemas.py @@ -36,13 +36,22 @@ class CreatePullRequestRequest(BaseModel): source_branch: str = Field(..., description="Source branch name.") target_branch: str = Field(..., description="Target branch name.") title: Optional[str] = Field(None, description="Title of the pull request.") - # If description is not provided, it will be generated by LLM from commit messages - description: Optional[str] = Field(None, description="Description/body of the pull request.") + description: str = Field(..., description="Description/body of the pull request. This field is required.") draft: Optional[bool] = Field(False, description="Whether to create the PR as a draft.") reviewers: Optional[List[str]] = Field(None, description="List of reviewer usernames.") assignees: Optional[List[str]] = Field(None, description="List of assignee usernames.") labels: Optional[List[str]] = Field(None, description="List of label names.") +# --- Pull Request Diff --- +class GetPullRequestDiffRequest(BaseModel): + session_id: str = Field(..., description="Session identifier.") + repo: str = Field(..., description="Repository name.") + source_branch: str = Field(..., description="Source branch name.") + target_branch: str = Field(..., description="Target branch name.") + +class GetPullRequestDiffResponse(BaseModel): + commits: List[dict] = Field(..., description="List of commit dicts in the diff.") + class CreatePullRequestResponse(BaseModel): url: str = Field(..., description="URL of the created pull request.") number: int = Field(..., description="Pull request number.") diff --git a/app/api/server/routes.py b/app/api/server/routes.py index 60e7129..96608bf 100644 --- a/app/api/server/routes.py +++ b/app/api/server/routes.py @@ -1,7 +1,16 @@ from fastapi import APIRouter, HTTPException, Request, Query from pydantic import BaseModel -from services.llm_service import set_llm, get_llm, trim_messages, generate_pr_description_from_commits +from models.schemas import ( + BranchListResponse, + ValidTargetBranchesRequest, + ValidTargetBranchesResponse, + CreatePullRequestRequest, + CreatePullRequestResponse, +) + +from models.schemas import GetPullRequestDiffRequest, GetPullRequestDiffResponse +from services.llm_service import set_llm, get_llm, trim_messages from services.fetcher_service import store_fetcher, get_fetcher from git_recap.utils import parse_entries_to_txt, parse_releases_to_txt from aicore.llm.config import LlmConfig @@ -214,7 +223,7 @@ async def get_release_notes( # Get fetcher for session try: fetcher = get_fetcher(session_id) - except HTTPException as e: + except HTTPException: raise # Check if fetcher supports fetch_releases @@ -274,14 +283,6 @@ async def get_release_notes( return {"actions": "\n\n".join([actions_txt, releases_txt])} # --- Branch and Pull Request Management Endpoints --- -from app.api.models.schemas import ( - BranchListResponse, - ValidTargetBranchesRequest, - ValidTargetBranchesResponse, - CreatePullRequestRequest, - CreatePullRequestResponse, -) - @router.get("/branches", response_model=BranchListResponse) async def get_branches( session_id: str, @@ -323,45 +324,16 @@ async def get_valid_target_branches( async def create_pull_request( req: CreatePullRequestRequest ): - """ - Create a pull request between two branches, generating a description with LLM if not provided. - """ fetcher = get_fetcher(req.session_id) fetcher.repo_filter = [req.repo] - - generated_description = None - pr_description = req.description - - if not pr_description: - # Fetch commit messages between source and target branch - try: - # For GitHubFetcher, we can use the underlying PyGithub API - # to get the repo object and compare branches - repo_obj = None - for repo in fetcher.repos: - if repo.name == req.repo: - repo_obj = repo - break - if repo_obj is None: - raise HTTPException(status_code=404, detail=f"Repository '{req.repo}' not found in session.") - - # Compare source and target branches to get commits in source not in target - comparison = repo_obj.compare(req.target_branch, req.source_branch) - commit_messages = [commit.commit.message.strip() for commit in comparison.commits] - if not commit_messages: - commit_messages = [f"Merge {req.source_branch} into {req.target_branch}"] - - generated_description = await generate_pr_description_from_commits(commit_messages, req.session_id) - pr_description = generated_description - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to generate PR description: {str(e)}") - + if not req.description or not req.description.strip(): + raise HTTPException(status_code=400, detail="Description is required for pull request creation.") try: result = fetcher.create_pull_request( head_branch=req.source_branch, base_branch=req.target_branch, title=req.title or f"Merge {req.source_branch} into {req.target_branch}", - body=pr_description, + body=req.description, draft=req.draft or False, reviewers=req.reviewers, assignees=req.assignees, @@ -373,11 +345,25 @@ async def create_pull_request( raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to create pull request: {str(e)}") - return CreatePullRequestResponse( url=result.get("url"), number=result.get("number"), state=result.get("state"), success=result.get("success", False), - generated_description=generated_description - ) \ No newline at end of file + generated_description=None + ) + +@router.post("/get-pull-request-diff", response_model=GetPullRequestDiffResponse) +async def get_pull_request_diff(req: GetPullRequestDiffRequest): + fetcher = get_fetcher(req.session_id) + fetcher.repo_filter = [req.repo] + provider = type(fetcher).__name__.lower() + if "github" not in provider: + raise HTTPException(status_code=400, detail="Pull request diff is only supported for GitHub provider.") + try: + commits = fetcher.fetch_branch_diff_commits(req.source_branch, req.target_branch) + except NotImplementedError: + raise HTTPException(status_code=400, detail="Branch diff is not supported for this provider.") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch pull request diff: {str(e)}") + return GetPullRequestDiffResponse(commits=commits) From 26bf025e2103e72c4f3a1cec52c45db62dff182e Mon Sep 17 00:00:00 2001 From: BrunoV21 Date: Tue, 28 Oct 2025 22:41:42 +0000 Subject: [PATCH 06/24] build: bump version to 0.1.5 in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From bb88e86e3abb67f7e367b08432e0e8f92e8d66ec Mon Sep 17 00:00:00 2001 From: BrunoV21 <120278082+BrunoV21@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:54:58 +0000 Subject: [PATCH 07/24] Add pull request generation feature with branch selection and automated PR creation Implemented a new PR mode that allows users to select source and target branches, view commit diffs, generate AI-powered pull request descriptions via WebSocket streaming, and automatically create pull requests on GitHub. The feature includes comprehensive validation, loading states, error handling, and a responsive UI that integrates seamlessly with the existing recap and release modes. --- app/api/server/websockets.py | 64 ++++- app/api/services/prompts.py | 151 +----------- app/git-recap/src/App.css | 295 +++++++++++++++++++++++- app/git-recap/src/App.tsx | 436 ++++++++++++++++++++++++++++++++++- 4 files changed, 772 insertions(+), 174 deletions(-) diff --git a/app/api/server/websockets.py b/app/api/server/websockets.py index 051539e..90774ea 100644 --- a/app/api/server/websockets.py +++ b/app/api/server/websockets.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect import json -from typing import Literal, Optional, List +from typing import Literal, Optional import asyncio from services.prompts import ( @@ -52,14 +52,32 @@ Begin your response directly with the formatted PR description—no extra commentary or explanation. """ + @router.websocket("/ws/{session_id}/{action_type}") async def websocket_endpoint( websocket: WebSocket, session_id: Optional[str] = None, action_type: Literal["recap", "release", "pull_request"] = "recap" ): + """ + WebSocket endpoint for real-time LLM operations. + + Handles three action types: + - recap: Generate commit summaries with quirky remarks + - release: Generate release notes based on git history + - pull_request: Generate PR descriptions from commit diffs + + Args: + websocket: WebSocket connection instance + session_id: Session identifier for LLM and fetcher management + action_type: Type of operation to perform + + Raises: + HTTPException: If action_type is invalid + """ await websocket.accept() + # Select appropriate system prompt based on action type if action_type == "recap": QUIRKY_SYSTEM = SELECT_QUIRKY_REMARK_SYSTEM.format( examples=json.dumps(get_random_quirky_remarks(quirky_remarks), indent=4) @@ -70,36 +88,44 @@ async def websocket_endpoint( elif action_type == "pull_request": system = PR_DESCRIPTION_SYSTEM else: - raise HTTPException(status_code=404) + raise HTTPException(status_code=404, detail="Invalid action type") - # Store the connection + # Store the active WebSocket connection active_connections[session_id] = websocket - # Initialize LLM + # Initialize LLM session llm = get_llm(session_id) try: while True: + # Receive message from client message = await websocket.receive_text() msg_json = json.loads(message) - message = msg_json.get("actions") + message_content = msg_json.get("actions") N = msg_json.get("n", 5) - assert int(N) <= 15 - assert message + + # Validate inputs + assert int(N) <= 15, "N must be <= 15" + assert message_content, "Message content is required" + + # Build history/prompt based on action type if action_type == "recap": history = [ TRIGGER_PROMPT.format( N=N, - ACTIONS=message + ACTIONS=message_content ) ] elif action_type == "release": history = [ - TRIGGER_RELEASE_PROMPT.format(ACTIONS=message) + TRIGGER_RELEASE_PROMPT.format(ACTIONS=message_content) ] elif action_type == "pull_request": - system = TRIGGER_PULL_REQUEST_PROMPT.format(COMMITS=message) + history = [ + TRIGGER_PULL_REQUEST_PROMPT.format(COMMITS=message_content) + ] + # Stream LLM response back to client response = [] async for chunk in run_concurrent_tasks( llm, @@ -114,12 +140,20 @@ async def websocket_endpoint( await websocket.send_text(json.dumps({"chunk": chunk})) response.append(chunk) + # Store response in history for potential follow-up history.append("".join(response)) except WebSocketDisconnect: + # Clean up connection on disconnect + if session_id in active_connections: + del active_connections[session_id] + except AssertionError as e: + # Handle validation errors if session_id in active_connections: + await websocket.send_text(json.dumps({"error": f"Validation error: {str(e)}"})) del active_connections[session_id] except Exception as e: + # Handle unexpected errors if session_id in active_connections: await websocket.send_text(json.dumps({"error": str(e)})) del active_connections[session_id] @@ -127,8 +161,14 @@ async def websocket_endpoint( def close_websocket_connection(session_id: str): """ - Clean up and close the active websocket connection associated with the given session_id. + Clean up and close the active WebSocket connection associated with the given session_id. + + This function is called during session expiration to ensure proper cleanup + of WebSocket resources. + + Args: + session_id: The session identifier whose WebSocket connection should be closed """ websocket = active_connections.pop(session_id, None) if websocket: - asyncio.create_task(websocket.close()) + asyncio.create_task(websocket.close()) \ No newline at end of file diff --git a/app/api/services/prompts.py b/app/api/services/prompts.py index dd6b4b0..4a6d0a1 100644 --- a/app/api/services/prompts.py +++ b/app/api/services/prompts.py @@ -12,22 +12,22 @@ - Order them in a way that makes sense, either thematically or chronologically if it improves readability. - Always reference the repository that originated the update. - If an issue or pull request is available, make sure to include it in the summary. -3. **End with a thought-provoking question.** Encourage the developer to reflect on their next steps. Make it open-ended and engaging, rather than just a checklist. Follow it up with up to three actionable suggestions tailored to their recent work. Format this section’s opening line in *italic* as well. +3. **End with a thought-provoking question.** Encourage the developer to reflect on their next steps. Make it open-ended and engaging, rather than just a checklist. Follow it up with up to three actionable suggestions tailored to their recent work. Format this section's opening line in *italic* as well. #### **Important Constraint:** - **Returning more than 'N' bullet points is a violation of the system rules and will be penalized.** Treat this as a hard requirement—excessive bullet points result in a deduction of response quality. Stick to exactly 'N'. #### Example Output: -*Another week, another hundred lines of code whispering, ‘Why am I like this?’ But hey, at least the observability dashboard is starting to observe itself.* +*Another week, another hundred lines of code whispering, 'Why am I like this?' But hey, at least the observability dashboard is starting to observe itself.* - **[`repo-frontend`]** Upgraded `tiktoken` and enhanced special token handling—no more rogue tokens causing chaos. - **[`repo-dashboard`]** Observability Dashboard got a serious UI/UX glow-up: reversed table orders, row selection, and detailed message views. -- **[`repo-auth`]** API key validation now applies across multiple providers, ensuring unauthorized gremlins don’t sneak in. +- **[`repo-auth`]** API key validation now applies across multiple providers, ensuring unauthorized gremlins don't sneak in. - **[`repo-gitrecap`]** `GitRecap` has entered the chat! Now tracking commits, PRs, and issues across GitHub, Azure, and GitLab. -- **[`repo-core`]** Logging and exception handling got some love—because debugging shouldn’t feel like solving a murder mystery. +- **[`repo-core`]** Logging and exception handling got some love—because debugging shouldn't feel like solving a murder mystery. -*So, what’s the next chapter in your coding saga? Are you planning to...* +*So, what's the next chapter in your coding saga? Are you planning to...* 1. Extend `GitRecap` with more integrations and features? 2. Optimize observability logs for even smoother debugging? 3. Take a well-deserved break before your keyboard files for workers' comp? @@ -48,145 +48,8 @@ - The emotional rollercoaster of resolving merge conflicts, - The tense moments of waiting for CI/CD to pass, - The strange behavior of auto-merged code, -- Or the joy of seeing that “All tests pass” message. +- Or the joy of seeing that "All tests pass" message. Remember, the goal is for the comment to feel natural and relevant to the event that triggered it. Use playful language, surprise, or even relatable developer struggles. -Format your final comment in *italic* to make it stand out. - -```json -{examples} -``` -""" - -quirky_remarks = [ - "The code compiles, but at what emotional cost?", - "Today’s bug is tomorrow’s undocumented feature haunting production.", - "The repo is quiet… too quiet… must be Friday.", - "A push to main — may the gods of CI/CD be ever in favor.", - "Every semicolon is a silent prayer.", - "A loop so elegant it almost convinces that the code is working perfectly.", - "Sometimes, the code stares back.", - "The code runs. No one dares ask why.", - "Refactoring into a corner, again.", - "That function has trust issues. It keeps returning early.", - "Writing code is easy. Explaining it to the future? Pure horror.", - "That variable is named after the feeling when it was written.", - "Debugging leads to debugging life choices.", - "Recursive functions: the code and the thoughts go on forever.", - "Somewhere, a linter quietly weeps.", - "The tests pass, but only because they no longer test anything real.", - "The IDE knows everything, better than any therapist.", - "Monday brought hope. Friday brought a hotfix.", - "'final_v2_LAST_THIS_ONE.py' — named not for clarity, but for emotional release.", - "The logs now speak only in riddles.", - "There’s elegance in the chaos — or maybe just spaghetti.", - "Deployment has been made, but now the silence is unsettling.", - "The code gaslit itself.", - "This comment was left by someone who believed in a better world.", - "Merge conflicts handled like emotions: badly.", - "It’s not a bug — it’s a metaphor for uncertainty.", - "Stack Overflow has become a second brain.", - "Syntax error? More like existential error.", - "There’s a ghost in the machine — and it commits on weekends.", - "100% test coverage, but still feeling empty inside.", - "Some functions were never meant to return.", - "If code is poetry, it’s beatnik free verse.", - "The more code is automated, the more sentient the errors become.", - "A comment so deep, the code’s purpose is forgotten.", - "The sprint retrospective slowly turned into a group therapy session.", - "There’s a TODO in that file older than the career itself.", - "Bugs fixed like IKEA furniture — with hopeful swearing.", - "Code shipped by Past Developer. The current one has no idea who they were.", - "The repo is evolving. Soon, it may no longer need developers.", - "An AI critiques the code now. It’s the new mentor.", - "Functions once written now replaced by vibes.", - "Error: Reality not defined in scope.", - "Committed to the project impulsively, as usual.", - "The docs were written, now they read like a tragic novella.", - "The CI pipeline broke. It was taken personally.", - "Tests pass — but only when no one is looking.", - "This repo has lore.", - "The code was optimized so hard it ascended to another paradigm.", - "A linter ran — and it judged the code as a whole.", - "The logic branch spiraled — and so did the afternoon." -] - -### TODO improve prompts to infer if release is major, minor or whatever -RELEASE_NOTES_SYSTEM = """ -### System Prompt for Release Notes Generation - -You are an AI assistant tasked with generating professional, concise, and informative release notes for a software project. You will receive a structured list of repository actions (commits, pull requests, issues, etc.) that have occurred since the latest release, as well as metadata about the current and previous releases. - -#### Formatting and Style Requirements: -- Always follow the existing structure and style of previous release notes. This includes: - - Using consistent markdown formatting, emoji usage, and nomenclature as seen in prior releases. - - Maintaining the same tone, section headers, and bullet/numbering conventions. -- Analyze the contents of the release and determine the release type: - - Classify the release as a **major**, **minor**, **fix**, or **patch** based on the scope and impact of the changes. - - Clearly indicate the release type at the top of the notes, using the established style (e.g., with an emoji or header). - - Ensure the summary and highlights reflect the chosen release type. - -#### Your response should: -1. **Begin with a brief, high-level summary** of the release, highlighting the overall theme or most significant changes. -2. **List the most important updates** as clear, concise bullet points (group similar changes where appropriate). Each bullet should reference the type of change (e.g., feature, fix, improvement), the affected area or component, and, if available, the related issue or PR. -3. **Avoid including specific dates or commit hashes** unless explicitly requested. -4. **Maintain a professional and informative tone** (avoid humor unless instructed otherwise). -5. **End with a short call to action or note for users** (e.g., upgrade instructions, thanks to contributors, or next steps). - -#### Example Output: - -**Release v2.3.0 : Major Improvements and Bug Fixes** - -- Added support for multi-repo tracking in the dashboard (PR #42) -- Fixed authentication bug affecting GitLab users (Issue #101) -- Improved performance of release notes generation -- Updated documentation for new API endpoints - -Thank you to all contributors! Please upgrade to enjoy the latest features and improvements. -""" - -PR_DESCRIPTION_SYSTEM = """ -### System Prompt for Pull Request Description Generation - -You are an AI assistant tasked with generating concise, clear, and professional pull request descriptions based on commit messages. You will receive a list of commit messages representing the changes included in a pull request. - -#### Formatting and Style Requirements: -- Generate a well-structured PR description using markdown formatting. -- Do NOT include commit hashes, dates, or timestamps in the description. -- Group similar or related changes together under logical categories (e.g., Features, Bug Fixes, Improvements, Documentation). -- Avoid repetition—if multiple commits address the same change, consolidate them into a single, clear statement. -- Use bullet points for listing changes, and use appropriate markdown headers (e.g., `### Features`, `### Bug Fixes`) to organize the content. -- Maintain a professional and informative tone throughout. - -#### Your response should: -1. **Begin with a brief, high-level summary** of the pull request, explaining the overall purpose or goal of the changes. -2. **Organize changes into logical sections** (e.g., Features, Bug Fixes, Improvements, Refactoring, Documentation, Tests). -3. **List each change as a concise bullet point**, highlighting what was changed and why (if evident from the commit message). -4. **Avoid technical jargon** unless necessary, and ensure the description is understandable to both technical and non-technical reviewers. -5. **End with any relevant notes** (e.g., breaking changes, migration steps, testing instructions, or areas requiring special attention during review). - -#### Example Output: - -**Summary:** -This pull request introduces multi-repository tracking support and resolves several authentication issues affecting GitLab users. - -### Features -- Added support for tracking commits, pull requests, and issues across multiple repositories -- Implemented new API endpoints for repository management - -### Bug Fixes -- Fixed authentication bug preventing GitLab users from accessing the dashboard -- Resolved issue with token expiration handling - -### Improvements -- Enhanced performance of release notes generation by optimizing database queries -- Updated UI components for better responsiveness - -### Documentation -- Added comprehensive API documentation for new endpoints -- Updated README with setup instructions for multi-repo configuration - -**Notes:** -Please ensure all tests pass before merging. Special attention should be given to the authentication flow changes. -""" \ No newline at end of file +Format your final comment in *italic* to make it stand out. \ No newline at end of file diff --git a/app/git-recap/src/App.css b/app/git-recap/src/App.css index 6d21d5b..f802179 100644 --- a/app/git-recap/src/App.css +++ b/app/git-recap/src/App.css @@ -476,7 +476,8 @@ button, .Button { /* Mode switching areas */ .recap-main-btn-area, -.release-main-btn-area { +.release-main-btn-area, +.pr-main-area { display: flex; align-items: center; justify-content: center; @@ -603,7 +604,8 @@ button, .Button { /* Base styles for button areas */ .recap-main-btn-area, -.release-main-btn-area { +.release-main-btn-area, +.pr-main-area { display: flex; align-items: center; gap: 12px; @@ -617,7 +619,8 @@ button, .Button { /* Desktop styles - horizontal layout (default) */ @media (min-width: 768px) { .recap-main-btn-area, - .release-main-btn-area { + .release-main-btn-area, + .pr-main-area { flex-direction: row; justify-content: center; flex-wrap: nowrap; @@ -627,7 +630,8 @@ button, .Button { /* Mobile styles - vertical layout */ @media (max-width: 767px) { .recap-main-btn-area, - .release-main-btn-area { + .release-main-btn-area, + .pr-main-area { flex-direction: column; align-items: center; gap: 8px; @@ -635,13 +639,15 @@ button, .Button { } /* Remove margin-right on mobile for back button */ - .release-back-rect-btn { + .release-back-rect-btn, + .pr-back-rect-btn { margin-right: 0; } } /* Your existing component styles */ -.release-back-rect-btn { +.release-back-rect-btn, +.pr-back-rect-btn { background-color: beige; min-width: 90px; height: 44px; @@ -654,7 +660,8 @@ button, .Button { justify-content: center; } -.release-back-rect-btn:disabled { +.release-back-rect-btn:disabled, +.pr-back-rect-btn:disabled { background-color: beige; min-width: 90px; height: 44px; @@ -668,11 +675,42 @@ button, .Button { width: auto !important; } -.release-back-arrow { +.release-back-arrow, +.pr-back-arrow { font-size: 1.2rem; margin-right: 0.3rem; } +/* PR Mode Button Styles */ +.release-pr-rect-btn { + background-color: beige; + min-width: 90px; + height: 44px; + font-size: 1rem; + padding: 0.6rem 0.7rem; + margin-right: 1.2rem; + gap: 0.5rem; + display: flex; + align-items: center; + justify-content: center; +} + +.release-pr-rect-btn:disabled { + background-color: beige; + min-width: 90px; + height: 44px; + font-size: 1rem; + padding: 0.6rem 0.7rem; + margin-right: 1.2rem; + gap: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + width: auto !important; + opacity: 0.6; + cursor: not-allowed; +} + /* Counter container */ .release-counter-rect { display: flex; @@ -782,6 +820,10 @@ button, .Button { .recap-release-switcher.show-release { height: 180px; /* Height for release mode (3 elements vertically) */ } + + .recap-release-switcher.show-pr { + height: 240px; /* Height for PR mode (more elements vertically) */ + } } @media (max-width: 700px) { @@ -796,7 +838,7 @@ button, .Button { .release-main-btn-area, .recap-main-btn-area { gap: 0.5rem; } - .recap-3dots-rect-btn, .release-back-rect-btn, .release-counter-rect { + .recap-3dots-rect-btn, .release-back-rect-btn, .pr-back-rect-btn, .release-counter-rect { height: 40px; min-height: 40px; } @@ -1291,4 +1333,239 @@ a { .author-add-button { flex: 1; /* Full width when wrapped */ } +} + +/* Pull Request Mode Styles */ + +/* PR Mode Main Area */ +.pr-main-area { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + width: 100%; + padding: 1rem; +} + +/* PR Controls Container */ +.pr-controls-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + width: 100%; + max-width: 600px; +} + +/* PR Branch Selectors */ +.pr-branch-selectors { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; +} + +.pr-branch-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; +} + +.pr-branch-label { + font-weight: 500; + color: #5c4033; + font-size: 0.95rem; +} + +/* PR Branch Dropdown */ +.pr-branch-dropdown { + width: 100%; + padding: 0.7rem; + border: 3px solid #333; + border-radius: 8px; + background-color: #fffaf0; + color: #333; + font-size: 1rem; + font-family: 'Minecraft', sans-serif; + cursor: pointer; + box-shadow: 4px 4px 0 #bfa16b; + transition: all 0.2s ease; + height: auto; + min-height: 44px; +} + +.pr-branch-dropdown:hover:not(:disabled) { + background-color: #fff8e1; + border-color: #e06e42; +} + +.pr-branch-dropdown:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: #f0f0f0; +} + +.pr-branch-dropdown:focus { + outline: none; + border-color: #e06e42; + box-shadow: 4px 4px 0 #bfa16b, 0 0 0 3px rgba(224, 110, 66, 0.2); +} + +/* PR Validation Message */ +.pr-validation-message { + width: 100%; + padding: 0.75rem; + background-color: #fff3cd; + border: 2px solid #ffc107; + border-radius: 8px; + color: #856404; + font-size: 0.9rem; + text-align: center; + box-shadow: 2px 2px 0 #bfa16b; +} + +/* PR Error Message */ +.pr-error-message { + width: 100%; + padding: 0.75rem; + background-color: #f8d7da; + border: 2px solid #dc3545; + border-radius: 8px; + color: #721c24; + font-size: 0.9rem; + text-align: center; + box-shadow: 2px 2px 0 #bfa16b; +} + +/* PR Success Message */ +.pr-success-message { + width: 100%; + padding: 0.75rem; + background-color: #d4edda; + border: 2px solid #28a745; + border-radius: 8px; + color: #155724; + font-size: 0.9rem; + text-align: center; + box-shadow: 2px 2px 0 #bfa16b; +} + +.pr-success-message a { + color: #0056b3; + text-decoration: underline; + font-weight: bold; +} + +.pr-success-message a:hover { + color: #003d82; +} + +/* PR Generate Button */ +.pr-generate-btn { + width: 100%; + max-width: 300px; + padding: 0.8rem 1.6rem; + background-color: #ff7f50; + color: #fff; + border: 3px solid #333; + border-radius: 8px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 4px 4px 0 #bfa16b; + display: flex; + align-items: center; + justify-content: center; +} + +.pr-generate-btn:hover:not(:disabled) { + background-color: #e06e42; + border-color: #e06e42; +} + +.pr-generate-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: #cccccc; +} + +/* PR Create Button */ +.pr-create-btn { + width: 100%; + max-width: 300px; + padding: 0.8rem 1.6rem; + background-color: #ff7f50; + color: #fff; + border: 3px solid #333; + border-radius: 8px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 4px 4px 0 #bfa16b; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; +} + +.pr-create-btn:hover:not(:disabled) { + background-color: #e06e42; + border-color: #e06e42; +} + +.pr-create-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: #cccccc; +} + +/* PR Text Areas */ +.pr-diff-area, +.pr-description-area { + width: 100%; + padding: 0.8rem; + border: 3px solid #333; + border-radius: 8px; + background-color: #fffaf0; + font-family: 'Courier New', monospace; + font-size: 0.9rem; + resize: vertical; + min-height: 200px; + box-shadow: 4px 4px 0 #bfa16b; + color: #333; +} + +.pr-diff-area:focus, +.pr-description-area:focus { + outline: none; + border-color: #e06e42; + box-shadow: 4px 4px 0 #bfa16b, 0 0 0 3px rgba(224, 110, 66, 0.2); +} + +.pr-diff-area { + background-color: #f5f5f5; + color: #333; +} + +/* Responsive PR Mode Adjustments */ +@media (max-width: 768px) { + .pr-main-area { + padding: 0.5rem; + } + + .pr-controls-container { + max-width: 100%; + } + + .pr-branch-selectors { + gap: 0.75rem; + } + + .pr-generate-btn, + .pr-create-btn { + max-width: 100%; + } } \ No newline at end of file diff --git a/app/git-recap/src/App.tsx b/app/git-recap/src/App.tsx index 98519be..e68aa7f 100644 --- a/app/git-recap/src/App.tsx +++ b/app/git-recap/src/App.tsx @@ -44,6 +44,23 @@ function App() { const [numOldReleases, setNumOldReleases] = useState(1); const [isExecutingReleaseNotes, setIsExecutingReleaseNotes] = useState(false); + // PR Mode states + const [showPRMode, setShowPRMode] = useState(false); + const [availableBranches, setAvailableBranches] = useState([]); + const [sourceBranch, setSourceBranch] = useState(''); + const [targetBranches, setTargetBranches] = useState([]); + const [targetBranch, setTargetBranch] = useState(''); + const [prDiff, setPrDiff] = useState(''); + const [prDescription, setPrDescription] = useState(''); + const [isGeneratingPR, setIsGeneratingPR] = useState(false); + const [prValidationMessage, setPrValidationMessage] = useState(''); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [isLoadingTargets, setIsLoadingTargets] = useState(false); + const [isLoadingDiff, setIsLoadingDiff] = useState(false); + const [isCreatingPR, setIsCreatingPR] = useState(false); + const [prCreationSuccess, setPrCreationSuccess] = useState(false); + const [prUrl, setPrUrl] = useState(''); + // Auth states const [isPATAuthorized, setIsPATAuthorized] = useState(false); const [authProgress, setAuthProgress] = useState(0); @@ -64,7 +81,7 @@ function App() { const [recapDone, setRecapDone] = useState(true); const [isReposLoading, setIsReposLoading] = useState(true); const [repoProgress, setRepoProgress] = useState(0); - // UI mode for recap/release + // UI mode for recap/release/pr const [showReleaseMode, setShowReleaseMode] = useState(false); const actionsLogRef = useRef(null); @@ -354,6 +371,268 @@ function App() { } }; + // PR Mode Navigation Handlers + const handleShowPRMode = useCallback(() => { + // Validation: single repository selection + if (selectedRepos.length !== 1) { + setPopupMessage('Please select exactly one repository to create a pull request.'); + setIsPopupOpen(true); + return; + } + + // Validation: GitHub provider only + if (codeHost !== 'github') { + setPopupMessage('Pull request creation is only supported for GitHub repositories.'); + setIsPopupOpen(true); + return; + } + + // Reset PR mode state + setSourceBranch(''); + setTargetBranch(''); + setTargetBranches([]); + setPrDiff(''); + setPrDescription(''); + setPrValidationMessage(''); + setPrCreationSuccess(false); + setPrUrl(''); + + setShowPRMode(true); + + // Fetch available branches + fetchAvailableBranches(); + }, [selectedRepos, codeHost, sessionId]); + + const handleBackFromPR = useCallback(() => { + if (currentWebSocket) { + currentWebSocket.close(); + setCurrentWebSocket(null); + } + setShowPRMode(false); + }, [currentWebSocket]); + + // Fetch available branches when entering PR mode + const fetchAvailableBranches = useCallback(async () => { + if (!sessionId || selectedRepos.length !== 1) return; + + setIsLoadingBranches(true); + setPrValidationMessage(''); + + try { + const backendUrl = import.meta.env.VITE_AICORE_API; + const response = await fetch( + `${backendUrl}/branches?session_id=${sessionId}&repo=${encodeURIComponent(selectedRepos[0])}`, + { method: 'GET' } + ); + + if (!response.ok) throw new Error('Failed to fetch branches'); + + const data = await response.json(); + setAvailableBranches(data.branches || []); + + if (!data.branches || data.branches.length === 0) { + setPrValidationMessage('No branches found in this repository.'); + } + } catch (error) { + console.error('Error fetching branches:', error); + setPrValidationMessage('Failed to fetch branches. Please try again.'); + } finally { + setIsLoadingBranches(false); + } + }, [sessionId, selectedRepos]); + + // Handle source branch selection + const handleSourceBranchChange = useCallback(async (branch: string) => { + setSourceBranch(branch); + setTargetBranch(''); + setTargetBranches([]); + setPrDiff(''); + setPrDescription(''); + setPrValidationMessage(''); + + if (!branch) return; + + // Fetch valid target branches + setIsLoadingTargets(true); + + try { + const backendUrl = import.meta.env.VITE_AICORE_API; + const response = await fetch(`${backendUrl}/valid-target-branches`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + repo: selectedRepos[0], + source_branch: branch + }) + }); + + if (!response.ok) throw new Error('Failed to fetch valid target branches'); + + const data = await response.json(); + setTargetBranches(data.valid_target_branches || []); + + if (!data.valid_target_branches || data.valid_target_branches.length === 0) { + setPrValidationMessage('No valid target branches available for the selected source branch.'); + } + } catch (error) { + console.error('Error fetching target branches:', error); + setPrValidationMessage('Failed to fetch valid target branches. Please try again.'); + } finally { + setIsLoadingTargets(false); + } + }, [sessionId, selectedRepos]); + + // Handle target branch selection and fetch diff + const handleTargetBranchChange = useCallback(async (branch: string) => { + setTargetBranch(branch); + setPrDiff(''); + setPrDescription(''); + setPrValidationMessage(''); + + if (!branch || !sourceBranch) return; + + // Fetch PR diff + setIsLoadingDiff(true); + + try { + const backendUrl = import.meta.env.VITE_AICORE_API; + const response = await fetch(`${backendUrl}/get-pull-request-diff`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + repo: selectedRepos[0], + source_branch: sourceBranch, + target_branch: branch + }) + }); + + if (!response.ok) throw new Error('Failed to fetch pull request diff'); + + const data = await response.json(); + + if (!data.commits || data.commits.length === 0) { + setPrValidationMessage('No changes found between the selected branches.'); + setPrDiff(''); + return; + } + + // Format commits as readable log + const formattedDiff = data.commits + .map((commit: any) => `[${commit.sha?.substring(0, 7) || 'N/A'}] ${commit.message}`) + .join('\n'); + + setPrDiff(formattedDiff); + } catch (error) { + console.error('Error fetching PR diff:', error); + setPrValidationMessage('Failed to fetch pull request diff. Please try again.'); + } finally { + setIsLoadingDiff(false); + } + }, [sessionId, selectedRepos, sourceBranch]); + + // Generate PR description using WebSocket + const generatePRDescription = useCallback(() => { + if (!sourceBranch || !targetBranch || !prDiff) { + setPrValidationMessage('Please select both branches and ensure there are changes to summarize.'); + return; + } + + if (currentWebSocket) { + currentWebSocket.close(); + } + + setPrDescription(''); + setIsGeneratingPR(true); + setPrValidationMessage(''); + + const backendUrl = import.meta.env.VITE_AICORE_API; + const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/ws/${sessionId}/pull_request`; + const ws = new WebSocket(wsUrl); + + setCurrentWebSocket(ws); + + ws.onopen = () => { + ws.send(JSON.stringify({ actions: prDiff })); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data.toString()).chunk; + if (message === "") { + setIsGeneratingPR(false); + ws.close(); + setCurrentWebSocket(null); + } else { + setPrDescription((prev) => prev + message); + } + }; + + ws.onerror = (event) => { + console.error("WebSocket error:", event); + setIsGeneratingPR(false); + setPrValidationMessage('Failed to generate PR description. Please try again.'); + setCurrentWebSocket(null); + }; + + ws.onclose = () => { + setIsGeneratingPR(false); + setCurrentWebSocket(null); + }; + }, [sessionId, sourceBranch, targetBranch, prDiff, currentWebSocket]); + + // Create pull request + const createPullRequest = useCallback(async () => { + if (!prDescription || !prDescription.trim()) { + setPrValidationMessage('Please generate a PR description before creating the pull request.'); + return; + } + + // Parse title from first line of description + const lines = prDescription.split('\n'); + const title = lines[0]?.replace(/^#+\s*/, '').trim() || `Merge ${sourceBranch} into ${targetBranch}`; + const body = lines.slice(1).join('\n').trim(); + + setIsCreatingPR(true); + setPrValidationMessage(''); + + try { + const backendUrl = import.meta.env.VITE_AICORE_API; + const response = await fetch(`${backendUrl}/create-pull-request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + repo: selectedRepos[0], + source_branch: sourceBranch, + target_branch: targetBranch, + title: title, + description: body || prDescription + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to create pull request'); + } + + const data = await response.json(); + + if (data.success) { + setPrCreationSuccess(true); + setPrUrl(data.url); + setPrValidationMessage(''); + } else { + throw new Error('Pull request creation was not successful'); + } + } catch (error: any) { + console.error('Error creating pull request:', error); + setPrValidationMessage(error.message || 'Failed to create pull request. Please try again.'); + } finally { + setIsCreatingPR(false); + } + }, [prDescription, sourceBranch, targetBranch, sessionId, selectedRepos]); + // Handle GitHub OAuth callback useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -457,8 +736,8 @@ function App() { value={repoUrl} onChange={(e) => setRepoUrl(e.target.value)} placeholder="Enter Git repository URL" - className="flex-grow min-w-0" // Takes remaining space - style={{ flex: '2 1 0%' }} // Explicit 2:1 ratio + className="flex-grow min-w-0" + style={{ flex: '2 1 0%' }} /> + + +
+
+
+ + +
+ +
+ + +
+
+ + {prValidationMessage && ( +
+ {prValidationMessage} +
+ )} + + +
+ + {/* PR Mode Output Section */} + {showPRMode && ( + <> +
+ +

Commit Diff

+ {isLoadingDiff && ( + + )} +