gtg 0.4.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.
- goodtogo/__init__.py +66 -0
- goodtogo/adapters/__init__.py +22 -0
- goodtogo/adapters/agent_state.py +490 -0
- goodtogo/adapters/cache_memory.py +208 -0
- goodtogo/adapters/cache_sqlite.py +305 -0
- goodtogo/adapters/github.py +523 -0
- goodtogo/adapters/time_provider.py +123 -0
- goodtogo/cli.py +311 -0
- goodtogo/container.py +313 -0
- goodtogo/core/__init__.py +0 -0
- goodtogo/core/analyzer.py +982 -0
- goodtogo/core/errors.py +100 -0
- goodtogo/core/interfaces.py +388 -0
- goodtogo/core/models.py +312 -0
- goodtogo/core/validation.py +144 -0
- goodtogo/parsers/__init__.py +0 -0
- goodtogo/parsers/claude.py +188 -0
- goodtogo/parsers/coderabbit.py +352 -0
- goodtogo/parsers/cursor.py +135 -0
- goodtogo/parsers/generic.py +192 -0
- goodtogo/parsers/greptile.py +249 -0
- gtg-0.4.0.dist-info/METADATA +278 -0
- gtg-0.4.0.dist-info/RECORD +27 -0
- gtg-0.4.0.dist-info/WHEEL +5 -0
- gtg-0.4.0.dist-info/entry_points.txt +2 -0
- gtg-0.4.0.dist-info/licenses/LICENSE +21 -0
- gtg-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""GitHub API adapter implementing the GitHubPort interface.
|
|
2
|
+
|
|
3
|
+
This module provides a concrete implementation of the GitHubPort interface
|
|
4
|
+
using httpx for HTTP requests to the GitHub REST API. It handles authentication,
|
|
5
|
+
rate limiting, and error handling.
|
|
6
|
+
|
|
7
|
+
Security features:
|
|
8
|
+
- Token stored in private `_token` attribute
|
|
9
|
+
- Token never appears in __repr__, __str__, or error messages
|
|
10
|
+
- All errors are redacted before propagation
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional, cast
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from goodtogo.adapters.time_provider import SystemTimeProvider
|
|
20
|
+
from goodtogo.core.interfaces import GitHubPort, TimeProvider
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitHubRateLimitError(Exception):
|
|
24
|
+
"""Raised when GitHub API rate limit is exceeded.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
reset_at: Unix timestamp when the rate limit resets.
|
|
28
|
+
retry_after: Seconds until the rate limit resets.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, reset_at: int, retry_after: int) -> None:
|
|
32
|
+
"""Initialize the rate limit error.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
message: Error description.
|
|
36
|
+
reset_at: Unix timestamp when rate limit resets.
|
|
37
|
+
retry_after: Seconds until rate limit resets.
|
|
38
|
+
"""
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.reset_at = reset_at
|
|
41
|
+
self.retry_after = retry_after
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GitHubAPIError(Exception):
|
|
45
|
+
"""Raised when a GitHub API request fails.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
status_code: HTTP status code from the response.
|
|
49
|
+
message: Error description (with sensitive data redacted).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message: str, status_code: int) -> None:
|
|
53
|
+
"""Initialize the API error.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
message: Error description.
|
|
57
|
+
status_code: HTTP status code.
|
|
58
|
+
"""
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.status_code = status_code
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GitHubAdapter(GitHubPort):
|
|
64
|
+
"""GitHub API adapter with secure token handling.
|
|
65
|
+
|
|
66
|
+
This adapter implements the GitHubPort interface using httpx to make
|
|
67
|
+
HTTP requests to the GitHub REST API and GraphQL API.
|
|
68
|
+
|
|
69
|
+
The authentication token is stored in a private attribute and is never
|
|
70
|
+
exposed through string representations or error messages.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
BASE_URL: GitHub REST API base URL.
|
|
74
|
+
GRAPHQL_URL: GitHub GraphQL API URL.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> adapter = GitHubAdapter(token="ghp_...")
|
|
78
|
+
>>> pr = adapter.get_pr("owner", "repo", 123)
|
|
79
|
+
>>> print(pr["title"])
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
BASE_URL = "https://api.github.com"
|
|
83
|
+
GRAPHQL_URL = "https://api.github.com/graphql"
|
|
84
|
+
|
|
85
|
+
def __init__(self, token: str, time_provider: Optional[TimeProvider] = None) -> None:
|
|
86
|
+
"""Initialize the GitHub adapter.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
token: GitHub personal access token or OAuth token.
|
|
90
|
+
Must have 'repo' scope for private repositories.
|
|
91
|
+
time_provider: Optional TimeProvider for time operations.
|
|
92
|
+
Defaults to SystemTimeProvider if not provided.
|
|
93
|
+
"""
|
|
94
|
+
# Token stored in private attribute - never log, cache, or serialize
|
|
95
|
+
self._token = token
|
|
96
|
+
self._time_provider = time_provider or SystemTimeProvider()
|
|
97
|
+
self._client = httpx.Client(
|
|
98
|
+
base_url=self.BASE_URL,
|
|
99
|
+
headers={
|
|
100
|
+
"Accept": "application/vnd.github+json",
|
|
101
|
+
"Authorization": f"Bearer {self._token}",
|
|
102
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
103
|
+
},
|
|
104
|
+
timeout=30.0,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str:
|
|
108
|
+
"""Return string representation with redacted token.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
String with token redacted for safe logging.
|
|
112
|
+
"""
|
|
113
|
+
return "GitHubAdapter(token=<redacted>)"
|
|
114
|
+
|
|
115
|
+
def __str__(self) -> str:
|
|
116
|
+
"""Return string representation with redacted token.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
String with token redacted for safe logging.
|
|
120
|
+
"""
|
|
121
|
+
return self.__repr__()
|
|
122
|
+
|
|
123
|
+
def __del__(self) -> None:
|
|
124
|
+
"""Clean up the HTTP client on deletion."""
|
|
125
|
+
if hasattr(self, "_client"):
|
|
126
|
+
self._client.close()
|
|
127
|
+
|
|
128
|
+
def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
129
|
+
"""Handle HTTP response, checking for errors and rate limits.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
response: The httpx response object.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Parsed JSON response as a dictionary.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
GitHubRateLimitError: If rate limit is exceeded.
|
|
139
|
+
GitHubAPIError: If the request fails for other reasons.
|
|
140
|
+
"""
|
|
141
|
+
# Check for rate limiting
|
|
142
|
+
if response.status_code == 403:
|
|
143
|
+
remaining = response.headers.get("X-RateLimit-Remaining", "unknown")
|
|
144
|
+
if remaining == "0":
|
|
145
|
+
reset_at = int(response.headers.get("X-RateLimit-Reset", "0"))
|
|
146
|
+
retry_after = max(0, reset_at - self._time_provider.now_int())
|
|
147
|
+
raise GitHubRateLimitError(
|
|
148
|
+
f"GitHub API rate limit exceeded. Resets in {retry_after} seconds.",
|
|
149
|
+
reset_at=reset_at,
|
|
150
|
+
retry_after=retry_after,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Check for secondary rate limiting
|
|
154
|
+
if response.status_code == 429:
|
|
155
|
+
retry_after = int(response.headers.get("Retry-After", "60"))
|
|
156
|
+
reset_at = self._time_provider.now_int() + retry_after
|
|
157
|
+
raise GitHubRateLimitError(
|
|
158
|
+
f"GitHub API secondary rate limit. Retry after {retry_after} seconds.",
|
|
159
|
+
reset_at=reset_at,
|
|
160
|
+
retry_after=retry_after,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Handle other errors
|
|
164
|
+
if response.status_code >= 400:
|
|
165
|
+
# Do not include response body in error - may contain sensitive data
|
|
166
|
+
raise GitHubAPIError(
|
|
167
|
+
f"GitHub API request failed with status {response.status_code}",
|
|
168
|
+
status_code=response.status_code,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return cast(dict[str, Any], response.json())
|
|
172
|
+
|
|
173
|
+
def _handle_list_response(self, response: httpx.Response) -> list[dict[str, Any]]:
|
|
174
|
+
"""Handle HTTP response that returns a list.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
response: The httpx response object.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Parsed JSON response as a list.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
GitHubRateLimitError: If rate limit is exceeded.
|
|
184
|
+
GitHubAPIError: If the request fails.
|
|
185
|
+
"""
|
|
186
|
+
# Reuse error handling logic (will raise on errors)
|
|
187
|
+
self._handle_response(response)
|
|
188
|
+
|
|
189
|
+
# The above will raise on errors, so we can safely parse
|
|
190
|
+
return cast(list[dict[str, Any]], response.json())
|
|
191
|
+
|
|
192
|
+
def get_pr(self, owner: str, repo: str, pr_number: int) -> dict[str, Any]:
|
|
193
|
+
"""Fetch PR metadata.
|
|
194
|
+
|
|
195
|
+
Retrieves basic PR information including title, state, head SHA,
|
|
196
|
+
base branch, and timestamps.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
owner: Repository owner (organization or user name).
|
|
200
|
+
repo: Repository name.
|
|
201
|
+
pr_number: Pull request number.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Dictionary containing PR metadata with keys like:
|
|
205
|
+
- 'number': PR number
|
|
206
|
+
- 'title': PR title
|
|
207
|
+
- 'state': PR state ('open', 'closed', 'merged')
|
|
208
|
+
- 'head': Dictionary with 'sha' key for latest commit
|
|
209
|
+
- 'base': Dictionary with 'ref' key for base branch
|
|
210
|
+
- 'created_at': ISO timestamp
|
|
211
|
+
- 'updated_at': ISO timestamp
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
GitHubRateLimitError: If rate limit is exceeded.
|
|
215
|
+
GitHubAPIError: If the request fails or PR is not found.
|
|
216
|
+
"""
|
|
217
|
+
response = self._client.get(f"/repos/{owner}/{repo}/pulls/{pr_number}")
|
|
218
|
+
return self._handle_response(response)
|
|
219
|
+
|
|
220
|
+
def get_pr_comments(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]:
|
|
221
|
+
"""Fetch all PR comments (inline + review + issue).
|
|
222
|
+
|
|
223
|
+
Retrieves all types of comments associated with a PR:
|
|
224
|
+
- Review comments (inline on specific code lines)
|
|
225
|
+
- Issue comments (on the PR itself)
|
|
226
|
+
|
|
227
|
+
Note: Review body comments are retrieved via get_pr_reviews().
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
owner: Repository owner (organization or user name).
|
|
231
|
+
repo: Repository name.
|
|
232
|
+
pr_number: Pull request number.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of dictionaries, each containing:
|
|
236
|
+
- 'id': Unique comment identifier
|
|
237
|
+
- 'user': Dictionary with 'login' key for author
|
|
238
|
+
- 'body': Comment text content
|
|
239
|
+
- 'created_at': ISO timestamp
|
|
240
|
+
- 'path': File path (for inline comments, None otherwise)
|
|
241
|
+
- 'line': Line number (for inline comments, None otherwise)
|
|
242
|
+
- 'in_reply_to_id': Parent comment ID if reply (None otherwise)
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
GitHubRateLimitError: If rate limit is exceeded.
|
|
246
|
+
GitHubAPIError: If the request fails.
|
|
247
|
+
"""
|
|
248
|
+
# Fetch review comments (inline code comments)
|
|
249
|
+
review_comments = self._fetch_paginated(f"/repos/{owner}/{repo}/pulls/{pr_number}/comments")
|
|
250
|
+
|
|
251
|
+
# Fetch issue comments (PR-level comments)
|
|
252
|
+
issue_comments = self._fetch_paginated(f"/repos/{owner}/{repo}/issues/{pr_number}/comments")
|
|
253
|
+
|
|
254
|
+
# Combine and return all comments
|
|
255
|
+
return review_comments + issue_comments
|
|
256
|
+
|
|
257
|
+
def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]:
|
|
258
|
+
"""Fetch all PR reviews.
|
|
259
|
+
|
|
260
|
+
Retrieves all reviews submitted on a PR, including approvals,
|
|
261
|
+
change requests, and comment-only reviews.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
owner: Repository owner (organization or user name).
|
|
265
|
+
repo: Repository name.
|
|
266
|
+
pr_number: Pull request number.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List of dictionaries, each containing:
|
|
270
|
+
- 'id': Unique review identifier
|
|
271
|
+
- 'user': Dictionary with 'login' key for reviewer
|
|
272
|
+
- 'body': Review body text (may be empty)
|
|
273
|
+
- 'state': Review state ('APPROVED', 'CHANGES_REQUESTED',
|
|
274
|
+
'COMMENTED', 'DISMISSED', 'PENDING')
|
|
275
|
+
- 'submitted_at': ISO timestamp
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
GitHubRateLimitError: If rate limit is exceeded.
|
|
279
|
+
GitHubAPIError: If the request fails.
|
|
280
|
+
"""
|
|
281
|
+
return self._fetch_paginated(f"/repos/{owner}/{repo}/pulls/{pr_number}/reviews")
|
|
282
|
+
|
|
283
|
+
def get_pr_threads(self, owner: str, repo: str, pr_number: int) -> list[dict[str, Any]]:
|
|
284
|
+
"""Fetch all review threads with resolution status.
|
|
285
|
+
|
|
286
|
+
Uses GitHub's GraphQL API to retrieve review threads with their
|
|
287
|
+
resolution status, which is not available in the REST API.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
owner: Repository owner (organization or user name).
|
|
291
|
+
repo: Repository name.
|
|
292
|
+
pr_number: Pull request number.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
List of dictionaries, each containing:
|
|
296
|
+
- 'id': Thread node ID
|
|
297
|
+
- 'is_resolved': Boolean indicating if thread is resolved
|
|
298
|
+
- 'is_outdated': Boolean indicating if thread is outdated
|
|
299
|
+
- 'path': File path the thread is attached to
|
|
300
|
+
- 'line': Line number in the diff
|
|
301
|
+
- 'comments': List of comments in the thread
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
GitHubRateLimitError: If rate limit is exceeded.
|
|
305
|
+
GitHubAPIError: If the request fails.
|
|
306
|
+
"""
|
|
307
|
+
query = """
|
|
308
|
+
query($owner: String!, $repo: String!, $pr_number: Int!) {
|
|
309
|
+
repository(owner: $owner, name: $repo) {
|
|
310
|
+
pullRequest(number: $pr_number) {
|
|
311
|
+
reviewThreads(first: 100) {
|
|
312
|
+
nodes {
|
|
313
|
+
id
|
|
314
|
+
isResolved
|
|
315
|
+
isOutdated
|
|
316
|
+
path
|
|
317
|
+
line
|
|
318
|
+
comments(first: 100) {
|
|
319
|
+
nodes {
|
|
320
|
+
id
|
|
321
|
+
body
|
|
322
|
+
author {
|
|
323
|
+
login
|
|
324
|
+
}
|
|
325
|
+
createdAt
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
variables = {"owner": owner, "repo": repo, "pr_number": pr_number}
|
|
336
|
+
|
|
337
|
+
response = self._client.post(
|
|
338
|
+
self.GRAPHQL_URL,
|
|
339
|
+
json={"query": query, "variables": variables},
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
data = self._handle_response(response)
|
|
343
|
+
|
|
344
|
+
# Check for GraphQL errors
|
|
345
|
+
if "errors" in data:
|
|
346
|
+
error_messages = [e.get("message", "Unknown error") for e in data["errors"]]
|
|
347
|
+
raise GitHubAPIError(
|
|
348
|
+
f"GraphQL query failed: {'; '.join(error_messages)}",
|
|
349
|
+
status_code=200,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Extract threads from response
|
|
353
|
+
threads_data = (
|
|
354
|
+
data.get("data", {})
|
|
355
|
+
.get("repository", {})
|
|
356
|
+
.get("pullRequest", {})
|
|
357
|
+
.get("reviewThreads", {})
|
|
358
|
+
.get("nodes", [])
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Transform to consistent format
|
|
362
|
+
threads: list[dict[str, Any]] = []
|
|
363
|
+
for thread in threads_data:
|
|
364
|
+
comments_nodes = thread.get("comments", {}).get("nodes", [])
|
|
365
|
+
threads.append(
|
|
366
|
+
{
|
|
367
|
+
"id": thread.get("id"),
|
|
368
|
+
"is_resolved": thread.get("isResolved", False),
|
|
369
|
+
"is_outdated": thread.get("isOutdated", False),
|
|
370
|
+
"path": thread.get("path"),
|
|
371
|
+
"line": thread.get("line"),
|
|
372
|
+
"comments": [
|
|
373
|
+
{
|
|
374
|
+
"id": c.get("id"),
|
|
375
|
+
"body": c.get("body", ""),
|
|
376
|
+
"author": c.get("author", {}).get("login", "unknown"),
|
|
377
|
+
"created_at": c.get("createdAt"),
|
|
378
|
+
}
|
|
379
|
+
for c in comments_nodes
|
|
380
|
+
],
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
return threads
|
|
385
|
+
|
|
386
|
+
def get_ci_status(self, owner: str, repo: str, ref: str) -> dict[str, Any]:
|
|
387
|
+
"""Fetch CI/CD check status for a commit.
|
|
388
|
+
|
|
389
|
+
Retrieves the combined status of all CI checks for a specific
|
|
390
|
+
commit reference. This includes both commit statuses (legacy)
|
|
391
|
+
and check runs (GitHub Actions, third-party CI).
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
owner: Repository owner (organization or user name).
|
|
395
|
+
repo: Repository name.
|
|
396
|
+
ref: Git reference (commit SHA, branch name, or tag).
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
Dictionary containing:
|
|
400
|
+
- 'state': Overall state ('success', 'failure', 'pending')
|
|
401
|
+
- 'total_count': Total number of checks
|
|
402
|
+
- 'statuses': List of individual status checks
|
|
403
|
+
- 'check_runs': List of check runs (GitHub Actions, etc.)
|
|
404
|
+
|
|
405
|
+
Each status/check_run contains:
|
|
406
|
+
- 'name': Check name
|
|
407
|
+
- 'state' or 'status': Individual check state
|
|
408
|
+
- 'conclusion': Final conclusion (for completed checks)
|
|
409
|
+
- 'target_url' or 'html_url': Link to check details
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
GitHubRateLimitError: If rate limit is exceeded.
|
|
413
|
+
GitHubAPIError: If the request fails.
|
|
414
|
+
"""
|
|
415
|
+
# Fetch combined commit status (legacy status API)
|
|
416
|
+
status_response = self._client.get(f"/repos/{owner}/{repo}/commits/{ref}/status")
|
|
417
|
+
status_data = self._handle_response(status_response)
|
|
418
|
+
|
|
419
|
+
# Fetch check runs (GitHub Actions, etc.)
|
|
420
|
+
check_runs_response = self._client.get(f"/repos/{owner}/{repo}/commits/{ref}/check-runs")
|
|
421
|
+
check_runs_data = self._handle_response(check_runs_response)
|
|
422
|
+
|
|
423
|
+
# Combine results
|
|
424
|
+
statuses = status_data.get("statuses", [])
|
|
425
|
+
check_runs = check_runs_data.get("check_runs", [])
|
|
426
|
+
|
|
427
|
+
# Calculate overall state
|
|
428
|
+
all_states: list[str] = []
|
|
429
|
+
|
|
430
|
+
# Add legacy status states
|
|
431
|
+
for status in statuses:
|
|
432
|
+
all_states.append(status.get("state", "pending"))
|
|
433
|
+
|
|
434
|
+
# Add check run conclusions
|
|
435
|
+
for check_run in check_runs:
|
|
436
|
+
status_val = check_run.get("status", "queued")
|
|
437
|
+
if status_val == "completed":
|
|
438
|
+
conclusion = check_run.get("conclusion", "neutral")
|
|
439
|
+
if conclusion == "success":
|
|
440
|
+
all_states.append("success")
|
|
441
|
+
elif conclusion in ("failure", "cancelled", "timed_out"):
|
|
442
|
+
all_states.append("failure")
|
|
443
|
+
else:
|
|
444
|
+
all_states.append("pending")
|
|
445
|
+
else:
|
|
446
|
+
all_states.append("pending")
|
|
447
|
+
|
|
448
|
+
# Determine overall state
|
|
449
|
+
if not all_states:
|
|
450
|
+
overall_state = "success" # No checks means success
|
|
451
|
+
elif "failure" in all_states:
|
|
452
|
+
overall_state = "failure"
|
|
453
|
+
elif "pending" in all_states:
|
|
454
|
+
overall_state = "pending"
|
|
455
|
+
else:
|
|
456
|
+
overall_state = "success"
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
"state": overall_state,
|
|
460
|
+
"total_count": len(statuses) + len(check_runs),
|
|
461
|
+
"statuses": statuses,
|
|
462
|
+
"check_runs": check_runs,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
def _fetch_paginated(self, endpoint: str) -> list[dict[str, Any]]:
|
|
466
|
+
"""Fetch all pages of a paginated endpoint.
|
|
467
|
+
|
|
468
|
+
GitHub API paginates results. This method handles pagination
|
|
469
|
+
by following 'next' links in the Link header.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
endpoint: API endpoint path (e.g., '/repos/owner/repo/pulls').
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
Combined list of all items from all pages.
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
GitHubRateLimitError: If rate limit is exceeded.
|
|
479
|
+
GitHubAPIError: If any request fails.
|
|
480
|
+
"""
|
|
481
|
+
results: list[dict[str, Any]] = []
|
|
482
|
+
url: Optional[str] = endpoint
|
|
483
|
+
params: dict[str, str] = {"per_page": "100"}
|
|
484
|
+
|
|
485
|
+
while url is not None:
|
|
486
|
+
response = self._client.get(url, params=params if "?" not in url else None)
|
|
487
|
+
|
|
488
|
+
# Handle response (will raise on errors)
|
|
489
|
+
if response.status_code >= 400:
|
|
490
|
+
self._handle_response(response)
|
|
491
|
+
|
|
492
|
+
page_results = cast(list[dict[str, Any]], response.json())
|
|
493
|
+
results.extend(page_results)
|
|
494
|
+
|
|
495
|
+
# Check for next page in Link header
|
|
496
|
+
url = self._get_next_page_url(response)
|
|
497
|
+
params = {} # Clear params for subsequent requests (URL has them)
|
|
498
|
+
|
|
499
|
+
return results
|
|
500
|
+
|
|
501
|
+
def _get_next_page_url(self, response: httpx.Response) -> Optional[str]:
|
|
502
|
+
"""Extract next page URL from Link header.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
response: HTTP response with potential Link header.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
URL for next page, or None if no more pages.
|
|
509
|
+
"""
|
|
510
|
+
link_header = response.headers.get("Link", "")
|
|
511
|
+
if not link_header:
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
# Parse Link header: <url>; rel="next", <url>; rel="prev"
|
|
515
|
+
for part in link_header.split(","):
|
|
516
|
+
if 'rel="next"' in part:
|
|
517
|
+
# Extract URL from <url>
|
|
518
|
+
url_part: str = part.split(";")[0].strip()
|
|
519
|
+
if url_part.startswith("<") and url_part.endswith(">"):
|
|
520
|
+
next_url: str = url_part[1:-1]
|
|
521
|
+
return next_url
|
|
522
|
+
|
|
523
|
+
return None
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Time provider implementations.
|
|
2
|
+
|
|
3
|
+
This module provides implementations of the TimeProvider interface:
|
|
4
|
+
- SystemTimeProvider: Uses real system time (for production)
|
|
5
|
+
- MockTimeProvider: Controllable time for deterministic tests
|
|
6
|
+
|
|
7
|
+
Using dependency injection for time eliminates flaky tests caused by
|
|
8
|
+
time.sleep() calls and non-deterministic time.time() values.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from goodtogo.core.interfaces import TimeProvider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SystemTimeProvider(TimeProvider):
|
|
19
|
+
"""Real system time provider for production use.
|
|
20
|
+
|
|
21
|
+
This implementation delegates to the standard library time module.
|
|
22
|
+
Use this in production code where real time behavior is needed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def now(self) -> float:
|
|
26
|
+
"""Get current time as Unix timestamp.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Current time as seconds since epoch (float).
|
|
30
|
+
"""
|
|
31
|
+
return time.time()
|
|
32
|
+
|
|
33
|
+
def now_int(self) -> int:
|
|
34
|
+
"""Get current time as Unix timestamp (integer).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Current time as seconds since epoch (int).
|
|
38
|
+
"""
|
|
39
|
+
return int(time.time())
|
|
40
|
+
|
|
41
|
+
def sleep(self, seconds: float) -> None:
|
|
42
|
+
"""Sleep for the specified duration.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
seconds: Duration to sleep in seconds.
|
|
46
|
+
"""
|
|
47
|
+
time.sleep(seconds)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MockTimeProvider(TimeProvider):
|
|
51
|
+
"""Controllable time provider for deterministic testing.
|
|
52
|
+
|
|
53
|
+
This implementation allows tests to control time precisely:
|
|
54
|
+
- Set an initial time
|
|
55
|
+
- Advance time by specific amounts
|
|
56
|
+
- Sleep advances simulated time instantly (no real waiting)
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> time_provider = MockTimeProvider(start=1000.0)
|
|
60
|
+
>>> time_provider.now()
|
|
61
|
+
1000.0
|
|
62
|
+
>>> time_provider.advance(60)
|
|
63
|
+
>>> time_provider.now()
|
|
64
|
+
1060.0
|
|
65
|
+
>>> time_provider.sleep(30) # Instant, no real waiting
|
|
66
|
+
>>> time_provider.now()
|
|
67
|
+
1090.0
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, start: float = 0.0) -> None:
|
|
71
|
+
"""Initialize with a starting time.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
start: Initial time value (seconds since epoch).
|
|
75
|
+
Defaults to 0.0 for simplicity in tests.
|
|
76
|
+
"""
|
|
77
|
+
self._current_time = start
|
|
78
|
+
|
|
79
|
+
def now(self) -> float:
|
|
80
|
+
"""Get current simulated time.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Current simulated time as float.
|
|
84
|
+
"""
|
|
85
|
+
return self._current_time
|
|
86
|
+
|
|
87
|
+
def now_int(self) -> int:
|
|
88
|
+
"""Get current simulated time as integer.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Current simulated time as int.
|
|
92
|
+
"""
|
|
93
|
+
return int(self._current_time)
|
|
94
|
+
|
|
95
|
+
def sleep(self, seconds: float) -> None:
|
|
96
|
+
"""Advance simulated time instantly.
|
|
97
|
+
|
|
98
|
+
Unlike real sleep, this returns immediately after
|
|
99
|
+
advancing the internal time counter.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
seconds: Duration to advance time by.
|
|
103
|
+
"""
|
|
104
|
+
self._current_time += seconds
|
|
105
|
+
|
|
106
|
+
def advance(self, seconds: float) -> None:
|
|
107
|
+
"""Advance simulated time by the specified amount.
|
|
108
|
+
|
|
109
|
+
This is an alias for sleep() but with clearer intent
|
|
110
|
+
when used in test setup.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
seconds: Duration to advance time by.
|
|
114
|
+
"""
|
|
115
|
+
self._current_time += seconds
|
|
116
|
+
|
|
117
|
+
def set_time(self, timestamp: float) -> None:
|
|
118
|
+
"""Set simulated time to a specific value.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
timestamp: New time value (seconds since epoch).
|
|
122
|
+
"""
|
|
123
|
+
self._current_time = timestamp
|