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