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.
@@ -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
+ """