From 227a9c74ec6d74507cc5a11447f1bfffe00b7e31 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:14:29 -0700 Subject: [PATCH 1/2] fix: paginate discussion comments so MAX_COMMENTS_EVAL is honored get_discussions fetched comments with a single comments(first: 100) sub-list and never paginated it, while the outer search connection was paginated. With MAX_COMMENTS_EVAL set above 100, count_comments_per_user could never see more than those 100 nodes and silently stopped, diverging from the issue and pull request branches which keep paging until the limit is satisfied. The search query now also selects the discussion id and the comments pageInfo, and _fetch_remaining_comments pages the remaining comments for each discussion until hasNextPage is false or max_comments nodes are collected. issue_metrics passes the configured max_comments_eval through. Adds two regression tests covering the multi-page case and the case where the limit is already reached so no extra request is made. --- discussions.py | 83 +++++++++++++++++++++++++++++++++++--- issue_metrics.py | 2 +- test_discussions.py | 97 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 7 deletions(-) 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..d53e6e9 100644 --- a/test_discussions.py +++ b/test_discussions.py @@ -133,3 +133,100 @@ 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) From 485d293249215f594426a3ac9d1063d42cd9464c Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:31:20 -0700 Subject: [PATCH 2/2] test: cover discussion with no comments connection --- test_discussions.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test_discussions.py b/test_discussions.py index d53e6e9..6a25ed1 100644 --- a/test_discussions.py +++ b/test_discussions.py @@ -230,3 +230,29 @@ def test_get_discussions_stops_paginating_comments_at_max_comments(self): 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)