diff --git a/backend/src/github_pm/sdlc_metrics.py b/backend/src/github_pm/sdlc_metrics.py index 1cf1568..aa3f32f 100644 --- a/backend/src/github_pm/sdlc_metrics.py +++ b/backend/src/github_pm/sdlc_metrics.py @@ -427,6 +427,7 @@ def graphql_search_timeline_nodes( title url createdAt + state } ... on Issue { number diff --git a/backend/src/github_pm/status_report_service.py b/backend/src/github_pm/status_report_service.py index 92b7814..8feeb01 100644 --- a/backend/src/github_pm/status_report_service.py +++ b/backend/src/github_pm/status_report_service.py @@ -77,6 +77,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: for n in opened_pr_nodes if n.get("__typename") == "PullRequest" and _created_calendar_in_window(n, start_date, end_date) + and n.get("state") != "CLOSED" ] opened_pr_filtered.sort(key=lambda n: int(n["number"])) diff --git a/backend/tests/test_status_report_api.py b/backend/tests/test_status_report_api.py index 646c2a3..53a3a1a 100644 --- a/backend/tests/test_status_report_api.py +++ b/backend/tests/test_status_report_api.py @@ -45,6 +45,7 @@ def mock_connector_graphql(): "title": "Opened PR", "url": "https://github.com/test/repo/pull/11", "createdAt": "2025-04-05T12:00:00Z", + "state": "OPEN", } ] @@ -218,3 +219,93 @@ async def override_conn(): "repo:test/repo is:issue created:2025-04-04..2025-04-10" in q for q in gql_qs ) + + def test_opened_prs_exclude_closed_without_merge(self, client): + """PRs with GitHub state CLOSED (not merged) must not appear in opened_pull_requests.""" + gitctx = MagicMock() + gitctx.owner = "test" + gitctx.repo = "repo" + + merged_nodes = [] + + opened_pr_nodes = [ + { + "__typename": "PullRequest", + "number": 20, + "title": "Still open", + "url": "https://github.com/test/repo/pull/20", + "createdAt": "2025-04-05T12:00:00Z", + "state": "OPEN", + }, + { + "__typename": "PullRequest", + "number": 21, + "title": "Merged same window", + "url": "https://github.com/test/repo/pull/21", + "createdAt": "2025-04-05T12:00:00Z", + "state": "MERGED", + }, + { + "__typename": "PullRequest", + "number": 22, + "title": "Closed without merge", + "url": "https://github.com/test/repo/pull/22", + "createdAt": "2025-04-05T12:00:00Z", + "state": "CLOSED", + }, + ] + + issue_nodes = [] + + def post_side(path: str, data=None, **kwargs): + body = data + if path != "/graphql" or not isinstance(body, dict): + raise AssertionError(f"unexpected post {path=!r} body={body!r}") + q = (body.get("variables") or {}).get("q") or "" + if "is:merged" in q and "merged:" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": merged_nodes, + } + } + } + if "is:issue" in q and "created:" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": issue_nodes, + } + } + } + if "is:pr" in q and "created:" in q: + return { + "data": { + "search": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": opened_pr_nodes, + } + } + } + raise AssertionError(f"unexpected graphql q={q!r}") + + gitctx.post.side_effect = post_side + + async def override_conn(): + yield gitctx + + app.dependency_overrides[connection] = override_conn + try: + r = client.get( + "/api/v1/project-status", + params={"start_date": "2025-04-04", "end_date": "2025-04-10"}, + ) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 200 + opened = r.json()["opened_pull_requests"] + numbers = {p["number"] for p in opened} + assert numbers == {20, 21}