diff --git a/ciftt.py b/ciftt.py index 6f6dfa2..9f2e141 100644 --- a/ciftt.py +++ b/ciftt.py @@ -6,10 +6,12 @@ import typer from cli import check_token, create_issues, export_issues, update_issues +from cli.create_comments import create_comments app = typer.Typer(help="CIFTT - CSV Input for Feature Triage and Tracking") app.command()(create_issues) +app.command()(create_comments) app.command()(update_issues) app.command()(export_issues) app.command()(check_token) diff --git a/cli/common.py b/cli/common.py index a1c3bc4..39a5140 100644 --- a/cli/common.py +++ b/cli/common.py @@ -1,12 +1,17 @@ """ Common CLI utilities to eliminate code duplication across commands. """ + from typing import Dict, List, Tuple import typer from cli.csv_data import load_and_validate_csv -from cli.github import init_github_client, validate_repository_access, validate_token_scopes +from cli.github import ( + init_github_client, + validate_repository_access, + validate_token_scopes, +) from csv_data import CSVData from github.client import GitHubClient @@ -14,10 +19,10 @@ def load_csv_for_command(csv_file: str) -> CSVData: """ Common CSV loading logic for all CLI commands. - + Args: csv_file: Path to the CSV file - + Returns: CSVData object with loaded and validated data """ @@ -28,38 +33,37 @@ def load_csv_for_command(csv_file: str) -> CSVData: def setup_github_client_for_command( - required_scopes: List[str], - repositories: List[Tuple[str, str]] = None + required_scopes: List[str], repositories: List[Tuple[str, str]] = None ) -> GitHubClient: """ Initialize and validate GitHub client for CLI commands. - + Args: required_scopes: List of required OAuth scopes repositories: Optional list of (owner, repo) tuples to validate access - + Returns: Initialized and validated GitHubClient """ github_client = init_github_client() - + validate_token_scopes(github_client, required_scopes) - + if repositories: for owner, repo_name in repositories: validate_repository_access(github_client, owner, repo_name) - + return github_client def handle_cli_error(operation_name: str, exception: Exception) -> None: """ Standard error handling for CLI operations. - + Args: operation_name: Name of the operation that failed exception: The exception that was raised - + Raises: typer.Exit: Always exits with code 1 """ @@ -71,23 +75,23 @@ def validate_project_fields_for_csv( csv_data: CSVData, github_client: GitHubClient, project_owner: str, - project_number: str + project_number: str, ) -> None: """ Validate that CSV project fields exist in the GitHub project. - + Args: csv_data: CSV data containing project fields github_client: GitHub client for API calls project_owner: Project owner (user or organization) project_number: Project number - + Raises: typer.Exit: If validation fails """ if not csv_data.has_project_fields(): return - + try: field_definitions = github_client.get_project_field_definitions( project_owner, project_number @@ -104,16 +108,12 @@ def validate_project_fields_for_csv( invalid_fields.append(csv_field) if invalid_fields: - typer.echo( - f"โŒ Invalid project fields found: {', '.join(invalid_fields)}" - ) + typer.echo(f"โŒ Invalid project fields found: {', '.join(invalid_fields)}") available_fields = list(field_definitions.keys()) - typer.echo( - f"๐Ÿ“‹ Available project fields: {', '.join(available_fields)}" - ) + typer.echo(f"๐Ÿ“‹ Available project fields: {', '.join(available_fields)}") raise typer.Exit(code=1) else: typer.echo(f"โœ… Project fields validated: {', '.join(valid_fields)}") except ValueError as e: - handle_cli_error("Project field validation", e) \ No newline at end of file + handle_cli_error("Project field validation", e) diff --git a/cli/create_comments.py b/cli/create_comments.py new file mode 100644 index 0000000..87549d6 --- /dev/null +++ b/cli/create_comments.py @@ -0,0 +1,177 @@ +import typer + +from utils import extract_issue_number, extract_repo_from_issue_url + +from .common import ( + handle_cli_error, + load_csv_for_command, + setup_github_client_for_command, +) + + +def create_comments( + csv_file: str = typer.Argument( + ..., help="Path to the CSV file containing comment data" + ), + dry_run: bool = typer.Option( + False, "--dry-run", "-d", help="Print actions without executing them" + ), +): + """ + Create comments on existing GitHub issues from a CSV file. + + CSV format should include URL and Comment columns. + Optional columns: Author, Date for attribution. + """ + try: + csv_data = load_csv_for_command(csv_file) + + _validate_csv_format(csv_data) + + if dry_run: + _perform_dry_run(csv_data) + return + + github_client = setup_github_client_for_command(required_scopes=["repo"]) + + _create_comments_from_csv(github_client, csv_data) + + except Exception as e: + handle_cli_error("Comment creation", e) + + +def _validate_csv_format(csv_data) -> None: + """ + Validate that the CSV has required columns for comment creation. + + Args: + csv_data: CSVData object to validate + + Raises: + ValueError: If required columns are missing + """ + if "URL" not in csv_data.data.columns: + raise ValueError("CSV must contain 'URL' column") + + if "Comment" not in csv_data.data.columns: + raise ValueError("CSV must contain 'Comment' column") + + # Check for empty URLs or comments + empty_urls = csv_data.data["URL"].isna() | (csv_data.data["URL"] == "") + if empty_urls.any(): + empty_rows = list(csv_data.data.index[empty_urls] + 1) + raise ValueError(f"Empty URL values found in rows: {empty_rows}") + + empty_comments = csv_data.data["Comment"].isna() | (csv_data.data["Comment"] == "") + if empty_comments.any(): + empty_rows = list(csv_data.data.index[empty_comments] + 1) + raise ValueError(f"Empty Comment values found in rows: {empty_rows}") + + +def _perform_dry_run(csv_data) -> None: + """ + Perform a dry run showing what comments would be created. + + Args: + csv_data: CSVData object with comment data + """ + typer.echo("๐Ÿงช Dry run mode - showing what would be done:") + typer.echo() + + for index, row in csv_data.data.iterrows(): + try: + url = row["URL"] + owner, repo_name = extract_repo_from_issue_url(url) + issue_number = extract_issue_number(url) + + # Format comment body with attribution if provided + formatted_comment = _format_comment_body(row) + + typer.echo(f"๐Ÿ“ Would create comment on {owner}/{repo_name}#{issue_number}") + typer.echo( + f" Comment: {formatted_comment[:100]}{'...' if len(formatted_comment) > 100 else ''}" + ) + typer.echo() + + except Exception as e: + typer.echo(f"โŒ Row {index + 1}: Invalid URL format - {e}") + typer.echo() + + +def _create_comments_from_csv(github_client, csv_data) -> None: + """ + Create comments from CSV data. + + Args: + github_client: GitHub client for API calls + csv_data: CSVData object with comment data + """ + typer.echo(f"๐Ÿ’ฌ Creating comments from {len(csv_data.data)} rows...") + typer.echo() + + success_count = 0 + error_count = 0 + + for index, row in csv_data.data.iterrows(): + try: + url = row["URL"] + owner, repo_name = extract_repo_from_issue_url(url) + issue_number = extract_issue_number(url) + + formatted_comment = _format_comment_body(row) + + # Create the comment + response = github_client.create_issue_comment( + owner, repo_name, issue_number, formatted_comment + ) + + comment_id = response.get("id") + typer.echo( + f"โœ… Created comment {comment_id} on {owner}/{repo_name}#{issue_number}" + ) + success_count += 1 + + except Exception as e: + typer.echo(f"โŒ Row {index + 1}: Failed to create comment - {e}") + error_count += 1 + + typer.echo() + typer.echo(f"๐Ÿ“Š Summary: {success_count} comments created, {error_count} failed") + + +def _format_comment_body(row) -> str: + """ + Format comment body with optional author attribution. + + Args: + row: Pandas Series with comment data + + Returns: + Formatted comment body string + """ + comment = str(row["Comment"]).strip() + + # Add attribution if Author or Date columns are present + attribution_parts = [] + + if ( + "Author" in row + and row["Author"] is not None + and str(row["Author"]).strip() + and str(row["Author"]).lower() != "nan" + ): + attribution_parts.append(f"Originally by: {str(row['Author']).strip()}") + + if ( + "Date" in row + and row["Date"] is not None + and str(row["Date"]).strip() + and str(row["Date"]).lower() != "nan" + ): + attribution_parts.append(f"Date: {str(row['Date']).strip()}") + + if attribution_parts: + attribution = " | ".join(attribution_parts) + comment = f"{comment}\n\n*{attribution}*" + + return comment diff --git a/cli/create_issues.py b/cli/create_issues.py index c8eafcc..05576dc 100644 --- a/cli/create_issues.py +++ b/cli/create_issues.py @@ -30,8 +30,7 @@ def create_issues( return github_client = setup_github_client_for_command( - required_scopes=["repo"], - repositories=[(owner, repo_name)] + required_scopes=["repo"], repositories=[(owner, repo_name)] ) issues = transform_csv_to_new_issues(csv_data.data) diff --git a/cli/update_issues.py b/cli/update_issues.py index c698713..f4f06fd 100644 --- a/cli/update_issues.py +++ b/cli/update_issues.py @@ -5,7 +5,12 @@ from transform import transform_csv_to_updated_issues from utils import extract_repo_from_issue_url, parse_github_project_identifier -from .common import handle_cli_error, load_csv_for_command, setup_github_client_for_command, validate_project_fields_for_csv +from .common import ( + handle_cli_error, + load_csv_for_command, + setup_github_client_for_command, + validate_project_fields_for_csv, +) from .dry_run import perform_dry_run from .issues import update_issues_in_github @@ -98,8 +103,7 @@ def update_issues( return github_client = setup_github_client_for_command( - required_scopes=["repo", "project"], - repositories=repositories + required_scopes=["repo", "project"], repositories=repositories ) # Validate that the project exists and is accessible @@ -107,14 +111,14 @@ def update_issues( project_info = github_client.validate_project_exists( project_owner, project_number ) - typer.echo( - f"โœ… Project validated: {project_info.title} ({project_info.type})" - ) + typer.echo(f"โœ… Project validated: {project_info.title} ({project_info.type})") except ValueError as e: handle_cli_error("Project validation", e) # Validate project fields before processing issues - validate_project_fields_for_csv(csv_data, github_client, project_owner, project_number) + validate_project_fields_for_csv( + csv_data, github_client, project_owner, project_number + ) # Repository access already validated by setup_github_client_for_command diff --git a/csv_data.py b/csv_data.py index 37ed60e..a3acdfb 100644 --- a/csv_data.py +++ b/csv_data.py @@ -77,16 +77,16 @@ def _validate_titles(self) -> None: """ new_issues_mask = self._identify_new_issues() has_new_issues = new_issues_mask.any() - + self._validate_title_column_exists(has_new_issues) - + if has_new_issues and "Title" in self.data.columns: self._validate_title_values(new_issues_mask) def _identify_new_issues(self) -> pd.Series: """ Identify which rows represent new issues (rows without URLs). - + Returns: Boolean Series indicating which rows are new issues """ @@ -99,10 +99,10 @@ def _identify_new_issues(self) -> pd.Series: def _validate_title_column_exists(self, has_new_issues: bool) -> None: """ Validate that Title column exists when there are new issues. - + Args: has_new_issues: Whether there are any new issues in the data - + Raises: ValueError: If Title column is missing but required """ @@ -114,10 +114,10 @@ def _validate_title_column_exists(self, has_new_issues: bool) -> None: def _validate_title_values(self, new_issues_mask: pd.Series) -> None: """ Validate that all new issues have non-empty title values. - + Args: new_issues_mask: Boolean Series indicating which rows are new issues - + Raises: ValueError: If any new issues have empty titles """ diff --git a/github/client.py b/github/client.py index f1c41cd..412592f 100644 --- a/github/client.py +++ b/github/client.py @@ -7,9 +7,16 @@ from pydantic import BaseModel from github.client_utils import _extract_project_fields -from github.data import NewIssue, ProjectFieldUpdateResult, ProjectFieldValue, ProjectInfo, UpdatedIssue +from github.data import ( + NewIssue, + ProjectFieldUpdateResult, + ProjectFieldValue, + ProjectInfo, + UpdatedIssue, +) from github.rate_limit import RateLimitMixin + # Load GraphQL queries def _load_graphql_query(filename: str) -> str: """Load a GraphQL query from the queries directory.""" @@ -45,6 +52,15 @@ def update_issue(self, owner: str, repo: str, issue_update: UpdatedIssue) -> dic return self._patch_request(endpoint, data) + def create_issue_comment( + self, owner: str, repo: str, issue_number: int, body: str + ) -> dict: + """Create a comment on an existing issue.""" + endpoint = f"repos/{owner}/{repo}/issues/{issue_number}/comments" + data = {"body": body} + + return self._post_request(endpoint, data) + def get_all_issues( self, owner: str, repo: str, state: Literal["open", "closed", "all"] = "open" ) -> list: @@ -294,14 +310,16 @@ def update_issue_project_fields( # Update all project fields self._update_project_fields( - project_fields, available_fields, project_id, item_id, - project_number, updated_fields, errors + project_fields, + available_fields, + project_id, + item_id, + project_number, + updated_fields, + errors, ) - return ProjectFieldUpdateResult( - updated_fields=updated_fields, - errors=errors - ) + return ProjectFieldUpdateResult(updated_fields=updated_fields, errors=errors) def _update_project_fields( self, @@ -311,11 +329,11 @@ def _update_project_fields( item_id: str, project_number: str, updated_fields: dict, - errors: dict + errors: dict, ) -> None: """ Update all project fields for an issue. - + Args: project_fields: Dictionary of field names to values to update available_fields: Available fields in the project @@ -329,9 +347,9 @@ def _update_project_fields( for field_name, field_value in project_fields.items(): if field_name not in available_fields: - errors[ - field_name - ] = f"Field '{field_name}' not found in project #{project_number}" + errors[field_name] = ( + f"Field '{field_name}' not found in project #{project_number}" + ) continue field_info = available_fields[field_name] @@ -363,15 +381,15 @@ def _find_target_project( ) -> dict: """ Find and validate the target project from available projects. - + Args: projects_info: Dictionary of available projects project_number: Target project number to find issue_number: Issue number for error messages - + Returns: Project info dictionary for the target project - + Raises: ValueError: If target project is not found """ @@ -389,9 +407,7 @@ def _find_target_project( f"Available projects: {', '.join(available_numbers)}" ) - def validate_project_exists( - self, owner: str, project_number: str - ) -> ProjectInfo: + def validate_project_exists(self, owner: str, project_number: str) -> ProjectInfo: """ Validate that a GitHub project exists and is accessible. diff --git a/tests/conftest.py b/tests/conftest.py index 079f6c3..a9eddee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,12 @@ def mock_github_client(): "html_url": "https://github.com/owner/repo/issues/123", } + client.create_issue_comment.return_value = { + "id": 12345, + "body": "Test comment", + "html_url": "https://github.com/owner/repo/issues/123#issuecomment-12345", + } + client.update_issue_project_fields.return_value = ProjectFieldUpdateResult() client._get_request.return_value = {"permissions": {"push": True}} diff --git a/tests/integration/fixtures/create_comments.csv b/tests/integration/fixtures/create_comments.csv new file mode 100644 index 0000000..53099cf --- /dev/null +++ b/tests/integration/fixtures/create_comments.csv @@ -0,0 +1,4 @@ +URL,Comment,Author,Date +https://github.com/owner/repo/issues/123,This was originally reported in JIRA-456 with high priority,john.doe,2024-01-15 +https://github.com/owner/repo/issues/124,"Fixed in legacy system, migration complete",jane.smith,2024-01-16 +https://github.com/another/repo/issues/42,Simple comment without attribution,, \ No newline at end of file diff --git a/tests/integration/test_create_comments_integration.py b/tests/integration/test_create_comments_integration.py new file mode 100644 index 0000000..95ba7b5 --- /dev/null +++ b/tests/integration/test_create_comments_integration.py @@ -0,0 +1,122 @@ +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from click.exceptions import Exit + +from cli.create_comments import create_comments + + +class TestCreateCommentsIntegration: + """Integration tests for the create_comments command.""" + + def test_create_comments_success(self, fixtures_dir, mock_github_client): + """Test successful creation of comments from CSV.""" + csv_file = str(fixtures_dir / "create_comments.csv") + + with patch( + "cli.common.init_github_client", return_value=mock_github_client + ), patch("cli.common.validate_token_scopes"), patch( + "cli.common.validate_repository_access" + ): + + create_comments(csv_file, dry_run=False) + + # Verify that create_issue_comment was called for each row in the CSV + assert mock_github_client.create_issue_comment.call_count == 3 + + # Verify the calls were made with correct parameters + calls = mock_github_client.create_issue_comment.call_args_list + + # First call with attribution + assert calls[0][0][:3] == ("owner", "repo", 123) + comment_body = calls[0][0][3] + assert "This was originally reported in JIRA-456" in comment_body + assert "*Originally by: john.doe | Date: 2024-01-15*" in comment_body + + # Second call with attribution + assert calls[1][0][:3] == ("owner", "repo", 124) + + # Third call different repo, no attribution + assert calls[2][0][:3] == ("another", "repo", 42) + + def test_create_comments_dry_run(self, fixtures_dir, capsys): + """Test dry run mode for create comments.""" + csv_file = str(fixtures_dir / "create_comments.csv") + + create_comments(csv_file, dry_run=True) + + captured = capsys.readouterr() + assert "๐Ÿงช Dry run mode" in captured.out + assert "Would create comment on owner/repo#123" in captured.out + assert "Would create comment on owner/repo#124" in captured.out + assert "Would create comment on another/repo#42" in captured.out + + def test_create_comments_missing_url_column(self, tmp_path): + """Test handling of CSV missing URL column.""" + invalid_csv = tmp_path / "invalid.csv" + invalid_csv.write_text("Comment,Author\nTest comment,author") + + with pytest.raises(Exit): + create_comments(str(invalid_csv), dry_run=False) + + def test_create_comments_missing_comment_column(self, tmp_path): + """Test handling of CSV missing Comment column.""" + invalid_csv = tmp_path / "invalid.csv" + invalid_csv.write_text( + "URL,Author\nhttps://github.com/owner/repo/issues/123,author" + ) + + with pytest.raises(Exit): + create_comments(str(invalid_csv), dry_run=False) + + def test_create_comments_empty_url_values(self, tmp_path): + """Test handling of empty URL values.""" + invalid_csv = tmp_path / "invalid.csv" + invalid_csv.write_text( + "URL,Comment\n,Test comment\nhttps://github.com/owner/repo/issues/123,Another comment" + ) + + with pytest.raises(Exit): + create_comments(str(invalid_csv), dry_run=False) + + def test_create_comments_invalid_url_format(self, tmp_path, capsys): + """Test handling of invalid URL format in dry run.""" + invalid_csv = tmp_path / "invalid.csv" + invalid_csv.write_text("URL,Comment\ninvalid-url,Test comment") + + create_comments(str(invalid_csv), dry_run=True) + + captured = capsys.readouterr() + assert "โŒ Row 1: Invalid URL format" in captured.out + + def test_create_comments_github_api_error( + self, fixtures_dir, mock_github_client, capsys + ): + """Test handling of GitHub API errors during comment creation.""" + csv_file = str(fixtures_dir / "create_comments.csv") + + # Mock first call to succeed, second to fail + mock_github_client.create_issue_comment.side_effect = [ + {"id": 12345}, + Exception("API Error"), + {"id": 12346}, + ] + + with patch( + "cli.common.init_github_client", return_value=mock_github_client + ), patch("cli.common.validate_token_scopes"), patch( + "cli.common.validate_repository_access" + ): + + create_comments(csv_file, dry_run=False) + + # Verify that create_issue_comment was attempted for each row + assert mock_github_client.create_issue_comment.call_count == 3 + + # Check that error was logged but execution continued + captured = capsys.readouterr() + assert "โœ… Created comment 12345" in captured.out + assert "โŒ Row 2: Failed to create comment" in captured.out + assert "โœ… Created comment 12346" in captured.out + assert "๐Ÿ“Š Summary: 2 comments created, 1 failed" in captured.out diff --git a/tests/integration/test_update_issues_integration.py b/tests/integration/test_update_issues_integration.py index 0cbda2c..f43c96a 100644 --- a/tests/integration/test_update_issues_integration.py +++ b/tests/integration/test_update_issues_integration.py @@ -18,11 +18,11 @@ def test_update_issues_success(self, fixtures_dir, mock_github_client): # Mock project validation mock_github_client.validate_project_exists.return_value = ProjectInfo( id="test-id", - title="Test Project", + title="Test Project", number=123, url="https://github.com/users/owner/projects/123", owner="owner", - type="user" + type="user", ) with patch( @@ -74,11 +74,11 @@ def test_update_issues_github_api_error(self, fixtures_dir, mock_github_client): # Mock project validation mock_github_client.validate_project_exists.return_value = ProjectInfo( id="test-id", - title="Test Project", + title="Test Project", number=123, url="https://github.com/users/owner/projects/123", owner="owner", - type="user" + type="user", ) # Mock GitHub client to raise an exception @@ -104,11 +104,9 @@ def test_update_issues_invalid_url_format(self, tmp_path): "title,description,url\nTest title,Test description,not-a-github-url" ) - with patch( - "cli.common.init_github_client" - ), patch("cli.common.validate_token_scopes"), patch( - "cli.common.validate_repository_access" - ): + with patch("cli.common.init_github_client"), patch( + "cli.common.validate_token_scopes" + ), patch("cli.common.validate_repository_access"): # Should handle invalid URLs gracefully with pytest.raises(Exit): # Should exit when no valid URLs found @@ -126,11 +124,11 @@ def test_update_issues_with_project_fields(self, tmp_path, mock_github_client): # Mock project validation mock_github_client.validate_project_exists.return_value = ProjectInfo( id="test-id", - title="Test Project", + title="Test Project", number=123, url="https://github.com/users/owner/projects/123", owner="owner", - type="user" + type="user", ) # Mock project field definitions @@ -140,9 +138,10 @@ def test_update_issues_with_project_fields(self, tmp_path, mock_github_client): } # Mock project field update method - mock_github_client.update_issue_project_fields.return_value = ProjectFieldUpdateResult( - updated_fields={"Priority": "High", "Status": "In Progress"}, - errors={} + mock_github_client.update_issue_project_fields.return_value = ( + ProjectFieldUpdateResult( + updated_fields={"Priority": "High", "Status": "In Progress"}, errors={} + ) ) with patch(