Repository: github/issue-metrics (python)

Issue:
Discussions are limited to 100 results
Seems like some folks might want this to be increased to measure metrics on more than 100 discussions. We could either implement a more reasonable limit or figure out how to handle paging up to the limit of github search results.

Pull request:
feat(discussions): refactor get_discussions function for pagination support
# Pull Request

This pull request includes enhancements to the `get_discussions` function in `discussions.py` to support pagination and improvements to the corresponding tests in `test_discussions.py`. The most important changes include adding pagination to the GraphQL query, handling pagination in the function, and updating the tests to cover these scenarios.

Closes #44

## Proposed Changes

- Add pagination support to GitHub Discussions Query.

Respective Issue: #44

## Readiness Checklist

### Author/Contributor

- [x] If documentation is needed for this change, has that been included in this pull request
- [x] run `make lint` and fix any issues that you have introduced
- [x] run `make test` and ensure you have test coverage for the lines you are introducing
- [x] If publishing new data to the public (scorecards, security scan results, code quality results, live dashboards, etc.), please request review from `@jeffrey-luszcz`

### Reviewer

- [x] Label as either `fix`, `documentation`, `enhancement`, `infrastructure`, `maintenance`, or `breaking`


Patch:
diff --git a/discussions.py b/discussions.py
index 9b16090..b3ab1d0 100644
--- a/discussions.py
+++ b/discussions.py
@@ -16,15 +16,15 @@ def get_discussions(token: str, search_query: str, ghe: str):
     Args:
         token (str): A personal access token for GitHub.
         search_query (str): The search query to filter discussions by.
+        ghe (str): GitHub Enterprise URL if applicable, or None for github.com.
 
     Returns:
         list: A list of discussions in the repository that match the search query.
-
     """
-    # Construct the GraphQL query
+    # Construct the GraphQL query with pagination
     query = """
-    query($query: String!) {
-        search(query: $query, type: DISCUSSION, first: 100) {
+    query($query: String!, $cursor: String) {
+        search(query: $query, type: DISCUSSION, first: 100, after: $cursor) {
             edges {
                 node {
                     ... on Discussion {
@@ -41,34 +41,57 @@ def get_discussions(token: str, search_query: str, ghe: str):
                     }
                 }
             }
+            pageInfo {
+                hasNextPage
+                endCursor
+            }
         }
     }
     """
 
     # Remove the type:discussions filter from the search query
     search_query = search_query.replace("type:discussions ", "")
-    # Set the variables for the GraphQL query
-    variables = {"query": search_query}
 
     # Send the GraphQL request
     api_endpoint = f"{ghe}/api" if ghe else "https://api.github.com"
     headers = {"Authorization": f"Bearer {token}"}
-    response = requests.post(
-        f"{api_endpoint}/graphql",
-        json={"query": query, "variables": variables},
-        headers=headers,
-        timeout=60,
-    )
 
-    # Check for errors in the GraphQL response
-    if response.status_code != 200 or "errors" in response.json():
-        raise ValueError("GraphQL query failed")
+    discussions = []
+    cursor = None
 
-    data = response.json()["data"]
+    while True:
+        # Set the variables for the GraphQL query
+        variables = {"query": search_query, "cursor": cursor}
 
-    # Extract the discussions from the GraphQL response
-    discussions = []
-    for edge in data["search"]["edges"]:
-        discussions.append(edge["node"])
+        # Send the GraphQL request
+        response = requests.post(
+            f"{api_endpoint}/graphql",
+            json={"query": query, "variables": variables},
+            headers=headers,
+            timeout=60,
+        )
+
+        # Check for errors in the GraphQL response
+        if response.status_code != 200:
+            raise ValueError(
+                f"GraphQL query failed with status code {response.status_code}"
+            )
+
+        response_json = response.json()
+        if "errors" in response_json:
+            raise ValueError(f"GraphQL query failed: {response_json['errors']}")
+
+        data = response_json["data"]
+
+        # Extract the discussions from the current page
+        for edge in data["search"]["edges"]:
+            discussions.append(edge["node"])
+
+        # Check if there are more pages
+        page_info = data["search"]["pageInfo"]
+        if not page_info["hasNextPage"]:
+            break
+
+        cursor = page_info["endCursor"]
 
     return discussions


Test patch:
diff --git a/test_discussions.py b/test_discussions.py
index 22b247f..8145aad 100644
--- a/test_discussions.py
+++ b/test_discussions.py
@@ -14,54 +14,49 @@ from discussions import get_discussions
 class TestGetDiscussions(unittest.TestCase):
     """A class to test the get_discussions function in the discussions module."""
 
-    @patch("requests.post")
-    def test_get_discussions(self, mock_post):
-        """Test the get_discussions function with a successful GraphQL response.
-
-        This test mocks a successful GraphQL response and checks that the
-        function returns the expected discussions.
-
-        """
-        # Mock the GraphQL response
-        mock_response = {
+    def _create_mock_response(
+        self, discussions, has_next_page=False, end_cursor="cursor123"
+    ):
+        """Helper method to create a mock GraphQL response."""
+        return {
             "data": {
                 "search": {
-                    "edges": [
-                        {
-                            "node": {
-                                "title": "Discussion 1",
-                                "url": "https://github.com/user/repo/discussions/1",
-                                "createdAt": "2021-01-01T00:00:00Z",
-                                "comments": {
-                                    "nodes": [{"createdAt": "2021-01-01T00:01:00Z"}]
-                                },
-                                "answerChosenAt": None,
-                                "closedAt": None,
-                            }
-                        },
-                        {
-                            "node": {
-                                "title": "Discussion 2",
-                                "url": "https://github.com/user/repo/discussions/2",
-                                "createdAt": "2021-01-02T00:00:00Z",
-                                "comments": {
-                                    "nodes": [{"createdAt": "2021-01-02T00:01:00Z"}]
-                                },
-                                "answerChosenAt": "2021-01-03T00:00:00Z",
-                                "closedAt": "2021-01-04T00:00:00Z",
-                            }
-                        },
-                    ]
+                    "edges": [{"node": discussion} for discussion in discussions],
+                    "pageInfo": {"hasNextPage": has_next_page, "endCursor": end_cursor},
                 }
             }
         }
+
+    @patch("requests.post")
+    def test_get_discussions_single_page(self, mock_post):
+        """Test the get_discussions function with a single page of results."""
+        # Mock data for two discussions
+        mock_discussions = [
+            {
+                "title": "Discussion 1",
+                "url": "https://github.com/user/repo/discussions/1",
+                "createdAt": "2021-01-01T00:00:00Z",
+                "comments": {"nodes": [{"createdAt": "2021-01-01T00:01:00Z"}]},
+                "answerChosenAt": None,
+                "closedAt": None,
+            },
+            {
+                "title": "Discussion 2",
+                "url": "https://github.com/user/repo/discussions/2",
+                "createdAt": "2021-01-02T00:00:00Z",
+                "comments": {"nodes": [{"createdAt": "2021-01-02T00:01:00Z"}]},
+                "answerChosenAt": "2021-01-03T00:00:00Z",
+                "closedAt": "2021-01-04T00:00:00Z",
+            },
+        ]
+
         mock_post.return_value.status_code = 200
-        mock_post.return_value.json.return_value = mock_response
-        mock_ghe = ""
+        mock_post.return_value.json.return_value = self._create_mock_response(
+            mock_discussions, has_next_page=False
+        )
 
-        # Call the function with mock arguments
         discussions = get_discussions(
-            "token", "repo:user/repo type:discussions query", mock_ghe
+            "token", "repo:user/repo type:discussions query", ""
         )
 
         # Check that the function returns the expected discussions
@@ -69,17 +64,77 @@ class TestGetDiscussions(unittest.TestCase):
         self.assertEqual(discussions[0]["title"], "Discussion 1")
         self.assertEqual(discussions[1]["title"], "Discussion 2")
 
+        # Verify only one API call was made
+        self.assertEqual(mock_post.call_count, 1)
+
     @patch("requests.post")
-    def test_get_discussions_error(self, mock_post):
-        """Test the get_discussions function with a failed GraphQL response.
+    def test_get_discussions_multiple_pages(self, mock_post):
+        """Test the get_discussions function with multiple pages of results."""
+        # Mock data for pagination
+        page1_discussions = [
+            {
+                "title": "Discussion 1",
+                "url": "https://github.com/user/repo/discussions/1",
+                "createdAt": "2021-01-01T00:00:00Z",
+                "comments": {"nodes": [{"createdAt": "2021-01-01T00:01:00Z"}]},
+                "answerChosenAt": None,
+                "closedAt": None,
+            }
+        ]
+
+        page2_discussions = [
+            {
+                "title": "Discussion 2",
+                "url": "https://github.com/user/repo/discussions/2",
+                "createdAt": "2021-01-02T00:00:00Z",
+                "comments": {"nodes": [{"createdAt": "2021-01-02T00:01:00Z"}]},
+                "answerChosenAt": None,
+                "closedAt": None,
+            }
+        ]
 
-        This test mocks a failed GraphQL response and checks that the function raises a ValueError.
+        # Configure mock to return different responses for each call
+        mock_post.return_value.status_code = 200
+        mock_post.return_value.json.side_effect = [
+            self._create_mock_response(
+                page1_discussions, has_next_page=True, end_cursor="cursor123"
+            ),
+            self._create_mock_response(page2_discussions, has_next_page=False),
+        ]
+
+        discussions = get_discussions(
+            "token", "repo:user/repo type:discussions query", ""
+        )
 
-        """
-        # Mock a failed GraphQL response
+        # Check that all discussions were returned
+        self.assertEqual(len(discussions), 2)
+        self.assertEqual(discussions[0]["title"], "Discussion 1")
+        self.assertEqual(discussions[1]["title"], "Discussion 2")
+
+        # Verify that two API calls were made
+        self.assertEqual(mock_post.call_count, 2)
+
+    @patch("requests.post")
+    def test_get_discussions_error_status_code(self, mock_post):
+        """Test the get_discussions function with a failed HTTP response."""
         mock_post.return_value.status_code = 500
-        mock_ghe = ""
 
-        # Call the function with mock arguments and check that it raises an error
-        with self.assertRaises(ValueError):
-            get_discussions("token", "repo:user/repo type:discussions query", mock_ghe)
+        with self.assertRaises(ValueError) as context:
+            get_discussions("token", "repo:user/repo type:discussions query", "")
+
+        self.assertIn(
+            "GraphQL query failed with status code 500", str(context.exception)
+        )
+
+    @patch("requests.post")
+    def test_get_discussions_graphql_error(self, mock_post):
+        """Test the get_discussions function with GraphQL errors in response."""
+        mock_post.return_value.status_code = 200
+        mock_post.return_value.json.return_value = {
+            "errors": [{"message": "GraphQL Error"}]
+        }
+
+        with self.assertRaises(ValueError) as context:
+            get_discussions("token", "repo:user/repo type:discussions query", "")
+
+        self.assertIn("GraphQL query failed:", str(context.exception))