diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index d16239e..eeac254 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -1,4 +1,5 @@ from collections import defaultdict +from dataclasses import dataclass from datetime import date import time from typing import Annotated, AsyncGenerator @@ -13,12 +14,18 @@ api_router = APIRouter() -async def connection() -> AsyncGenerator[Repository, None]: +@dataclass +class GitHubCtx: + github: Github + repo: Repository + + +async def connection() -> AsyncGenerator[GitHubCtx, None]: """FastAPI Dependency to open & close Github connections""" - c = None + gh = None print(f"Opening GitHub connection for repo {context.github_repo}") try: - c = Github(auth=Auth.Token(context.github_token)) + gh = Github(auth=Auth.Token(context.github_token)) except Exception as e: print(f"Error opening GitHub service: {e}") raise HTTPException( @@ -26,9 +33,9 @@ async def connection() -> AsyncGenerator[Repository, None]: ) try: # yield the repository object - repo = c.get_repo(context.github_repo) + repo = gh.get_repo(context.github_repo) print(f"Repository: {repo.name}") - yield repo + yield GitHubCtx(github=gh, repo=repo) except HTTPException: raise except Exception as e: @@ -37,8 +44,8 @@ async def connection() -> AsyncGenerator[Repository, None]: status_code=400, detail=f"Can't interact with GitHub: {str(e)!r}" ) finally: - if c: - c.close() + if gh: + gh.close() @api_router.get("/project") @@ -51,12 +58,13 @@ async def get_project(): @api_router.get("/issues/{milestone_number}") async def get_issues( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], milestone_number: Annotated[int, Path(title="Milestone")], sort: Annotated[ str | None, Query(title="Sort", description="List of labels to sort by") ] = None, ): + repo = gitctx.repo if sort: sort_by = [s.strip() for s in sort.split(",")] else: @@ -73,6 +81,48 @@ async def get_issues( issues = repo.get_issues(milestone=milestone, state="open") for i in issues: labels = set([label.name.lower() for label in i.labels]) + if "pull_request" not in i.raw_data: + query = """query($owner: String!, $repo: String!, $issue: Int!) { + repository(owner: $owner, name: $repo, followRenames: true) { + issue(number: $issue) { + closedByPullRequestsReferences(first: 100, includeClosedPrs: true) { + nodes { + number + title + url + } + } + } + } + } + """ + owner, repo = context.github_repo.split("/", maxsplit=1) + try: + gql_response = gitctx.github.requester.graphql_query( + query=query, + variables={ + "owner": owner, + "repo": repo, + "issue": i.number, + }, + ) + data = gql_response[1]["data"] + issue_node = data["repository"]["issue"] + closed = issue_node["closedByPullRequestsReferences"]["nodes"] + if len(closed) > 0: + i.raw_data["closed_by"] = [ + { + "number": linked["number"], + "title": linked["title"], + "url": linked["url"], + } + for linked in closed + ] + except Exception as e: + print( + f"Error finding linked PRs for issue {i.number}: {e!r}", flush=True + ) + continue for label in sort_by: if label in labels: sorted_issues[label].append(i.raw_data) @@ -80,9 +130,8 @@ async def get_issues( else: sorted_issues["other"].append(i.raw_data) all_issues = [] - for label in sort_by: + for label in sort_by + ["other"]: all_issues.extend(sorted_issues[label]) - all_issues.extend(sorted_issues["other"]) print( f"[{issues.totalCount}({len(all_issues)}) issues: {time.time() - start:.3f} seconds]" ) @@ -91,11 +140,11 @@ async def get_issues( @api_router.get("/comments/{issue_number}") async def get_comments( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], ): start = time.time() - comments = repo.get_issue(issue_number).get_comments() + comments = gitctx.repo.get_issue(issue_number).get_comments() simplified = [i.raw_data for i in comments] print(f"[{len(simplified)} comments: {time.time() - start:.3f} seconds]") return simplified @@ -105,7 +154,8 @@ async def get_comments( @api_router.get("/milestones") -async def get_milestones(repo: Annotated[Repository, Depends(connection)]): +async def get_milestones(gitctx: Annotated[GitHubCtx, Depends(connection)]): + repo = gitctx.repo milestones = repo.get_milestones() response = [ { @@ -135,13 +185,13 @@ class CreateMilestone(BaseModel): @api_router.post("/milestones") async def create_milestone( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], milestone: Annotated[CreateMilestone, Body(title="Milestone")], ): start = time.time() print(f"Creating milestone: {milestone!r}", flush=True) try: - m = repo.create_milestone( + m = gitctx.repo.create_milestone( title=milestone.title, state="open", description=milestone.description, @@ -161,12 +211,12 @@ async def create_milestone( @api_router.delete("/milestones/{milestone_number}") async def delete_milestone( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], milestone_number: Annotated[int, Path(title="Milestone")], ): start = time.time() try: - milestone = repo.get_milestone(milestone_number) + milestone = gitctx.repo.get_milestone(milestone_number) except Exception as e: print(f"Milestone not found: {milestone_number!r}", flush=True) raise HTTPException( @@ -188,13 +238,13 @@ async def delete_milestone( @api_router.post("/issues/{issue_number}/milestone/{milestone_number}") async def add_milestone_to_issue( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], milestone_number: Annotated[int, Path(title="Milestone")], ): start = time.time() - issue = repo.get_issue(issue_number) - milestone = repo.get_milestone(milestone_number) + issue = gitctx.repo.get_issue(issue_number) + milestone = gitctx.repo.get_milestone(milestone_number) issue.edit(milestone=milestone) print( f"[{milestone_number} milestone added to issue {issue_number}: {time.time() - start:.3f} seconds]" @@ -204,12 +254,12 @@ async def add_milestone_to_issue( @api_router.delete("/issues/{issue_number}/milestone/{milestone_number}") async def remove_milestone_from_issue( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], milestone_number: Annotated[int, Path(title="Milestone")], ): start = time.time() - issue = repo.get_issue(issue_number) + issue = gitctx.repo.get_issue(issue_number) issue.edit(milestone=None) print( f"[{milestone_number} milestone removed from issue {issue_number}: {time.time() - start:.3f} seconds]" @@ -223,9 +273,9 @@ async def remove_milestone_from_issue( @api_router.get("/labels") -async def get_labels(repo: Annotated[Repository, Depends(connection)]): +async def get_labels(gitctx: Annotated[GitHubCtx, Depends(connection)]): start = time.time() - labels = [label.raw_data for label in repo.get_labels()] + labels = [label.raw_data for label in gitctx.repo.get_labels()] print(f"[{len(labels)} labels: {time.time() - start:.3f} seconds]") return labels @@ -238,12 +288,12 @@ class CreateLabel(BaseModel): @api_router.post("/labels") async def create_label( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], label: Annotated[CreateLabel, Body(title="Label")], ): start = time.time() try: - label = repo.create_label( + label = gitctx.repo.create_label( name=label.name, color=label.color, description=label.description ) print(f"[{label.name} label created: {time.time() - start:.3f} seconds]") @@ -256,11 +306,11 @@ async def create_label( @api_router.delete("/labels/{label_name}") async def delete_label( - repo: Annotated[Repository, Depends(connection)], label_name: str + gitctx: Annotated[GitHubCtx, Depends(connection)], label_name: str ): start = time.time() try: - label = repo.get_label(label_name) + label = gitctx.repo.get_label(label_name) except Exception as e: print(f"Label not found: {label_name!r}", flush=True) raise HTTPException( @@ -277,12 +327,12 @@ async def delete_label( @api_router.post("/issues/{issue_number}/labels/{label_name}") async def add_label_to_issue( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], label_name: Annotated[str, Path(title="Label")], ): start = time.time() - issue = repo.get_issue(issue_number) + issue = gitctx.repo.get_issue(issue_number) issue.add_to_labels(label_name) print( f"[{label_name} label added to issue {issue_number}: {time.time() - start:.3f} seconds]" @@ -292,12 +342,12 @@ async def add_label_to_issue( @api_router.delete("/issues/{issue_number}/labels/{label_name}") async def remove_label_from_issue( - repo: Annotated[Repository, Depends(connection)], + gitctx: Annotated[GitHubCtx, Depends(connection)], issue_number: Annotated[int, Path(title="Issue")], label_name: Annotated[str, Path(title="Label")], ): start = time.time() - issue = repo.get_issue(issue_number) + issue = gitctx.repo.get_issue(issue_number) issue.remove_from_labels(label_name) print( f"[{label_name} label removed from issue {issue_number}: {time.time() - start:.3f} seconds]" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 36845e4..0b7befb 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -26,6 +26,7 @@ get_labels, get_milestones, get_project, + GitHubCtx, remove_label_from_issue, remove_milestone_from_issue, ) @@ -53,10 +54,12 @@ async def test_connection_success(self): # Act async_gen = connection() - repo = await async_gen.__anext__() + gitctx = await async_gen.__anext__() # Assert - assert repo == mock_repo + assert isinstance(gitctx, GitHubCtx) + assert gitctx.repo == mock_repo + assert gitctx.github == mock_github mock_github.get_repo.assert_called_once_with("test/repo") # Clean up - trigger the finally block @@ -121,8 +124,9 @@ async def test_connection_closes_on_exit(self): # Act async_gen = connection() - repo = await async_gen.__anext__() - assert repo == mock_repo + gitctx = await async_gen.__anext__() + assert isinstance(gitctx, GitHubCtx) + assert gitctx.repo == mock_repo # Trigger cleanup by consuming the generator try: @@ -169,10 +173,12 @@ async def test_get_issues_with_milestone(self): mock_label2 = Mock() mock_label2.name = "feature" mock_issue1 = Mock() - mock_issue1.raw_data = {"id": 1, "title": "Issue 1"} + mock_issue1.raw_data = {"id": 1, "title": "Issue 1", "pull_request": {}} + mock_issue1.number = 1 mock_issue1.labels = [mock_label1] mock_issue2 = Mock() mock_issue2.raw_data = {"id": 2, "title": "Issue 2"} + mock_issue2.number = 2 mock_issue2.labels = [mock_label2] # Create an iterable mock with totalCount @@ -190,24 +196,128 @@ def __iter__(self): 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) + mock_requester = Mock() + mock_requester.graphql_query.return_value = ( + None, + { + "data": { + "repository": { + "issue": {"closedByPullRequestsReferences": {"nodes": []}} + } + } + }, + ) - # 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" + mock_github = Mock() + mock_github.requester = mock_requester + + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + + # Act + result = await get_issues(mock_gitctx, 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" + ) + # Issue 1 has pull_request, so no GraphQL call + # Issue 2 doesn't have pull_request, so GraphQL should be called + assert mock_requester.graphql_query.call_count == 1 + + @pytest.mark.asyncio + async def test_get_issues_with_linked_prs(self): + """Test getting issues with linked PRs from GraphQL.""" + # Arrange + mock_milestone = Mock() + mock_milestone.title = "Test Milestone" + mock_label = Mock() + mock_label.name = "bug" + mock_issue = Mock() + mock_issue.raw_data = {"id": 1, "title": "Issue 1"} + mock_issue.number = 1 + mock_issue.labels = [mock_label] + + # 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_issue]) + + mock_repo = Mock() + mock_repo.get_milestone.return_value = mock_milestone + mock_repo.get_issues.return_value = mock_issues_obj + + mock_requester = Mock() + mock_requester.graphql_query.return_value = ( + None, + { + "data": { + "repository": { + "issue": { + "closedByPullRequestsReferences": { + "nodes": [ + { + "number": 123, + "title": "Fix Issue 1", + "url": "https://github.com/test/repo/pull/123", + }, + { + "number": 456, + "title": "Another fix", + "url": "https://github.com/test/repo/pull/456", + }, + ] + } + } + } + } + }, ) + mock_github = Mock() + mock_github.requester = mock_requester + + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + + # Act + result = await get_issues(mock_gitctx, milestone_number=1) + + # Assert + assert len(result) == 1 + assert result[0]["id"] == 1 + assert "closed_by" in result[0] + assert len(result[0]["closed_by"]) == 2 + assert result[0]["closed_by"][0]["number"] == 123 + assert result[0]["closed_by"][0]["title"] == "Fix Issue 1" + assert ( + result[0]["closed_by"][0]["url"] + == "https://github.com/test/repo/pull/123" + ) + assert result[0]["closed_by"][1]["number"] == 456 + mock_requester.graphql_query.assert_called_once() + @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.number = 1 mock_issue1.labels = [] # Create an iterable mock with totalCount @@ -224,14 +334,36 @@ def __iter__(self): mock_repo = Mock() mock_repo.get_issues.return_value = mock_issues_obj - # Act - result = await get_issues(mock_repo, milestone_number=0) + mock_requester = Mock() + mock_requester.graphql_query.return_value = ( + None, + { + "data": { + "repository": { + "issue": {"closedByPullRequestsReferences": {"nodes": []}} + } + } + }, + ) - # 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() + mock_github = Mock() + mock_github.requester = mock_requester + + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + + # Act + result = await get_issues(mock_gitctx, 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() + # GraphQL should be called for issue without pull_request + assert mock_requester.graphql_query.call_count == 1 class TestGetComments: @@ -254,8 +386,11 @@ async def test_get_comments(self): mock_repo = Mock() mock_repo.get_issue.return_value = mock_issue + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await get_comments(mock_repo, issue_number=123) + result = await get_comments(mock_gitctx, issue_number=123) # Assert assert len(result) == 2 @@ -292,8 +427,11 @@ async def test_get_milestones(self): mock_repo = Mock() mock_repo.get_milestones.return_value = mock_milestones + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await get_milestones(mock_repo) + result = await get_milestones(mock_gitctx) # Assert assert len(result) == 3 # 2 milestones + 1 "none" milestone @@ -325,8 +463,11 @@ async def test_create_milestone_success(self): due_on=date(2024, 12, 31), ) + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await create_milestone(mock_repo, milestone_data) + result = await create_milestone(mock_gitctx, milestone_data) # Assert assert result == mock_milestone.raw_data @@ -351,8 +492,11 @@ async def test_create_milestone_with_defaults(self): milestone_data = CreateMilestone(title="New Milestone") + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await create_milestone(mock_repo, milestone_data) + result = await create_milestone(mock_gitctx, milestone_data) # Assert assert result == mock_milestone.raw_data @@ -374,8 +518,11 @@ async def test_delete_milestone_success(self): mock_repo = Mock() mock_repo.get_milestone.return_value = mock_milestone + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await delete_milestone(mock_repo, milestone_number=1) + result = await delete_milestone(mock_gitctx, milestone_number=1) # Assert assert result == {"message": "1 milestone deleted"} @@ -389,9 +536,12 @@ async def test_delete_milestone_not_found(self): mock_repo = Mock() mock_repo.get_milestone.side_effect = Exception("Milestone not found") + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act & Assert with pytest.raises(Exception): - await delete_milestone(mock_repo, milestone_number=999) + await delete_milestone(mock_gitctx, milestone_number=999) class TestAddMilestoneToIssue: @@ -410,9 +560,12 @@ async def test_add_milestone_to_issue(self): mock_repo.get_issue.return_value = mock_issue mock_repo.get_milestone.return_value = mock_milestone + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act result = await add_milestone_to_issue( - mock_repo, issue_number=123, milestone_number=1 + mock_gitctx, issue_number=123, milestone_number=1 ) # Assert @@ -435,9 +588,12 @@ async def test_remove_milestone_from_issue(self): mock_repo = Mock() mock_repo.get_issue.return_value = mock_issue + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act result = await remove_milestone_from_issue( - mock_repo, issue_number=123, milestone_number=1 + mock_gitctx, issue_number=123, milestone_number=1 ) # Assert @@ -464,8 +620,11 @@ async def test_get_labels(self): mock_repo = Mock() mock_repo.get_labels.return_value = mock_labels + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await get_labels(mock_repo) + result = await get_labels(mock_gitctx) # Assert assert len(result) == 2 @@ -492,8 +651,11 @@ async def test_create_label_success(self): name="new-label", color="green", description="Test label" ) + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await create_label(mock_repo, label_data) + result = await create_label(mock_gitctx, label_data) # Assert assert result == mock_label.raw_data @@ -516,8 +678,11 @@ async def test_create_label_with_defaults(self): label_data = CreateLabel(name="new-label", color="green") + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await create_label(mock_repo, label_data) + result = await create_label(mock_gitctx, label_data) # Assert assert result == mock_label.raw_data @@ -539,8 +704,11 @@ async def test_delete_label_success(self): mock_repo = Mock() mock_repo.get_label.return_value = mock_label + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await delete_label(mock_repo, label_name="bug") + result = await delete_label(mock_gitctx, label_name="bug") # Assert assert result == {"message": "bug label deleted"} @@ -554,9 +722,12 @@ async def test_delete_label_not_found(self): mock_repo = Mock() mock_repo.get_label.side_effect = Exception("Label not found") + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act & Assert with pytest.raises(Exception): - await delete_label(mock_repo, label_name="nonexistent") + await delete_label(mock_gitctx, label_name="nonexistent") class TestAddLabelToIssue: @@ -572,8 +743,13 @@ async def test_add_label_to_issue(self): mock_repo = Mock() mock_repo.get_issue.return_value = mock_issue + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act - result = await add_label_to_issue(mock_repo, issue_number=123, label_name="bug") + result = await add_label_to_issue( + mock_gitctx, issue_number=123, label_name="bug" + ) # Assert assert result == {"message": "bug label added to issue 123"} @@ -594,9 +770,12 @@ async def test_remove_label_from_issue(self): mock_repo = Mock() mock_repo.get_issue.return_value = mock_issue + mock_github = Mock() + mock_gitctx = GitHubCtx(github=mock_github, repo=mock_repo) + # Act result = await remove_label_from_issue( - mock_repo, issue_number=123, label_name="bug" + mock_gitctx, issue_number=123, label_name="bug" ) # Assert diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index 762440e..f30af23 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -465,6 +465,30 @@ const IssueCard = ({ issue, onMilestoneChange }) => { /> )} + {issue.closed_by && issue.closed_by.length > 0 && ( + + (closed by{' '} + {issue.closed_by.map((pr, index) => ( + + {index > 0 && ', '} + + + #{pr.number} + + + + ))} + ) + + )} {' - '} {issue.title} {issue.type && (