pull-request-fixer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pull_request_fixer/__init__.py +13 -0
- pull_request_fixer/_version.py +34 -0
- pull_request_fixer/cli.py +1059 -0
- pull_request_fixer/exceptions.py +66 -0
- pull_request_fixer/github_client.py +330 -0
- pull_request_fixer/graphql_queries.py +310 -0
- pull_request_fixer/models.py +70 -0
- pull_request_fixer/pr_fixer.py +37 -0
- pull_request_fixer/pr_scanner.py +423 -0
- pull_request_fixer/progress_tracker.py +483 -0
- pull_request_fixer/py.typed +0 -0
- pull_request_fixer-0.1.0.dist-info/METADATA +451 -0
- pull_request_fixer-0.1.0.dist-info/RECORD +16 -0
- pull_request_fixer-0.1.0.dist-info/WHEEL +4 -0
- pull_request_fixer-0.1.0.dist-info/entry_points.txt +2 -0
- pull_request_fixer-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
|
3
|
+
|
|
4
|
+
"""Custom exceptions for pr-title-fixer."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PRTitleFixerError(Exception):
|
|
10
|
+
"""Base exception for pr-title-fixer."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileAccessError(PRTitleFixerError):
|
|
16
|
+
"""Error accessing or reading a file."""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitHubAPIError(PRTitleFixerError):
|
|
22
|
+
"""Error communicating with GitHub API."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticationError(GitHubAPIError):
|
|
28
|
+
"""GitHub authentication failed."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RateLimitError(GitHubAPIError):
|
|
34
|
+
"""GitHub API rate limit exceeded."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str = "GitHub API rate limit exceeded",
|
|
39
|
+
reset_time: int | None = None,
|
|
40
|
+
):
|
|
41
|
+
"""Initialize rate limit error.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
message: Error message
|
|
45
|
+
reset_time: Unix timestamp when rate limit resets
|
|
46
|
+
"""
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.reset_time = reset_time
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NetworkError(PRTitleFixerError):
|
|
52
|
+
"""Network communication error."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class GitOperationError(PRTitleFixerError):
|
|
58
|
+
"""Error performing git operation."""
|
|
59
|
+
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConfigurationError(PRTitleFixerError):
|
|
64
|
+
"""Configuration error."""
|
|
65
|
+
|
|
66
|
+
pass
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
|
3
|
+
|
|
4
|
+
"""GitHub API client for repository and PR operations."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
13
|
+
|
|
14
|
+
from .exceptions import FileAccessError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GitHubClient:
|
|
18
|
+
"""Client for GitHub API operations."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, token: str, base_url: str = "https://api.github.com"):
|
|
21
|
+
"""Initialize GitHub client.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
token: GitHub personal access token
|
|
25
|
+
base_url: GitHub API base URL
|
|
26
|
+
"""
|
|
27
|
+
self.token = token
|
|
28
|
+
self.base_url = base_url
|
|
29
|
+
self.headers = {
|
|
30
|
+
"Authorization": f"Bearer {token}",
|
|
31
|
+
"Accept": "application/vnd.github+json",
|
|
32
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self) -> GitHubClient:
|
|
36
|
+
"""Async context manager entry."""
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
|
|
40
|
+
"""Async context manager exit."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
async def validate_token(self) -> tuple[bool, str, list[str]]:
|
|
44
|
+
"""Validate GitHub token and check permissions.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tuple of (is_valid, username, scopes)
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
FileAccessError: If token validation fails
|
|
51
|
+
"""
|
|
52
|
+
query = """
|
|
53
|
+
query {
|
|
54
|
+
viewer {
|
|
55
|
+
login
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
result = await self._graphql_request(query)
|
|
62
|
+
viewer = result.get("viewer", {})
|
|
63
|
+
username = viewer.get("login", "")
|
|
64
|
+
|
|
65
|
+
# Get token scopes from REST API
|
|
66
|
+
# Note: GitHub Actions tokens may not have access to /user endpoint
|
|
67
|
+
scopes: list[str] = []
|
|
68
|
+
try:
|
|
69
|
+
url = f"{self.base_url}/user"
|
|
70
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
71
|
+
response = await client.get(url, headers=self.headers)
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
|
|
74
|
+
# Scopes are in the X-OAuth-Scopes header
|
|
75
|
+
scopes_header = response.headers.get("X-OAuth-Scopes", "")
|
|
76
|
+
scopes = [
|
|
77
|
+
s.strip() for s in scopes_header.split(",") if s.strip()
|
|
78
|
+
]
|
|
79
|
+
except httpx.HTTPStatusError as scope_error:
|
|
80
|
+
# GitHub Actions tokens may not have user access (403)
|
|
81
|
+
# This is expected and we can continue without scope info
|
|
82
|
+
if scope_error.response.status_code != 403:
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
return True, username, scopes
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
msg = f"Token validation failed: {e}"
|
|
89
|
+
raise FileAccessError(msg) from e
|
|
90
|
+
|
|
91
|
+
@retry( # type: ignore[misc]
|
|
92
|
+
stop=stop_after_attempt(3),
|
|
93
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
94
|
+
)
|
|
95
|
+
async def _request(
|
|
96
|
+
self,
|
|
97
|
+
method: str,
|
|
98
|
+
endpoint: str,
|
|
99
|
+
**kwargs: Any,
|
|
100
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
101
|
+
"""Make an API request with retry logic.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
method: HTTP method
|
|
105
|
+
endpoint: API endpoint path
|
|
106
|
+
**kwargs: Additional arguments for httpx request
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Response JSON data
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
FileAccessError: If request fails
|
|
113
|
+
"""
|
|
114
|
+
url = f"{self.base_url}{endpoint}"
|
|
115
|
+
|
|
116
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
117
|
+
try:
|
|
118
|
+
response = await client.request(
|
|
119
|
+
method,
|
|
120
|
+
url,
|
|
121
|
+
headers=self.headers,
|
|
122
|
+
**kwargs,
|
|
123
|
+
)
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
result: dict[str, Any] | list[dict[str, Any]] = response.json()
|
|
126
|
+
return result
|
|
127
|
+
except httpx.HTTPStatusError as e:
|
|
128
|
+
msg = f"GitHub API error: {e.response.status_code} - {e.response.text}"
|
|
129
|
+
raise FileAccessError(msg) from e
|
|
130
|
+
except httpx.RequestError as e:
|
|
131
|
+
msg = f"Request failed: {e}"
|
|
132
|
+
raise FileAccessError(msg) from e
|
|
133
|
+
|
|
134
|
+
@retry( # type: ignore[misc]
|
|
135
|
+
stop=stop_after_attempt(3),
|
|
136
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
137
|
+
)
|
|
138
|
+
async def _graphql_request(
|
|
139
|
+
self,
|
|
140
|
+
query: str,
|
|
141
|
+
variables: dict[str, Any] | None = None,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Make a GraphQL API request with retry logic.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
query: GraphQL query string
|
|
147
|
+
variables: Query variables
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Response data
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
FileAccessError: If request fails
|
|
154
|
+
"""
|
|
155
|
+
url = "https://api.github.com/graphql"
|
|
156
|
+
payload: dict[str, Any] = {"query": query}
|
|
157
|
+
if variables:
|
|
158
|
+
payload["variables"] = variables
|
|
159
|
+
|
|
160
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
161
|
+
try:
|
|
162
|
+
response = await client.post(
|
|
163
|
+
url,
|
|
164
|
+
headers=self.headers,
|
|
165
|
+
json=payload,
|
|
166
|
+
)
|
|
167
|
+
response.raise_for_status()
|
|
168
|
+
json_response: Any = response.json()
|
|
169
|
+
result: dict[str, Any] = (
|
|
170
|
+
json_response if isinstance(json_response, dict) else {}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Check for GraphQL errors
|
|
174
|
+
if "errors" in result:
|
|
175
|
+
errors = result["errors"]
|
|
176
|
+
msg = f"GraphQL errors: {errors}"
|
|
177
|
+
raise FileAccessError(msg)
|
|
178
|
+
|
|
179
|
+
data: dict[str, Any] = result.get("data", {})
|
|
180
|
+
return data
|
|
181
|
+
except httpx.HTTPStatusError as e:
|
|
182
|
+
msg = f"GitHub API error: {e.response.status_code} - {e.response.text}"
|
|
183
|
+
raise FileAccessError(msg) from e
|
|
184
|
+
except httpx.RequestError as e:
|
|
185
|
+
msg = f"Request failed: {e}"
|
|
186
|
+
raise FileAccessError(msg) from e
|
|
187
|
+
|
|
188
|
+
async def graphql(
|
|
189
|
+
self,
|
|
190
|
+
query: str,
|
|
191
|
+
variables: dict[str, Any] | None = None,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""Execute a GraphQL query.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
query: GraphQL query string
|
|
197
|
+
variables: Query variables
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Response data
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
FileAccessError: If request fails
|
|
204
|
+
"""
|
|
205
|
+
result: dict[str, Any] = await self._graphql_request(query, variables)
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
async def get_pr_files(
|
|
209
|
+
self, owner: str, repo: str, pr_number: int
|
|
210
|
+
) -> list[dict[str, Any]]:
|
|
211
|
+
"""Get files changed in a pull request.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
owner: Repository owner
|
|
215
|
+
repo: Repository name
|
|
216
|
+
pr_number: Pull request number
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of changed files
|
|
220
|
+
"""
|
|
221
|
+
files = await self._request(
|
|
222
|
+
"GET",
|
|
223
|
+
f"/repos/{owner}/{repo}/pulls/{pr_number}/files",
|
|
224
|
+
)
|
|
225
|
+
return files if isinstance(files, list) else []
|
|
226
|
+
|
|
227
|
+
async def get_file_content(
|
|
228
|
+
self, owner: str, repo: str, path: str, ref: str
|
|
229
|
+
) -> str:
|
|
230
|
+
"""Get file content from a repository.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
owner: Repository owner
|
|
234
|
+
repo: Repository name
|
|
235
|
+
path: File path
|
|
236
|
+
ref: Git ref (branch/commit SHA)
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Decoded file content
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
FileAccessError: If file cannot be retrieved
|
|
243
|
+
"""
|
|
244
|
+
result = await self._request(
|
|
245
|
+
"GET",
|
|
246
|
+
f"/repos/{owner}/{repo}/contents/{path}",
|
|
247
|
+
params={"ref": ref},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if not isinstance(result, dict):
|
|
251
|
+
msg = f"Unexpected response type for file content: {type(result)}"
|
|
252
|
+
raise FileAccessError(msg)
|
|
253
|
+
|
|
254
|
+
content_b64 = result.get("content", "")
|
|
255
|
+
if not content_b64:
|
|
256
|
+
return ""
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
return base64.b64decode(content_b64).decode("utf-8")
|
|
260
|
+
except (ValueError, UnicodeDecodeError) as e:
|
|
261
|
+
msg = f"Failed to decode file content: {e}"
|
|
262
|
+
raise FileAccessError(msg) from e
|
|
263
|
+
|
|
264
|
+
async def update_file(
|
|
265
|
+
self,
|
|
266
|
+
owner: str,
|
|
267
|
+
repo: str,
|
|
268
|
+
path: str,
|
|
269
|
+
content: str,
|
|
270
|
+
message: str,
|
|
271
|
+
branch: str,
|
|
272
|
+
sha: str,
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
"""Update a file in a repository.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
owner: Repository owner
|
|
278
|
+
repo: Repository name
|
|
279
|
+
path: File path
|
|
280
|
+
content: New file content
|
|
281
|
+
message: Commit message
|
|
282
|
+
branch: Branch name
|
|
283
|
+
sha: Current file SHA (for conflict detection)
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Commit data
|
|
287
|
+
"""
|
|
288
|
+
content_b64 = base64.b64encode(content.encode("utf-8")).decode("utf-8")
|
|
289
|
+
|
|
290
|
+
result = await self._request(
|
|
291
|
+
"PUT",
|
|
292
|
+
f"/repos/{owner}/{repo}/contents/{path}",
|
|
293
|
+
json={
|
|
294
|
+
"message": message,
|
|
295
|
+
"content": content_b64,
|
|
296
|
+
"branch": branch,
|
|
297
|
+
"sha": sha,
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
return result if isinstance(result, dict) else {}
|
|
301
|
+
|
|
302
|
+
async def get_rate_limit(self) -> dict[str, Any]:
|
|
303
|
+
"""Get current API rate limit status.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Rate limit information
|
|
307
|
+
"""
|
|
308
|
+
result = await self._request("GET", "/rate_limit")
|
|
309
|
+
return result if isinstance(result, dict) else {}
|
|
310
|
+
|
|
311
|
+
async def create_comment(
|
|
312
|
+
self, owner: str, repo: str, pr_number: int, body: str
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
"""Create a comment on a pull request.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
owner: Repository owner
|
|
318
|
+
repo: Repository name
|
|
319
|
+
pr_number: Pull request number
|
|
320
|
+
body: Comment body
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Comment data
|
|
324
|
+
"""
|
|
325
|
+
result = await self._request(
|
|
326
|
+
"POST",
|
|
327
|
+
f"/repos/{owner}/{repo}/issues/{pr_number}/comments",
|
|
328
|
+
json={"body": body},
|
|
329
|
+
)
|
|
330
|
+
return result if isinstance(result, dict) else {}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
|
3
|
+
|
|
4
|
+
"""GraphQL queries for efficient organization scanning."""
|
|
5
|
+
|
|
6
|
+
# Lightweight query to list repositories without PR nodes for accurate counting
|
|
7
|
+
# This is the SAME query used by dependamerge for efficient repo enumeration
|
|
8
|
+
ORG_REPOS_ONLY = """
|
|
9
|
+
query($org: String!, $reposCursor: String) {
|
|
10
|
+
organization(login: $org) {
|
|
11
|
+
repositories(first: 100, after: $reposCursor, orderBy: { field: NAME, direction: ASC }) {
|
|
12
|
+
totalCount
|
|
13
|
+
pageInfo {
|
|
14
|
+
hasNextPage
|
|
15
|
+
endCursor
|
|
16
|
+
}
|
|
17
|
+
nodes {
|
|
18
|
+
nameWithOwner
|
|
19
|
+
isArchived
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Query to get repositories WITH open PRs in an organization (aggregated)
|
|
28
|
+
# NOTE: This should NOT be used for counting - use ORG_REPOS_ONLY instead
|
|
29
|
+
ORG_REPOS_WITH_PRS = """
|
|
30
|
+
query($org: String!, $cursor: String, $prsPageSize: Int!, $contextsPageSize: Int!) {
|
|
31
|
+
organization(login: $org) {
|
|
32
|
+
repositories(first: 30, after: $cursor, orderBy: { field: NAME, direction: ASC }) {
|
|
33
|
+
pageInfo {
|
|
34
|
+
hasNextPage
|
|
35
|
+
endCursor
|
|
36
|
+
}
|
|
37
|
+
nodes {
|
|
38
|
+
nameWithOwner
|
|
39
|
+
isArchived
|
|
40
|
+
owner {
|
|
41
|
+
login
|
|
42
|
+
}
|
|
43
|
+
name
|
|
44
|
+
pullRequests(
|
|
45
|
+
states: OPEN
|
|
46
|
+
first: $prsPageSize
|
|
47
|
+
orderBy: { field: CREATED_AT, direction: DESC }
|
|
48
|
+
) {
|
|
49
|
+
totalCount
|
|
50
|
+
pageInfo {
|
|
51
|
+
hasNextPage
|
|
52
|
+
endCursor
|
|
53
|
+
}
|
|
54
|
+
nodes {
|
|
55
|
+
number
|
|
56
|
+
title
|
|
57
|
+
body
|
|
58
|
+
url
|
|
59
|
+
isDraft
|
|
60
|
+
author { login }
|
|
61
|
+
mergeable
|
|
62
|
+
mergeStateStatus
|
|
63
|
+
baseRefName
|
|
64
|
+
headRefName
|
|
65
|
+
headRefOid
|
|
66
|
+
createdAt
|
|
67
|
+
updatedAt
|
|
68
|
+
headRepository {
|
|
69
|
+
nameWithOwner
|
|
70
|
+
url
|
|
71
|
+
}
|
|
72
|
+
baseRepository {
|
|
73
|
+
nameWithOwner
|
|
74
|
+
url
|
|
75
|
+
}
|
|
76
|
+
isCrossRepository
|
|
77
|
+
maintainerCanModify
|
|
78
|
+
commits(last: 1) {
|
|
79
|
+
nodes {
|
|
80
|
+
commit {
|
|
81
|
+
oid
|
|
82
|
+
statusCheckRollup {
|
|
83
|
+
state
|
|
84
|
+
contexts(first: $contextsPageSize) {
|
|
85
|
+
nodes {
|
|
86
|
+
__typename
|
|
87
|
+
... on CheckRun {
|
|
88
|
+
name
|
|
89
|
+
status
|
|
90
|
+
conclusion
|
|
91
|
+
}
|
|
92
|
+
... on StatusContext {
|
|
93
|
+
context
|
|
94
|
+
state
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Query to get open PRs for a specific repository with status checks
|
|
112
|
+
# This matches dependamerge's REPO_OPEN_PRS_PAGE with parameterized page sizes
|
|
113
|
+
REPO_OPEN_PRS_PAGE = """
|
|
114
|
+
query($owner: String!, $name: String!, $prsCursor: String, $prsPageSize: Int!, $filesPageSize: Int!, $commentsPageSize: Int!, $contextsPageSize: Int!) {
|
|
115
|
+
repository(owner: $owner, name: $name) {
|
|
116
|
+
nameWithOwner
|
|
117
|
+
pullRequests(
|
|
118
|
+
states: OPEN
|
|
119
|
+
first: $prsPageSize
|
|
120
|
+
after: $prsCursor
|
|
121
|
+
orderBy: { field: CREATED_AT, direction: DESC }
|
|
122
|
+
) {
|
|
123
|
+
pageInfo {
|
|
124
|
+
hasNextPage
|
|
125
|
+
endCursor
|
|
126
|
+
}
|
|
127
|
+
nodes {
|
|
128
|
+
number
|
|
129
|
+
title
|
|
130
|
+
body
|
|
131
|
+
url
|
|
132
|
+
isDraft
|
|
133
|
+
author { login }
|
|
134
|
+
mergeable
|
|
135
|
+
mergeStateStatus
|
|
136
|
+
baseRefName
|
|
137
|
+
headRefName
|
|
138
|
+
headRefOid
|
|
139
|
+
createdAt
|
|
140
|
+
updatedAt
|
|
141
|
+
files(first: $filesPageSize) {
|
|
142
|
+
nodes {
|
|
143
|
+
path
|
|
144
|
+
additions
|
|
145
|
+
deletions
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
comments(first: $commentsPageSize, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
|
149
|
+
nodes {
|
|
150
|
+
author { login }
|
|
151
|
+
body
|
|
152
|
+
createdAt
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
reviews(first: 20, states: [PENDING, COMMENTED, APPROVED, CHANGES_REQUESTED]) {
|
|
156
|
+
nodes {
|
|
157
|
+
id
|
|
158
|
+
author { login }
|
|
159
|
+
state
|
|
160
|
+
body
|
|
161
|
+
createdAt
|
|
162
|
+
updatedAt
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
headRepository {
|
|
166
|
+
nameWithOwner
|
|
167
|
+
url
|
|
168
|
+
}
|
|
169
|
+
baseRepository {
|
|
170
|
+
nameWithOwner
|
|
171
|
+
url
|
|
172
|
+
}
|
|
173
|
+
isCrossRepository
|
|
174
|
+
maintainerCanModify
|
|
175
|
+
commits(last: 1) {
|
|
176
|
+
nodes {
|
|
177
|
+
commit {
|
|
178
|
+
oid
|
|
179
|
+
statusCheckRollup {
|
|
180
|
+
state
|
|
181
|
+
contexts(first: $contextsPageSize) {
|
|
182
|
+
nodes {
|
|
183
|
+
__typename
|
|
184
|
+
... on CheckRun {
|
|
185
|
+
name
|
|
186
|
+
status
|
|
187
|
+
conclusion
|
|
188
|
+
}
|
|
189
|
+
... on StatusContext {
|
|
190
|
+
context
|
|
191
|
+
state
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
# Query to get a single PR with status checks
|
|
206
|
+
PR_WITH_STATUS = """
|
|
207
|
+
query($owner: String!, $name: String!, $number: Int!, $contextsPageSize: Int!) {
|
|
208
|
+
repository(owner: $owner, name: $name) {
|
|
209
|
+
pullRequest(number: $number) {
|
|
210
|
+
number
|
|
211
|
+
title
|
|
212
|
+
body
|
|
213
|
+
url
|
|
214
|
+
isDraft
|
|
215
|
+
author { login }
|
|
216
|
+
mergeable
|
|
217
|
+
mergeStateStatus
|
|
218
|
+
baseRefName
|
|
219
|
+
headRefName
|
|
220
|
+
headRefOid
|
|
221
|
+
createdAt
|
|
222
|
+
updatedAt
|
|
223
|
+
headRepository {
|
|
224
|
+
nameWithOwner
|
|
225
|
+
url
|
|
226
|
+
}
|
|
227
|
+
baseRepository {
|
|
228
|
+
nameWithOwner
|
|
229
|
+
url
|
|
230
|
+
}
|
|
231
|
+
isCrossRepository
|
|
232
|
+
maintainerCanModify
|
|
233
|
+
commits(last: 1) {
|
|
234
|
+
nodes {
|
|
235
|
+
commit {
|
|
236
|
+
oid
|
|
237
|
+
statusCheckRollup {
|
|
238
|
+
state
|
|
239
|
+
contexts(first: $contextsPageSize) {
|
|
240
|
+
nodes {
|
|
241
|
+
__typename
|
|
242
|
+
... on CheckRun {
|
|
243
|
+
name
|
|
244
|
+
status
|
|
245
|
+
conclusion
|
|
246
|
+
}
|
|
247
|
+
... on StatusContext {
|
|
248
|
+
context
|
|
249
|
+
state
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
# Query to get first commit message for a PR (lightweight)
|
|
263
|
+
PR_FIRST_COMMIT = """
|
|
264
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
265
|
+
repository(owner: $owner, name: $name) {
|
|
266
|
+
pullRequest(number: $number) {
|
|
267
|
+
number
|
|
268
|
+
title
|
|
269
|
+
commits(first: 1) {
|
|
270
|
+
nodes {
|
|
271
|
+
commit {
|
|
272
|
+
oid
|
|
273
|
+
message
|
|
274
|
+
messageHeadline
|
|
275
|
+
messageBody
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
# Bulk query to get first commit messages for multiple PRs (performance optimized)
|
|
285
|
+
# This uses GraphQL aliases to fetch multiple PRs in a single request
|
|
286
|
+
BULK_PR_COMMITS_TEMPLATE = """
|
|
287
|
+
query($owner: String!, $name: String!) {
|
|
288
|
+
repository(owner: $owner, name: $name) {
|
|
289
|
+
{pr_queries}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
# Template for individual PR in bulk query
|
|
295
|
+
PR_COMMIT_FRAGMENT = """
|
|
296
|
+
pr{number}: pullRequest(number: {number}) {
|
|
297
|
+
number
|
|
298
|
+
title
|
|
299
|
+
commits(first: 1) {
|
|
300
|
+
nodes {
|
|
301
|
+
commit {
|
|
302
|
+
oid
|
|
303
|
+
message
|
|
304
|
+
messageHeadline
|
|
305
|
+
messageBody
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
"""
|