diff --git a/discussions.py b/discussions.py index 025869c..cc90403 100644 --- a/discussions.py +++ b/discussions.py @@ -9,14 +9,82 @@ from github import Github +COMMENTS_PAGE_SIZE = 100 + +# Fetches further pages of a single discussion's comments once the first page +# returned by the search query is exhausted. +COMMENTS_QUERY = """ +query($id: ID!, $cursor: String, $pageSize: Int!) { + node(id: $id) { + ... on Discussion { + comments(first: $pageSize, after: $cursor) { + nodes { + createdAt + author { + login + __typename + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +""" + + +def _fetch_remaining_comments( + github_connection: Github, discussion: dict, max_comments: int +): + """Page a single discussion's comments until max_comments is satisfied. -def get_discussions(github_connection: Github, search_query: str): + The search query returns only the first page of each discussion's comments, + so without this the mentor counting branch silently stops at that page. + + Args: + github_connection (Github): An authenticated PyGithub connection. + discussion (dict): A discussion node, updated in place. + max_comments (int): Maximum number of comments to collect. + """ + comments = discussion.get("comments") + if not comments: + return + + nodes = comments.get("nodes", []) + page_info = comments.get("pageInfo") or {} + + while len(nodes) < max_comments and page_info.get("hasNextPage"): + variables = { + "id": discussion["id"], + "cursor": page_info.get("endCursor"), + "pageSize": min(COMMENTS_PAGE_SIZE, max_comments - len(nodes)), + } + _, response_json = github_connection.requester.graphql_query( + COMMENTS_QUERY, variables + ) + page = response_json["data"]["node"]["comments"] + nodes.extend(page.get("nodes", [])) + page_info = page.get("pageInfo") or {} + + comments["pageInfo"] = page_info + + +def get_discussions( + github_connection: Github, search_query: str, max_comments: int = 20 +): """Get a list of discussions in a GitHub repository that match the search query. Args: github_connection (Github): An authenticated PyGithub connection. GitHub Enterprise routing is handled by the connection's base URL. search_query (str): The search query to filter discussions by. + max_comments (int): Maximum number of comments to collect per discussion. + Values above the GraphQL page size trigger extra requests, so that + discussions match the issue and pull request branches, which keep + paginating until the limit is satisfied. Returns: list: A list of discussions in the repository that match the search query. @@ -28,6 +96,7 @@ def get_discussions(github_connection: Github, search_query: str): edges { node { ... on Discussion { + id title url createdAt @@ -35,10 +104,6 @@ def get_discussions(github_connection: Github, search_query: str): login __typename } - # Only the first 100 comments are fetched (no - # pagination). MAX_COMMENTS_EVAL defaults to 20, so - # this ceiling is not hit in practice; setting it above - # 100 would silently cap discussion mentor counts. comments(first: 100) { nodes { createdAt @@ -47,6 +112,10 @@ def get_discussions(github_connection: Github, search_query: str): __typename } } + pageInfo { + hasNextPage + endCursor + } } answerChosenAt closedAt @@ -81,7 +150,9 @@ def get_discussions(github_connection: Github, search_query: str): # Extract the discussions from the current page for edge in data["search"]["edges"]: - discussions.append(edge["node"]) + discussion = edge["node"] + _fetch_remaining_comments(github_connection, discussion, max_comments) + discussions.append(discussion) # Check if there are more pages page_info = data["search"]["pageInfo"] diff --git a/issue_metrics.py b/issue_metrics.py index 0d97845..d7ac802 100755 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -307,7 +307,7 @@ def main(): # pragma: no cover raise ValueError( "The search query for discussions cannot include labels to measure" ) - issues = get_discussions(github_connection, search_query) + issues = get_discussions(github_connection, search_query, max_comments_eval) if len(issues) <= 0: print("No discussions found") write_to_markdown( diff --git a/test_discussions.py b/test_discussions.py index 2248147..6a25ed1 100644 --- a/test_discussions.py +++ b/test_discussions.py @@ -133,3 +133,126 @@ def test_get_discussions_propagates_github_exception(self): with self.assertRaises(GithubException): get_discussions(github_connection, "repo:user/repo type:discussions query") + + def test_get_discussions_paginates_comments(self): + """Comments beyond the first page are fetched when max_comments allows.""" + + def comment(index): + return { + "createdAt": "2021-01-01T00:00:00Z", + "author": {"login": f"user{index}", "__typename": "User"}, + } + + first_page = [comment(index) for index in range(100)] + second_page = [comment(index) for index in range(100, 150)] + + discussion = { + "id": "D_kwDO", + "title": "Discussion 1", + "url": "https://github.com/user/repo/discussions/1", + "createdAt": "2021-01-01T00:00:00Z", + "author": {"login": "author", "__typename": "User"}, + "comments": { + "nodes": first_page, + "pageInfo": {"hasNextPage": True, "endCursor": "comment100"}, + }, + "answerChosenAt": None, + "closedAt": None, + } + + github_connection = MagicMock() + github_connection.requester.graphql_query.side_effect = [ + ({}, self._create_mock_response([discussion], has_next_page=False)), + ( + {}, + { + "data": { + "node": { + "comments": { + "nodes": second_page, + "pageInfo": { + "hasNextPage": False, + "endCursor": "comment150", + }, + } + } + } + }, + ), + ] + + discussions = get_discussions( + github_connection, "repo:user/repo type:discussions query", max_comments=150 + ) + + # All 150 comments are collected, not just the first page of 100 + self.assertEqual(len(discussions[0]["comments"]["nodes"]), 150) + + # A follow-up query was made for the remaining comments + self.assertEqual(github_connection.requester.graphql_query.call_count, 2) + comment_call_variables = ( + github_connection.requester.graphql_query.call_args_list[1].args[1] + ) + self.assertEqual(comment_call_variables["id"], "D_kwDO") + self.assertEqual(comment_call_variables["cursor"], "comment100") + self.assertEqual(comment_call_variables["pageSize"], 50) + + def test_get_discussions_stops_paginating_comments_at_max_comments(self): + """No extra request is made once max_comments comments are collected.""" + discussion = { + "id": "D_kwDO", + "title": "Discussion 1", + "url": "https://github.com/user/repo/discussions/1", + "createdAt": "2021-01-01T00:00:00Z", + "author": {"login": "author", "__typename": "User"}, + "comments": { + "nodes": [ + { + "createdAt": "2021-01-01T00:00:00Z", + "author": {"login": "user1", "__typename": "User"}, + } + ], + "pageInfo": {"hasNextPage": True, "endCursor": "comment1"}, + }, + "answerChosenAt": None, + "closedAt": None, + } + + github_connection = MagicMock() + github_connection.requester.graphql_query.return_value = ( + {}, + self._create_mock_response([discussion], has_next_page=False), + ) + + discussions = get_discussions( + github_connection, "repo:user/repo type:discussions query", max_comments=1 + ) + + self.assertEqual(len(discussions[0]["comments"]["nodes"]), 1) + self.assertEqual(github_connection.requester.graphql_query.call_count, 1) + + def test_get_discussions_with_no_comments(self): + """A discussion without a comments connection triggers no extra request.""" + discussion = { + "id": "D_kwDO", + "title": "Discussion 1", + "url": "https://github.com/user/repo/discussions/1", + "createdAt": "2021-01-01T00:00:00Z", + "author": {"login": "author", "__typename": "User"}, + "comments": None, + "answerChosenAt": None, + "closedAt": None, + } + + github_connection = MagicMock() + github_connection.requester.graphql_query.return_value = ( + {}, + self._create_mock_response([discussion], has_next_page=False), + ) + + discussions = get_discussions( + github_connection, "repo:user/repo type:discussions query", max_comments=150 + ) + + self.assertIsNone(discussions[0]["comments"]) + self.assertEqual(github_connection.requester.graphql_query.call_count, 1)