diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff6c53b..069811f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Install dependencies run: | - uv sync --dev + uv sync --extra dev - name: Check code formatting with black run: | @@ -67,12 +67,12 @@ jobs: - name: Install dependencies run: | - uv sync --dev + uv sync --extra dev - name: Run tests with pytest run: | if [ -d "tests" ] && [ "$(ls -A tests 2>/dev/null)" ]; then - uv run pytest tests/ -v + uv run pytest tests/ -v --cov=github_pm --cov-report=term else echo "No tests directory or tests found, skipping pytest" fi diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ff1eb22..9556f59 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -45,8 +45,11 @@ github_pm = "github_pm.cli:main" dev = [ "black>=24.4.2", "flake8>=7.3.0", + "httpx>=0.27.0", "isort>=6.0.1", "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=5.0.0", "tox>=4.23.2", ] @@ -62,7 +65,7 @@ order_by_type = false [tool.tox] requires = ["tox>=4.23.2"] -env_list = ["format", "isort", "lint"] +env_list = ["format", "isort", "lint", "test", "coverage"] [tool.tox.env.format] description = "check code format" @@ -81,3 +84,53 @@ description = "check code" skip_install = true deps = ["flake8"] commands = [["flake8", { replace = "posargs", default = ["src", "tests"], extend = true}]] + +[tool.tox.env.test] +description = "run tests" +deps = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "httpx>=0.27.0"] +commands = [["pytest", { replace = "posargs", default = ["tests"], extend = true}]] + +[tool.tox.env.coverage] +description = "run tests with coverage" +deps = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "pytest-cov>=5.0.0", "httpx>=0.27.0"] +commands = [ + ["pytest", "--cov=github_pm", "--cov-report=term-missing", "--cov-report=html", { replace = "posargs", default = ["tests"], extend = true}], +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +[tool.coverage.run] +source = ["src"] +omit = [ + "*/tests/*", + "*/test_*.py", + "*/__pycache__/*", + "*/__init__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstractmethod", + "@abstractproperty", + "if False:", + "if 0:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] +show_missing = true +precision = 2 +skip_covered = false + +[tool.coverage.html] +directory = "htmlcov" diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index cbef6aa..d16239e 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -1,8 +1,9 @@ +from collections import defaultdict from datetime import date import time from typing import Annotated, AsyncGenerator -from fastapi import APIRouter, Body, Depends, HTTPException, Path +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query from github import Auth, Github, Repository from github.GithubObject import NotSet from pydantic import BaseModel, Field @@ -52,7 +53,15 @@ async def get_project(): async def get_issues( repo: Annotated[Repository, Depends(connection)], milestone_number: Annotated[int, Path(title="Milestone")], + sort: Annotated[ + str | None, Query(title="Sort", description="List of labels to sort by") + ] = None, ): + if sort: + sort_by = [s.strip() for s in sort.split(",")] + else: + sort_by = [] + sorted_issues = defaultdict(list) start = time.time() if milestone_number == 0: milestone = "none" @@ -62,11 +71,22 @@ async def get_issues( f"[Milestone {milestone_number} found: {milestone.title}: {time.time() - start:.3f} seconds]" ) issues = repo.get_issues(milestone=milestone, state="open") - simplified = [i.raw_data for i in issues] + for i in issues: + labels = set([label.name.lower() for label in i.labels]) + for label in sort_by: + if label in labels: + sorted_issues[label].append(i.raw_data) + break + else: + sorted_issues["other"].append(i.raw_data) + all_issues = [] + for label in sort_by: + all_issues.extend(sorted_issues[label]) + all_issues.extend(sorted_issues["other"]) print( - f"[{issues.totalCount}({len(simplified)}) issues: {time.time() - start:.3f} seconds]" + f"[{issues.totalCount}({len(all_issues)}) issues: {time.time() - start:.3f} seconds]" ) - return simplified + return all_issues @api_router.get("/comments/{issue_number}") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..f5ed6ed --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,13 @@ +"""Pytest configuration and fixtures. + +ai-generated: Cursor +""" + +from pathlib import Path +import sys + +# Add src directory to Python path for imports +backend_dir = Path(__file__).parent.parent +src_dir = backend_dir / "src" +if str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..36845e4 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,635 @@ +"""Tests for the api module. + +ai-generated: Cursor +""" + +from datetime import date +from unittest.mock import Mock, patch + +from fastapi import HTTPException +from fastapi.testclient import TestClient +import pytest + +from github_pm.api import ( + add_label_to_issue, + add_milestone_to_issue, + api_router, + connection, + create_label, + create_milestone, + CreateLabel, + CreateMilestone, + delete_label, + delete_milestone, + get_comments, + get_issues, + get_labels, + get_milestones, + get_project, + remove_label_from_issue, + remove_milestone_from_issue, +) +from github_pm.app import app + + +class TestConnection: + """Test the connection dependency.""" + + @pytest.mark.asyncio + async def test_connection_success(self): + """Test successful GitHub connection.""" + # Arrange + mock_repo = Mock() + mock_repo.name = "test-repo" + mock_github = Mock() + mock_github.get_repo.return_value = mock_repo + + with ( + patch("github_pm.api.Github", return_value=mock_github), + patch("github_pm.api.context") as mock_context, + ): + mock_context.github_repo = "test/repo" + mock_context.github_token = "test_token" + + # Act + async_gen = connection() + repo = await async_gen.__anext__() + + # Assert + assert repo == mock_repo + mock_github.get_repo.assert_called_once_with("test/repo") + + # Clean up - trigger the finally block + try: + await async_gen.__anext__() + except StopAsyncIteration: + pass + mock_github.close.assert_called_once() + + @pytest.mark.asyncio + async def test_connection_github_init_error(self): + """Test connection when GitHub initialization fails.""" + # Arrange + with ( + patch("github_pm.api.Github", side_effect=Exception("Auth failed")), + patch("github_pm.api.context") as mock_context, + ): + mock_context.github_repo = "test/repo" + mock_context.github_token = "test_token" + + # Act & Assert + async_gen = connection() + with pytest.raises(HTTPException) as exc_info: + await async_gen.__anext__() + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_connection_get_repo_error(self): + """Test connection when get_repo fails.""" + # Arrange + mock_github = Mock() + mock_github.get_repo.side_effect = Exception("Repo not found") + + with ( + patch("github_pm.api.Github", return_value=mock_github), + patch("github_pm.api.context") as mock_context, + ): + mock_context.github_repo = "test/repo" + mock_context.github_token = "test_token" + + # Act & Assert + async_gen = connection() + with pytest.raises(HTTPException) as exc_info: + await async_gen.__anext__() + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_connection_closes_on_exit(self): + """Test that GitHub connection is closed after use.""" + # Arrange + mock_repo = Mock() + mock_repo.name = "test-repo" + mock_github = Mock() + mock_github.get_repo.return_value = mock_repo + + with ( + patch("github_pm.api.Github", return_value=mock_github), + patch("github_pm.api.context") as mock_context, + ): + mock_context.github_repo = "test/repo" + mock_context.github_token = "test_token" + + # Act + async_gen = connection() + repo = await async_gen.__anext__() + assert repo == mock_repo + + # Trigger cleanup by consuming the generator + try: + await async_gen.__anext__() + except StopAsyncIteration: + pass + + # Assert + mock_github.close.assert_called_once() + + +class TestGetProject: + """Test the get_project endpoint.""" + + @pytest.mark.asyncio + async def test_get_project(self): + """Test getting project information.""" + # Arrange + with patch("github_pm.api.context") as mock_context: + mock_context.app_name = "Test App" + mock_context.github_repo = "test/repo" + + # Act + result = await get_project() + + # Assert + assert result == { + "app_name": "Test App", + "github_repo": "test/repo", + } + + +class TestGetIssues: + """Test the get_issues endpoint.""" + + @pytest.mark.asyncio + async def test_get_issues_with_milestone(self): + """Test getting issues for a specific milestone.""" + # Arrange + mock_milestone = Mock() + mock_milestone.title = "Test Milestone" + mock_label1 = Mock() + mock_label1.name = "bug" + mock_label2 = Mock() + mock_label2.name = "feature" + mock_issue1 = Mock() + mock_issue1.raw_data = {"id": 1, "title": "Issue 1"} + mock_issue1.labels = [mock_label1] + mock_issue2 = Mock() + mock_issue2.raw_data = {"id": 2, "title": "Issue 2"} + mock_issue2.labels = [mock_label2] + + # Create an iterable mock with totalCount + class IterableIssues: + def __init__(self, issues): + self.issues = issues + self.totalCount = len(issues) + + def __iter__(self): + return iter(self.issues) + + mock_issues_obj = IterableIssues([mock_issue1, mock_issue2]) + + mock_repo = Mock() + mock_repo.get_milestone.return_value = mock_milestone + mock_repo.get_issues.return_value = mock_issues_obj + + # Act + result = await get_issues(mock_repo, milestone_number=1) + + # Assert + assert len(result) == 2 + assert result[0]["id"] == 1 + assert result[1]["id"] == 2 + mock_repo.get_milestone.assert_called_once_with(1) + mock_repo.get_issues.assert_called_once_with( + milestone=mock_milestone, state="open" + ) + + @pytest.mark.asyncio + async def test_get_issues_with_no_milestone(self): + """Test getting issues with milestone_number=0 (no milestone).""" + # Arrange + mock_issue1 = Mock() + mock_issue1.raw_data = {"id": 1, "title": "Issue 1"} + mock_issue1.labels = [] + + # Create an iterable mock with totalCount + class IterableIssues: + def __init__(self, issues): + self.issues = issues + self.totalCount = len(issues) + + def __iter__(self): + return iter(self.issues) + + mock_issues_obj = IterableIssues([mock_issue1]) + + mock_repo = Mock() + mock_repo.get_issues.return_value = mock_issues_obj + + # Act + result = await get_issues(mock_repo, milestone_number=0) + + # Assert + assert len(result) == 1 + assert result[0]["id"] == 1 + mock_repo.get_issues.assert_called_once_with(milestone="none", state="open") + mock_repo.get_milestone.assert_not_called() + + +class TestGetComments: + """Test the get_comments endpoint.""" + + @pytest.mark.asyncio + async def test_get_comments(self): + """Test getting comments for an issue.""" + # Arrange + mock_comment1 = Mock() + mock_comment1.raw_data = {"id": 1, "body": "Comment 1"} + mock_comment2 = Mock() + mock_comment2.raw_data = {"id": 2, "body": "Comment 2"} + mock_comments = Mock() + mock_comments.__iter__ = Mock(return_value=iter([mock_comment1, mock_comment2])) + + mock_issue = Mock() + mock_issue.get_comments.return_value = mock_comments + + mock_repo = Mock() + mock_repo.get_issue.return_value = mock_issue + + # Act + result = await get_comments(mock_repo, issue_number=123) + + # Assert + assert len(result) == 2 + assert result[0]["id"] == 1 + assert result[1]["id"] == 2 + mock_repo.get_issue.assert_called_once_with(123) + mock_issue.get_comments.assert_called_once() + + +class TestGetMilestones: + """Test the get_milestones endpoint.""" + + @pytest.mark.asyncio + async def test_get_milestones(self): + """Test getting all milestones.""" + # Arrange + mock_milestone1 = Mock() + mock_milestone1.title = "Milestone 1" + mock_milestone1.number = 1 + mock_milestone1.description = "Description 1" + mock_milestone1.due_on = date(2024, 12, 31) + + mock_milestone2 = Mock() + mock_milestone2.title = "Milestone 2" + mock_milestone2.number = 2 + mock_milestone2.description = "Description 2" + mock_milestone2.due_on = None + + mock_milestones = Mock() + mock_milestones.__iter__ = Mock( + return_value=iter([mock_milestone1, mock_milestone2]) + ) + + mock_repo = Mock() + mock_repo.get_milestones.return_value = mock_milestones + + # Act + result = await get_milestones(mock_repo) + + # Assert + assert len(result) == 3 # 2 milestones + 1 "none" milestone + assert result[0]["title"] == "Milestone 1" + assert result[0]["number"] == 1 + assert result[1]["title"] == "Milestone 2" + assert result[1]["number"] == 2 + assert result[2]["title"] == "none" + assert result[2]["number"] == 0 + assert result[2]["due_on"] is None + + +class TestCreateMilestone: + """Test the create_milestone endpoint.""" + + @pytest.mark.asyncio + async def test_create_milestone_success(self): + """Test successfully creating a milestone.""" + # Arrange + mock_milestone = Mock() + mock_milestone.raw_data = {"id": 1, "title": "New Milestone"} + + mock_repo = Mock() + mock_repo.create_milestone.return_value = mock_milestone + + milestone_data = CreateMilestone( + title="New Milestone", + description="Test description", + due_on=date(2024, 12, 31), + ) + + # Act + result = await create_milestone(mock_repo, milestone_data) + + # Assert + assert result == mock_milestone.raw_data + mock_repo.create_milestone.assert_called_once_with( + title="New Milestone", + state="open", + description="Test description", + due_on=date(2024, 12, 31), + ) + + @pytest.mark.asyncio + async def test_create_milestone_with_defaults(self): + """Test creating a milestone with default values.""" + # Arrange + from github.GithubObject import NotSet + + mock_milestone = Mock() + mock_milestone.raw_data = {"id": 1, "title": "New Milestone"} + + mock_repo = Mock() + mock_repo.create_milestone.return_value = mock_milestone + + milestone_data = CreateMilestone(title="New Milestone") + + # Act + result = await create_milestone(mock_repo, milestone_data) + + # Assert + assert result == mock_milestone.raw_data + mock_repo.create_milestone.assert_called_once_with( + title="New Milestone", state="open", description=NotSet, due_on=NotSet + ) + + +class TestDeleteMilestone: + """Test the delete_milestone endpoint.""" + + @pytest.mark.asyncio + async def test_delete_milestone_success(self): + """Test successfully deleting a milestone.""" + # Arrange + mock_milestone = Mock() + mock_milestone.delete = Mock() + + mock_repo = Mock() + mock_repo.get_milestone.return_value = mock_milestone + + # Act + result = await delete_milestone(mock_repo, milestone_number=1) + + # Assert + assert result == {"message": "1 milestone deleted"} + mock_repo.get_milestone.assert_called_once_with(1) + mock_milestone.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_delete_milestone_not_found(self): + """Test deleting a milestone that doesn't exist.""" + # Arrange + mock_repo = Mock() + mock_repo.get_milestone.side_effect = Exception("Milestone not found") + + # Act & Assert + with pytest.raises(Exception): + await delete_milestone(mock_repo, milestone_number=999) + + +class TestAddMilestoneToIssue: + """Test the add_milestone_to_issue endpoint.""" + + @pytest.mark.asyncio + async def test_add_milestone_to_issue(self): + """Test adding a milestone to an issue.""" + # Arrange + mock_issue = Mock() + mock_issue.edit = Mock() + + mock_milestone = Mock() + + mock_repo = Mock() + mock_repo.get_issue.return_value = mock_issue + mock_repo.get_milestone.return_value = mock_milestone + + # Act + result = await add_milestone_to_issue( + mock_repo, issue_number=123, milestone_number=1 + ) + + # Assert + assert result == {"message": "1 milestone added to issue 123"} + mock_repo.get_issue.assert_called_once_with(123) + mock_repo.get_milestone.assert_called_once_with(1) + mock_issue.edit.assert_called_once_with(milestone=mock_milestone) + + +class TestRemoveMilestoneFromIssue: + """Test the remove_milestone_from_issue endpoint.""" + + @pytest.mark.asyncio + async def test_remove_milestone_from_issue(self): + """Test removing a milestone from an issue.""" + # Arrange + mock_issue = Mock() + mock_issue.edit = Mock() + + mock_repo = Mock() + mock_repo.get_issue.return_value = mock_issue + + # Act + result = await remove_milestone_from_issue( + mock_repo, issue_number=123, milestone_number=1 + ) + + # Assert + assert result == {"message": "1 milestone removed from issue 123"} + mock_repo.get_issue.assert_called_once_with(123) + mock_issue.edit.assert_called_once_with(milestone=None) + + +class TestGetLabels: + """Test the get_labels endpoint.""" + + @pytest.mark.asyncio + async def test_get_labels(self): + """Test getting all labels.""" + # Arrange + mock_label1 = Mock() + mock_label1.raw_data = {"id": 1, "name": "bug", "color": "red"} + mock_label2 = Mock() + mock_label2.raw_data = {"id": 2, "name": "feature", "color": "blue"} + + mock_labels = Mock() + mock_labels.__iter__ = Mock(return_value=iter([mock_label1, mock_label2])) + + mock_repo = Mock() + mock_repo.get_labels.return_value = mock_labels + + # Act + result = await get_labels(mock_repo) + + # Assert + assert len(result) == 2 + assert result[0]["name"] == "bug" + assert result[1]["name"] == "feature" + mock_repo.get_labels.assert_called_once() + + +class TestCreateLabel: + """Test the create_label endpoint.""" + + @pytest.mark.asyncio + async def test_create_label_success(self): + """Test successfully creating a label.""" + # Arrange + mock_label = Mock() + mock_label.name = "new-label" + mock_label.raw_data = {"id": 1, "name": "new-label", "color": "green"} + + mock_repo = Mock() + mock_repo.create_label.return_value = mock_label + + label_data = CreateLabel( + name="new-label", color="green", description="Test label" + ) + + # Act + result = await create_label(mock_repo, label_data) + + # Assert + assert result == mock_label.raw_data + mock_repo.create_label.assert_called_once_with( + name="new-label", color="green", description="Test label" + ) + + @pytest.mark.asyncio + async def test_create_label_with_defaults(self): + """Test creating a label with default description.""" + # Arrange + from github.GithubObject import NotSet + + mock_label = Mock() + mock_label.name = "new-label" + mock_label.raw_data = {"id": 1, "name": "new-label", "color": "green"} + + mock_repo = Mock() + mock_repo.create_label.return_value = mock_label + + label_data = CreateLabel(name="new-label", color="green") + + # Act + result = await create_label(mock_repo, label_data) + + # Assert + assert result == mock_label.raw_data + mock_repo.create_label.assert_called_once_with( + name="new-label", color="green", description=NotSet + ) + + +class TestDeleteLabel: + """Test the delete_label endpoint.""" + + @pytest.mark.asyncio + async def test_delete_label_success(self): + """Test successfully deleting a label.""" + # Arrange + mock_label = Mock() + mock_label.delete = Mock() + + mock_repo = Mock() + mock_repo.get_label.return_value = mock_label + + # Act + result = await delete_label(mock_repo, label_name="bug") + + # Assert + assert result == {"message": "bug label deleted"} + mock_repo.get_label.assert_called_once_with("bug") + mock_label.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_delete_label_not_found(self): + """Test deleting a label that doesn't exist.""" + # Arrange + mock_repo = Mock() + mock_repo.get_label.side_effect = Exception("Label not found") + + # Act & Assert + with pytest.raises(Exception): + await delete_label(mock_repo, label_name="nonexistent") + + +class TestAddLabelToIssue: + """Test the add_label_to_issue endpoint.""" + + @pytest.mark.asyncio + async def test_add_label_to_issue(self): + """Test adding a label to an issue.""" + # Arrange + mock_issue = Mock() + mock_issue.add_to_labels = Mock() + + mock_repo = Mock() + mock_repo.get_issue.return_value = mock_issue + + # Act + result = await add_label_to_issue(mock_repo, issue_number=123, label_name="bug") + + # Assert + assert result == {"message": "bug label added to issue 123"} + mock_repo.get_issue.assert_called_once_with(123) + mock_issue.add_to_labels.assert_called_once_with("bug") + + +class TestRemoveLabelFromIssue: + """Test the remove_label_from_issue endpoint.""" + + @pytest.mark.asyncio + async def test_remove_label_from_issue(self): + """Test removing a label from an issue.""" + # Arrange + mock_issue = Mock() + mock_issue.remove_from_labels = Mock() + + mock_repo = Mock() + mock_repo.get_issue.return_value = mock_issue + + # Act + result = await remove_label_from_issue( + mock_repo, issue_number=123, label_name="bug" + ) + + # Assert + assert result is None + mock_repo.get_issue.assert_called_once_with(123) + mock_issue.remove_from_labels.assert_called_once_with("bug") + + +class TestAPIRouterIntegration: + """Test API router integration with FastAPI app.""" + + def test_api_router_exists(self): + """Test that api_router is defined.""" + # Assert + assert api_router is not None + assert hasattr(api_router, "routes") + + def test_api_endpoints_registered(self): + """Test that API endpoints are registered in the router.""" + # Assert + assert len(api_router.routes) > 0 + + def test_get_project_endpoint(self): + """Test the /api/v1/project endpoint via TestClient.""" + # Arrange + client = TestClient(app) + + # Act + response = client.get("/api/v1/project") + + # Assert + # Should succeed (doesn't require GitHub connection) + assert response.status_code == 200 + data = response.json() + assert "app_name" in data + assert "github_repo" in data diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py new file mode 100644 index 0000000..e328b6c --- /dev/null +++ b/backend/tests/test_app.py @@ -0,0 +1,150 @@ +"""Tests for the app module. + +ai-generated: Cursor +""" + +from fastapi.testclient import TestClient + +from github_pm.app import app, router + + +class TestApp: + """Test the FastAPI application.""" + + def test_app_creation(self): + """Test that the FastAPI app is created successfully.""" + # Assert + assert app is not None + assert hasattr(app, "routes") + assert hasattr(app, "router") + + def test_app_configuration(self): + """Test that the app has correct title and version.""" + # Assert + assert app.title == "GitHub Project Management API" + assert app.version == "0.1.0" + + def test_health_endpoint(self): + """Test the health check endpoint.""" + # Arrange + client = TestClient(app) + + # Act + response = client.get("/health") + + # Assert + assert response.status_code == 200 + data = response.json() + assert data == {"message": "OK"} + + def test_health_endpoint_content_type(self): + """Test that health endpoint returns correct content type.""" + # Arrange + client = TestClient(app) + + # Act + response = client.get("/health") + + # Assert + assert response.headers["content-type"] == "application/json" + + def test_health_endpoint_methods(self): + """Test that health endpoint only accepts GET method.""" + # Arrange + client = TestClient(app) + + # Act & Assert + # GET should work + response = client.get("/health") + assert response.status_code == 200 + + # POST should return 405 Method Not Allowed or 404 Not Found + response = client.post("/health") + assert response.status_code in [404, 405] + + # PUT should return 405 Method Not Allowed or 404 Not Found + response = client.put("/health") + assert response.status_code in [404, 405] + + # DELETE should return 405 Method Not Allowed or 404 Not Found + response = client.delete("/health") + assert response.status_code in [404, 405] + + def test_api_router_included(self): + """Test that the API router is included at the correct prefix.""" + # Arrange + client = TestClient(app) + + # Act - Try to access an endpoint from the API router + # The /api/v1/project endpoint should exist + response = client.get("/api/v1/project") + + # Assert + # The endpoint should exist (may return 200 or error depending on dependencies) + # We just verify it's not a 404 for a non-existent route + # If it's 400, that's expected due to missing GitHub connection + # If it's 200, that's also valid + assert response.status_code != 404 + + def test_router_exists(self): + """Test that the router is properly defined.""" + # Assert + assert router is not None + assert hasattr(router, "routes") + + def test_health_route_in_router(self): + """Test that the health route is registered in the router.""" + # Assert + # Check that the router has at least one route + assert len(router.routes) > 0 + + # Check that there's a route for /health + # FastAPI routes can have different structures, so we check multiple ways + health_routes = [ + route + for route in router.routes + if hasattr(route, "path") and route.path == "/health" + ] + # If no direct path match, check if health endpoint is accessible via test client + if len(health_routes) == 0: + client = TestClient(app) + response = client.get("/health") + # If we can access it, the route exists (even if not directly in router.routes) + assert response.status_code == 200 + else: + assert len(health_routes) > 0 + + def test_app_includes_router(self): + """Test that the app includes the router.""" + # Assert + # The app should have routes from the router + assert len(app.routes) > 0 + + def test_nonexistent_endpoint_returns_404(self): + """Test that a nonexistent endpoint returns 404.""" + # Arrange + client = TestClient(app) + + # Act + response = client.get("/nonexistent") + + # Assert + assert response.status_code == 404 + + def test_api_prefix_correct(self): + """Test that API routes are accessible at /api/v1 prefix.""" + # Arrange + client = TestClient(app) + + # Act - Try accessing a route that should be at /api/v1 + # We'll check that /api/v1/project exists (even if it fails due to dependencies) + response = client.get("/api/v1/project") + + # Assert + # Should not be 404 (route exists) + # Could be 400 (missing dependencies) or 200 (success) + assert response.status_code != 404 + + # Verify that the same route without prefix doesn't exist + response_no_prefix = client.get("/project") + assert response_no_prefix.status_code == 404 diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py new file mode 100644 index 0000000..d3c2177 --- /dev/null +++ b/backend/tests/test_cli.py @@ -0,0 +1,88 @@ +"""Tests for the cli module. + +ai-generated: Cursor +""" + +import sys +from unittest.mock import MagicMock, patch + +# Mock uvicorn before importing cli module to avoid import errors +sys.modules["uvicorn"] = MagicMock() + +from github_pm.cli import main # noqa: E402 (must be after mock) + + +class TestMain: + """Test the main CLI function.""" + + @patch("github_pm.cli.uvicorn.run") + @patch("github_pm.cli.os.getpgid") + @patch("builtins.print") + def test_main_calls_getpgid_and_prints_message( + self, mock_print, mock_getpgid, mock_uvicorn_run + ): + """Test that main gets the process group ID and prints the kill command.""" + # Arrange + mock_getpgid.return_value = 12345 + + # Act + main() + + # Assert + mock_getpgid.assert_called_once_with(0) + mock_print.assert_called_once_with("'kill -- -12345' to stop the server") + + @patch("github_pm.cli.uvicorn.run") + @patch("github_pm.cli.os.getpgid") + def test_main_calls_uvicorn_with_correct_parameters( + self, mock_getpgid, mock_uvicorn_run + ): + """Test that main calls uvicorn.run with the correct parameters.""" + # Arrange + mock_getpgid.return_value = 12345 + + # Act + main() + + # Assert + mock_uvicorn_run.assert_called_once_with( + "github_pm.app:app", + host="0.0.0.0", + port=8000, + reload=True, + ) + + @patch("github_pm.cli.uvicorn.run") + @patch("github_pm.cli.os.getpgid") + def test_main_handles_different_pgid_values(self, mock_getpgid, mock_uvicorn_run): + """Test that main works with different process group ID values.""" + # Arrange + mock_getpgid.return_value = 99999 + + # Act + main() + + # Assert + mock_getpgid.assert_called_once_with(0) + mock_uvicorn_run.assert_called_once() + + @patch("github_pm.cli.uvicorn.run") + @patch("github_pm.cli.os.getpgid") + @patch("builtins.print") + def test_main_prints_correct_kill_command_format( + self, mock_print, mock_getpgid, mock_uvicorn_run + ): + """Test that the printed kill command has the correct format.""" + # Arrange + test_pgids = [1, 100, 12345, 999999] + + for pgid in test_pgids: + mock_getpgid.return_value = pgid + mock_print.reset_mock() + + # Act + main() + + # Assert + expected_message = f"'kill -- -{pgid}' to stop the server" + mock_print.assert_called_once_with(expected_message) diff --git a/backend/tests/test_context.py b/backend/tests/test_context.py new file mode 100644 index 0000000..36cba1e --- /dev/null +++ b/backend/tests/test_context.py @@ -0,0 +1,331 @@ +"""Tests for the context module. + +ai-generated: Cursor +""" + +import os +from unittest.mock import patch + +from github_pm.context import context, Settings + + +class TestSettings: + """Test the Settings class.""" + + def test_default_settings(self): + """Test that default settings are correct.""" + # Arrange - Clear env vars that might override defaults + env_vars_to_clear = ["APP_NAME", "GITHUB_REPO", "GITHUB_TOKEN"] + original_values = {} + for var in env_vars_to_clear: + original_values[var] = os.environ.pop(var, None) + + try: + # Act - Create settings without .env file by using a temp directory + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + settings = Settings() + finally: + os.chdir(original_cwd) + + # Assert + assert settings.app_name == "GitHub Project Manager" + assert settings.github_repo == "vllm-project/guidellm" + assert settings.github_token == "" + finally: + # Restore original environment + for var, value in original_values.items(): + if value is not None: + os.environ[var] = value + + def test_custom_settings_from_env(self): + """Test that settings can be overridden from environment variables.""" + # Arrange + env_vars = { + "APP_NAME": "My Custom App", + "GITHUB_REPO": "myorg/myrepo", + "GITHUB_TOKEN": "ghp_test_token_12345", + } + + # Act + with patch.dict(os.environ, env_vars, clear=False): + settings = Settings() + + # Assert + assert settings.app_name == "My Custom App" + assert settings.github_repo == "myorg/myrepo" + assert settings.github_token == "ghp_test_token_12345" + + def test_case_insensitive_env_vars(self): + """Test that environment variables are case-insensitive.""" + # Arrange + env_vars = { + "app_name": "Lowercase App Name", + "GITHUB_REPO": "test/repo", + "github_token": "lowercase_token", + } + + # Act + with patch.dict(os.environ, env_vars, clear=False): + settings = Settings() + + # Assert + assert settings.app_name == "Lowercase App Name" + assert settings.github_repo == "test/repo" + assert settings.github_token == "lowercase_token" + + def test_mixed_case_env_vars(self): + """Test that mixed case environment variables work.""" + # Arrange + env_vars = { + "App_Name": "Mixed Case App", + "GitHub_Repo": "mixed/case", + "GITHUB_TOKEN": "mixed_token", + } + + # Act + with patch.dict(os.environ, env_vars, clear=False): + settings = Settings() + + # Assert + assert settings.app_name == "Mixed Case App" + assert settings.github_repo == "mixed/case" + assert settings.github_token == "mixed_token" + + def test_extra_fields_ignored(self): + """Test that extra fields in environment are ignored.""" + # Arrange + env_vars = { + "APP_NAME": "Test App", + "EXTRA_FIELD_1": "should be ignored", + "EXTRA_FIELD_2": "also ignored", + "UNRELATED_VAR": "ignored too", + } + + # Act + with patch.dict(os.environ, env_vars, clear=False): + settings = Settings() + + # Assert + assert settings.app_name == "Test App" + # Verify extra fields are not accessible + assert not hasattr(settings, "EXTRA_FIELD_1") + assert not hasattr(settings, "EXTRA_FIELD_2") + assert not hasattr(settings, "UNRELATED_VAR") + + def test_env_file_loading(self, tmp_path): + """Test that settings can be loaded from .env file.""" + # Arrange + env_file = tmp_path / ".env" + env_file.write_text( + "APP_NAME=Env File App\n" + "GITHUB_REPO=envfile/repo\n" + "GITHUB_TOKEN=env_file_token\n" + ) + + # Act + with patch.dict(os.environ, {}, clear=False): + # Temporarily change directory to where .env file is located + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + settings = Settings() + finally: + os.chdir(original_cwd) + + # Assert + assert settings.app_name == "Env File App" + assert settings.github_token == "env_file_token" + assert settings.github_repo == "envfile/repo" + + def test_env_file_override_by_env_vars(self, tmp_path): + """Test that environment variables override .env file values.""" + # Arrange + env_file = tmp_path / ".env" + env_file.write_text( + "APP_NAME=Env File App\n" + "GITHUB_REPO=envfile/repo\n" + "GITHUB_TOKEN=env_file_token\n" + ) + + env_vars = { + "APP_NAME": "Env Var Override", + "GITHUB_TOKEN": "env_var_token", + } + + # Act + with patch.dict(os.environ, env_vars, clear=False): + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + settings = Settings() + finally: + os.chdir(original_cwd) + + # Assert + # Environment variables should override .env file + assert settings.app_name == "Env Var Override" + assert settings.github_token == "env_var_token" + # This one should come from .env file + assert settings.github_repo == "envfile/repo" + + def test_empty_string_values(self): + """Test that empty string values are handled correctly.""" + # Arrange + env_vars = { + "APP_NAME": "", + "GITHUB_REPO": "", + "GITHUB_TOKEN": "", + } + + # Act + with patch.dict(os.environ, env_vars, clear=False): + settings = Settings() + + # Assert + assert settings.app_name == "" + assert settings.github_repo == "" + assert settings.github_token == "" + + def test_settings_attributes_exist(self): + """Test that all expected settings attributes exist.""" + # Arrange + settings = Settings() + + # Act & Assert + assert hasattr(settings, "app_name") + assert hasattr(settings, "github_repo") + assert hasattr(settings, "github_token") + assert isinstance(settings.app_name, str) + assert isinstance(settings.github_repo, str) + assert isinstance(settings.github_token, str) + + def test_settings_model_config(self): + """Test that Settings model configuration is correct.""" + # Arrange + settings = Settings() + + # Act & Assert + # Verify model_config settings (model_config is a dict in Pydantic v2) + config = settings.model_config + assert config.get("extra") == "ignore" + assert config.get("validate_default") is True + assert config.get("case_sensitive") is False + assert config.get("env_file") == ".env" + + def test_partial_env_override(self): + """Test that only some settings can be overridden from environment.""" + # Arrange + env_vars = { + "GITHUB_TOKEN": "partial_token", + } + # Clear other env vars that might interfere + env_vars_to_clear = ["APP_NAME", "GITHUB_REPO"] + original_values = {} + for var in env_vars_to_clear: + original_values[var] = os.environ.pop(var, None) + + try: + # Act + with patch.dict(os.environ, env_vars, clear=False): + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + settings = Settings() + finally: + os.chdir(original_cwd) + + # Assert + # Only github_token should be overridden + assert settings.github_token == "partial_token" + # Others should use defaults + assert settings.app_name == "GitHub Project Manager" + assert settings.github_repo == "vllm-project/guidellm" + finally: + # Restore original environment + for var, value in original_values.items(): + if value is not None: + os.environ[var] = value + + def test_settings_are_pydantic_model(self): + """Test that Settings is a proper Pydantic model.""" + # Arrange & Act + settings = Settings() + + # Assert + # Verify it has Pydantic model methods + assert hasattr(settings, "model_dump") + assert hasattr(settings, "model_validate") + assert hasattr(settings, "model_config") + + def test_settings_model_dump(self): + """Test that Settings can be dumped to dict.""" + # Arrange - Clear env vars to get defaults + env_vars_to_clear = ["APP_NAME", "GITHUB_REPO", "GITHUB_TOKEN"] + original_values = {} + for var in env_vars_to_clear: + original_values[var] = os.environ.pop(var, None) + + try: + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + settings = Settings() + finally: + os.chdir(original_cwd) + + # Act + dumped = settings.model_dump() + + # Assert + assert isinstance(dumped, dict) + assert "app_name" in dumped + assert "github_repo" in dumped + assert "github_token" in dumped + assert dumped["app_name"] == "GitHub Project Manager" + assert dumped["github_repo"] == "vllm-project/guidellm" + assert dumped["github_token"] == "" + finally: + # Restore original environment + for var, value in original_values.items(): + if value is not None: + os.environ[var] = value + + +class TestContext: + """Test the context module-level instance.""" + + def test_context_is_settings_instance(self): + """Test that context is an instance of Settings.""" + # Assert + assert isinstance(context, Settings) + + def test_context_has_attributes(self): + """Test that context has all expected attributes.""" + # Assert + assert isinstance(context.app_name, str) + assert isinstance(context.github_repo, str) + assert isinstance(context.github_token, str) + assert hasattr(context, "app_name") + assert hasattr(context, "github_repo") + assert hasattr(context, "github_token") + + def test_context_is_singleton(self): + """Test that context is a module-level singleton instance.""" + # Arrange & Act + from github_pm.context import context as context1 + from github_pm.context import context as context2 + + # Assert + assert context1 is context2 + assert id(context1) == id(context2) diff --git a/backend/uv.lock b/backend/uv.lock index e4a22d1..9b64493 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -159,6 +159,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, +] + [[package]] name = "cryptography" version = "46.0.3" @@ -264,7 +299,7 @@ wheels = [ [[package]] name = "github-pm" -version = "0.1.0" +version = "0.0.1" source = { editable = "." } dependencies = [ { name = "click" }, @@ -278,7 +313,11 @@ dependencies = [ dev = [ { name = "black" }, { name = "flake8" }, + { name = "httpx" }, { name = "isort" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "tox" }, ] @@ -288,9 +327,13 @@ requires-dist = [ { name = "click", specifier = ">=8.3.1" }, { name = "fastapi", specifier = ">=0.123.9" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.3.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=6.0.1" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pygithub", specifier = ">=2.8.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "tox", marker = "extra == 'dev'", specifier = ">=4.23.2" }, { name = "uvicorn", specifier = ">=0.38.0" }, ] @@ -305,6 +348,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -314,6 +385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "isort" version = "7.0.0" @@ -488,6 +568,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/ba/7049ce39f653f6140aac4beb53a5aaf08b4407b6a3019aae394c1c5244ff/pygithub-2.8.1-py3-none-any.whl", hash = "sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0", size = 432709, upload-time = "2025-09-02T17:41:52.947Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyjwt" version = "2.10.1" @@ -551,6 +640,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" diff --git a/frontend/index.html b/frontend/index.html index eab173b..8e4bde8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,3 +1,4 @@ + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 513fe3a..0a61ded 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import React, { useState, useEffect } from 'react'; import { Page, @@ -12,6 +13,7 @@ import { fetchMilestones, fetchProject, fetchLabels } from './services/api'; import MilestoneCard from './components/MilestoneCard'; import ManageMilestones from './components/ManageMilestones'; import ManageLabels from './components/ManageLabels'; +import ManageSort from './components/ManageSort'; import milestonesCache from './utils/milestonesCache'; import labelsCache, { clearLabelsCache } from './utils/labelsCache'; @@ -24,6 +26,8 @@ const App = () => { const [projectLoading, setProjectLoading] = useState(true); const [isManageMilestonesOpen, setIsManageMilestonesOpen] = useState(false); const [isManageLabelsOpen, setIsManageLabelsOpen] = useState(false); + const [isManageSortOpen, setIsManageSortOpen] = useState(false); + const [sortOrder, setSortOrder] = useState([]); useEffect(() => { fetchProject() @@ -174,6 +178,12 @@ const App = () => { > Manage Labels + @@ -201,7 +211,11 @@ const App = () => { style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }} > {milestones.map((milestone) => ( - + ))} )} @@ -216,6 +230,12 @@ const App = () => { onClose={() => setIsManageLabelsOpen(false)} onLabelChange={handleLabelChange} /> + setIsManageSortOpen(false)} + sortOrder={sortOrder} + onSortChange={setSortOrder} + /> ); }; diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index cd16e5a..b8c33d8 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import App from './App'; diff --git a/frontend/src/components/CommentCard.jsx b/frontend/src/components/CommentCard.jsx index 4bcda4b..258a8ff 100644 --- a/frontend/src/components/CommentCard.jsx +++ b/frontend/src/components/CommentCard.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; diff --git a/frontend/src/components/CommentCard.test.jsx b/frontend/src/components/CommentCard.test.jsx index 6f94558..a6e8f82 100644 --- a/frontend/src/components/CommentCard.test.jsx +++ b/frontend/src/components/CommentCard.test.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import CommentCard from './CommentCard'; diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index d5ac9d0..762440e 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import React, { useState, useEffect, useRef } from 'react'; import { Card, @@ -16,6 +17,7 @@ import { Form, FormGroup, } from '@patternfly/react-core'; +import { CodeBranchIcon } from '@patternfly/react-icons'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { getDaysSince, formatDate } from '../utils/dateUtils'; @@ -451,6 +453,18 @@ const IssueCard = ({ issue, onMilestoneChange }) => { > #{issue.number} + {issue.pull_request && ( + + + + )} {' - '} {issue.title} {issue.type && ( diff --git a/frontend/src/components/IssueCard.test.jsx b/frontend/src/components/IssueCard.test.jsx index 270a34c..938a88a 100644 --- a/frontend/src/components/IssueCard.test.jsx +++ b/frontend/src/components/IssueCard.test.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/frontend/src/components/ManageLabels.jsx b/frontend/src/components/ManageLabels.jsx index c59c109..27505cb 100644 --- a/frontend/src/components/ManageLabels.jsx +++ b/frontend/src/components/ManageLabels.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import React, { useState, useEffect } from 'react'; import { Modal, diff --git a/frontend/src/components/ManageMilestones.jsx b/frontend/src/components/ManageMilestones.jsx index 608e9a7..99bdd58 100644 --- a/frontend/src/components/ManageMilestones.jsx +++ b/frontend/src/components/ManageMilestones.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import React, { useState, useEffect } from 'react'; import { Modal, diff --git a/frontend/src/components/ManageSort.jsx b/frontend/src/components/ManageSort.jsx new file mode 100644 index 0000000..b7764fd --- /dev/null +++ b/frontend/src/components/ManageSort.jsx @@ -0,0 +1,331 @@ +// ai-generated: Cursor +import React, { useState, useEffect, useRef } from 'react'; +import { Modal, Button, Tooltip, Spinner, Alert } from '@patternfly/react-core'; +import { fetchLabels } from '../services/api'; +import labelsCache from '../utils/labelsCache'; + +const ManageSort = ({ isOpen, onClose, sortOrder, onSortChange }) => { + const [allLabels, setAllLabels] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const dragItemRef = useRef(null); + + useEffect(() => { + if (isOpen) { + loadLabels(); + } + }, [isOpen]); + + const loadLabels = () => { + // Use cached data if available + if (labelsCache.data.length > 0) { + setAllLabels(labelsCache.data); + setLoading(false); + setError(labelsCache.error); + return; + } + + // If data is being loaded, wait for it + if (labelsCache.promise) { + setLoading(true); + labelsCache.promise + .then(() => { + setAllLabels(labelsCache.data); + setLoading(false); + setError(labelsCache.error); + }) + .catch(() => { + setLoading(false); + setError(labelsCache.error); + }); + return; + } + + // Otherwise load fresh + setLoading(true); + setError(null); + fetchLabels() + .then((data) => { + setAllLabels(data); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }; + + // Get current sort order labels (in order) and available labels (not in sort order) + const getCurrentSortLabels = () => { + return sortOrder + .map((labelName) => allLabels.find((l) => l.name === labelName)) + .filter((label) => label !== undefined); + }; + + const getAvailableLabels = () => { + const currentLabelNames = new Set(sortOrder); + return allLabels.filter((label) => !currentLabelNames.has(label.name)); + }; + + const handleDragStart = (e, index) => { + setDraggedIndex(index); + dragItemRef.current = index; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', e.target); + }; + + const handleDragOver = (e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverIndex(index); + }; + + const handleDragLeave = () => { + setDragOverIndex(null); + }; + + const handleDrop = (e, dropIndex) => { + e.preventDefault(); + setDragOverIndex(null); + + if (draggedIndex === null || draggedIndex === dropIndex) { + setDraggedIndex(null); + return; + } + + const currentSortLabels = getCurrentSortLabels(); + const newSortLabels = [...currentSortLabels]; + const draggedItem = newSortLabels[draggedIndex]; + newSortLabels.splice(draggedIndex, 1); + newSortLabels.splice(dropIndex, 0, draggedItem); + + const newSortOrder = newSortLabels.map((label) => label.name); + onSortChange(newSortOrder); + setDraggedIndex(null); + }; + + const handleRemoveLabel = (labelName) => { + const newSortOrder = sortOrder.filter((name) => name !== labelName); + onSortChange(newSortOrder); + }; + + const handleAddLabel = (labelName) => { + const newSortOrder = [...sortOrder, labelName]; + onSortChange(newSortOrder); + }; + + const getContrastColor = (hexColor) => { + if (!hexColor) return '#000000'; + // Remove # if present + const color = hexColor.replace('#', ''); + const r = parseInt(color.substring(0, 2), 16); + const g = parseInt(color.substring(2, 4), 16); + const b = parseInt(color.substring(4, 6), 16); + // Calculate luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? '#000000' : '#ffffff'; + }; + + const currentSortLabels = getCurrentSortLabels(); + const availableLabels = getAvailableLabels(); + + return ( + + Close + , + ]} + width="80%" + maxWidth="800px" + > +
+

+ Drag labels to reorder. Issues will be sorted by labels in the order + shown below. Click on labels in the "Available Labels" section to add + them. +

+
+ + {loading && ( +
+ +
+ )} + + {error && ( + + {error} + + )} + + {!loading && !error && ( +
+ {currentSortLabels.length === 0 ? ( +

+ No labels in sort order. Click "+ Add Label" to add labels. +

+ ) : ( +
+ {currentSortLabels.map((label, index) => ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={() => { + setDraggedIndex(null); + setDragOverIndex(null); + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.75rem', + backgroundColor: + dragOverIndex === index + ? '#f0f0f0' + : draggedIndex === index + ? '#e0e0e0' + : '#fff', + border: '1px solid #d2d2d2', + borderRadius: '0.25rem', + cursor: 'move', + opacity: draggedIndex === index ? 0.5 : 1, + transition: 'background-color 0.2s', + }} + > + + ⋮⋮ + + + + {label.name} + + + +
+ ))} +
+ )} + + {availableLabels.length > 0 && ( +
+

+ Available Labels +

+
+ {availableLabels.map((label) => ( + + handleAddLabel(label.name)} + onMouseEnter={(e) => { + e.currentTarget.style.opacity = '0.8'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = '1'; + }} + > + + {label.name} + + + ))} +
+
+ )} +
+ )} +
+ ); +}; + +export default ManageSort; diff --git a/frontend/src/components/MilestoneCard.jsx b/frontend/src/components/MilestoneCard.jsx index 3403647..05566e1 100644 --- a/frontend/src/components/MilestoneCard.jsx +++ b/frontend/src/components/MilestoneCard.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import React, { useState, useEffect, useRef } from 'react'; import { Card, @@ -11,7 +12,7 @@ import { import { fetchIssues } from '../services/api'; import IssueCard from './IssueCard'; -const MilestoneCard = ({ milestone }) => { +const MilestoneCard = ({ milestone, sortOrder = [] }) => { const [isExpanded, setIsExpanded] = useState(false); const [issues, setIssues] = useState([]); const [loading, setLoading] = useState(false); @@ -34,7 +35,7 @@ const MilestoneCard = ({ milestone }) => { if (isExpanded && !hasLoadedOnce && !loading) { setLoading(true); setError(null); - fetchIssues(milestone.number) + fetchIssues(milestone.number, sortOrder) .then((data) => { setIssues(data); setLoading(false); @@ -46,7 +47,32 @@ const MilestoneCard = ({ milestone }) => { setHasLoadedOnce(true); }); } - }, [isExpanded, milestone.number, hasLoadedOnce, loading]); + }, [isExpanded, milestone.number, hasLoadedOnce, loading, sortOrder]); + + // Re-fetch issues when sort order changes (if already loaded) + const prevSortOrderRef = useRef(sortOrder); + useEffect(() => { + // Only refetch if sort order actually changed and issues are already loaded + const sortOrderChanged = + JSON.stringify(prevSortOrderRef.current) !== JSON.stringify(sortOrder); + if (isExpanded && hasLoadedOnce && !loading && sortOrderChanged) { + prevSortOrderRef.current = sortOrder; + setLoading(true); + setError(null); + fetchIssues(milestone.number, sortOrder) + .then((data) => { + setIssues(data); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + } else { + prevSortOrderRef.current = sortOrder; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortOrder]); const formatDueDate = (dueOn) => { if (!dueOn) return 'No due date'; @@ -120,7 +146,7 @@ const MilestoneCard = ({ milestone }) => { if (isExpanded) { setLoading(true); setError(null); - fetchIssues(milestone.number) + fetchIssues(milestone.number, sortOrder) .then((data) => { setIssues(data); setLoading(false); diff --git a/frontend/src/components/MilestoneCard.test.jsx b/frontend/src/components/MilestoneCard.test.jsx index e3bee92..bb428eb 100644 --- a/frontend/src/components/MilestoneCard.test.jsx +++ b/frontend/src/components/MilestoneCard.test.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -54,7 +55,7 @@ describe('MilestoneCard', () => { await user.click(expandButton); await waitFor(() => { - expect(api.fetchIssues).toHaveBeenCalledWith(6); + expect(api.fetchIssues).toHaveBeenCalledWith(6, []); }); await waitFor(() => { diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 3b00989..36dcc9b 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,3 +1,4 @@ +// ai-generated: Cursor import React from 'react'; import ReactDOM from 'react-dom/client'; import '@patternfly/react-core/dist/styles/base.css'; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index bc73bab..ce86494 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,3 +1,4 @@ +// ai-generated: Cursor const API_BASE = '/api/v1'; export const fetchMilestones = async () => { @@ -8,8 +9,13 @@ export const fetchMilestones = async () => { return response.json(); }; -export const fetchIssues = async (milestoneNumber) => { - const response = await fetch(`${API_BASE}/issues/${milestoneNumber}`); +export const fetchIssues = async (milestoneNumber, sortOrder = []) => { + let url = `${API_BASE}/issues/${milestoneNumber}`; + if (sortOrder && sortOrder.length > 0) { + const sortParam = sortOrder.join(','); + url += `?sort=${encodeURIComponent(sortParam)}`; + } + const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch issues: ${response.statusText}`); } diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index d734242..8ba18ff 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -1,3 +1,4 @@ +// ai-generated: Cursor import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fetchMilestones, diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js index 0f7a496..cd69f94 100644 --- a/frontend/src/test/setup.js +++ b/frontend/src/test/setup.js @@ -1,3 +1,4 @@ +// ai-generated: Cursor import { expect, afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/frontend/src/utils/dateUtils.js b/frontend/src/utils/dateUtils.js index f6e963d..df5009d 100644 --- a/frontend/src/utils/dateUtils.js +++ b/frontend/src/utils/dateUtils.js @@ -1,3 +1,4 @@ +// ai-generated: Cursor export const getDaysSince = (dateString) => { if (!dateString) return 0; const date = new Date(dateString); diff --git a/frontend/src/utils/dateUtils.test.js b/frontend/src/utils/dateUtils.test.js index 8098af0..5e54a57 100644 --- a/frontend/src/utils/dateUtils.test.js +++ b/frontend/src/utils/dateUtils.test.js @@ -1,3 +1,4 @@ +// ai-generated: Cursor import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getDaysSince, formatDate } from './dateUtils'; diff --git a/frontend/src/utils/labelsCache.js b/frontend/src/utils/labelsCache.js index ab2d75f..90c5602 100644 --- a/frontend/src/utils/labelsCache.js +++ b/frontend/src/utils/labelsCache.js @@ -1,3 +1,4 @@ +// ai-generated: Cursor // Shared labels cache - loaded once and shared across all components let labelsCache = { data: [], diff --git a/frontend/src/utils/milestonesCache.js b/frontend/src/utils/milestonesCache.js index 525f220..4a086fd 100644 --- a/frontend/src/utils/milestonesCache.js +++ b/frontend/src/utils/milestonesCache.js @@ -1,3 +1,4 @@ +// ai-generated: Cursor // Shared milestones cache - loaded once and shared across all components let milestonesCache = { data: [], diff --git a/frontend/vite.config.js b/frontend/vite.config.js index bdaf48f..c8fd439 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,3 +1,4 @@ +// ai-generated: Cursor import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; @@ -18,4 +19,3 @@ export default defineConfig({ setupFiles: './src/test/setup.js', }, }); - diff --git a/run-local.sh b/run-local.sh index 9744c17..dcec39f 100755 --- a/run-local.sh +++ b/run-local.sh @@ -74,9 +74,9 @@ frontend_pid=$! b_pgid=$(ps -o pgid= -p ${backend_pid}) f_pgid=$(ps -o pgid= -p ${frontend_pid}) -# Remove the leading space from "ps -o" output -backend_pgid=${b_pgid# } -frontend_pgid=${f_pgid# } +# Remove spaces from "ps -o" output +backend_pgid=${b_pgid//[[:space:]]/} +frontend_pgid=${f_pgid//[[:space:]]/} to_kill="-${backend_pgid}" if [[ ${frontend_pgid} -ne ${backend_pgid} ]]; then