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,100 @@
1
+ """Error handling utilities for GoodToMerge.
2
+
3
+ This module provides secure error handling that prevents sensitive information
4
+ like GitHub tokens and credentials from leaking in error messages, logs, or
5
+ exception tracebacks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Optional
12
+
13
+
14
+ class RedactedError(Exception):
15
+ """Exception with sensitive data redacted from the message.
16
+
17
+ This exception wraps an original exception while redacting any sensitive
18
+ information (tokens, credentials, etc.) from the error message.
19
+
20
+ Attributes:
21
+ original: The original exception that was redacted, if any.
22
+
23
+ Example:
24
+ >>> original = Exception("Failed with token ghp_secret123")
25
+ >>> redacted = RedactedError("Failed with token <REDACTED_TOKEN>", original)
26
+ >>> str(redacted)
27
+ 'Failed with token <REDACTED_TOKEN>'
28
+ >>> redacted.original
29
+ Exception('Failed with token ghp_secret123')
30
+ """
31
+
32
+ def __init__(self, message: str, original: Optional[Exception] = None) -> None:
33
+ """Initialize a RedactedError.
34
+
35
+ Args:
36
+ message: The redacted error message (should already be sanitized).
37
+ original: The original exception before redaction.
38
+ """
39
+ self.original = original
40
+ super().__init__(message)
41
+
42
+
43
+ def redact_error(error: Exception) -> RedactedError:
44
+ """Redact sensitive information from an exception's error message.
45
+
46
+ This function creates a new RedactedError with all sensitive patterns
47
+ removed from the message. The original exception is preserved in the
48
+ `original` attribute for debugging purposes.
49
+
50
+ Redacted patterns include:
51
+ - GitHub Personal Access Tokens (ghp_*)
52
+ - GitHub OAuth Tokens (gho_*)
53
+ - GitHub PAT tokens (github_pat_*)
54
+ - URL credentials (://user:pass@host)
55
+ - Authorization headers (Authorization: Bearer/token ...)
56
+
57
+ Args:
58
+ error: The exception to redact.
59
+
60
+ Returns:
61
+ A RedactedError with sensitive data replaced with placeholders.
62
+
63
+ Example:
64
+ >>> error = Exception("Auth failed: ghp_abc123xyz789")
65
+ >>> redacted = redact_error(error)
66
+ >>> "ghp_abc123xyz789" in str(redacted)
67
+ False
68
+ >>> "<REDACTED_TOKEN>" in str(redacted)
69
+ True
70
+ >>> redacted.original is error
71
+ True
72
+ """
73
+ message = str(error)
74
+
75
+ # Redact GitHub tokens (ghp_, gho_, github_pat_)
76
+ # Pattern: prefix followed by alphanumeric characters and underscores
77
+ message = re.sub(
78
+ r"(ghp_|gho_|github_pat_)[a-zA-Z0-9_]+",
79
+ "<REDACTED_TOKEN>",
80
+ message,
81
+ )
82
+
83
+ # Redact URL credentials (://user:pass@host)
84
+ # Pattern: :// followed by non-colon chars, colon, non-@ chars, @
85
+ message = re.sub(
86
+ r"://[^:]+:[^@]+@",
87
+ "://<REDACTED>@",
88
+ message,
89
+ )
90
+
91
+ # Redact Authorization headers
92
+ # Pattern: Authorization (with optional quotes/colons) followed by Bearer/token and the value
93
+ message = re.sub(
94
+ r'(Authorization["\']?\s*:\s*["\']?)(Bearer\s+)?[a-zA-Z0-9_-]+',
95
+ r"\1<REDACTED>",
96
+ message,
97
+ flags=re.IGNORECASE,
98
+ )
99
+
100
+ return RedactedError(message, original=error)
@@ -0,0 +1,388 @@
1
+ """Abstract base classes (ports) for the GoodToMerge hexagonal architecture.
2
+
3
+ This module defines the abstract interfaces (ports) that the core domain
4
+ depends on. Concrete implementations (adapters) must implement these
5
+ interfaces to integrate with external systems like GitHub API and caching.
6
+
7
+ Following the Ports & Adapters (Hexagonal) architecture pattern, these
8
+ interfaces ensure the core domain has no external dependencies.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from typing import TYPE_CHECKING, Optional
15
+
16
+ if TYPE_CHECKING:
17
+ from goodtogo.core.models import (
18
+ CacheStats,
19
+ CommentClassification,
20
+ Priority,
21
+ ReviewerType,
22
+ )
23
+
24
+
25
+ class GitHubPort(ABC):
26
+ """Abstract interface for GitHub API access.
27
+
28
+ This port defines the contract for fetching PR-related data from GitHub.
29
+ Implementations may use REST API, GraphQL, or any other mechanism to
30
+ fulfill these requirements.
31
+
32
+ All methods accept validated inputs (owner, repo, pr_number) and return
33
+ raw dictionary data that will be processed by the analyzer.
34
+ """
35
+
36
+ @abstractmethod
37
+ def get_pr(self, owner: str, repo: str, pr_number: int) -> dict:
38
+ """Fetch PR metadata.
39
+
40
+ Retrieves basic PR information including title, state, head SHA,
41
+ base branch, and timestamps.
42
+
43
+ Args:
44
+ owner: Repository owner (organization or user name).
45
+ repo: Repository name.
46
+ pr_number: Pull request number.
47
+
48
+ Returns:
49
+ Dictionary containing PR metadata with keys like:
50
+ - 'number': PR number
51
+ - 'title': PR title
52
+ - 'state': PR state ('open', 'closed', 'merged')
53
+ - 'head': Dictionary with 'sha' key for latest commit
54
+ - 'base': Dictionary with 'ref' key for base branch
55
+ - 'created_at': ISO timestamp
56
+ - 'updated_at': ISO timestamp
57
+
58
+ Raises:
59
+ Exception: If the API request fails or PR is not found.
60
+ """
61
+ pass
62
+
63
+ @abstractmethod
64
+ def get_pr_comments(self, owner: str, repo: str, pr_number: int) -> list[dict]:
65
+ """Fetch all PR comments (inline + review + issue).
66
+
67
+ Retrieves all types of comments associated with a PR:
68
+ - Inline review comments on specific code lines
69
+ - Review body comments
70
+ - Issue-style comments on the PR itself
71
+
72
+ Args:
73
+ owner: Repository owner (organization or user name).
74
+ repo: Repository name.
75
+ pr_number: Pull request number.
76
+
77
+ Returns:
78
+ List of dictionaries, each containing:
79
+ - 'id': Unique comment identifier
80
+ - 'user': Dictionary with 'login' key for author
81
+ - 'body': Comment text content
82
+ - 'created_at': ISO timestamp
83
+ - 'path': File path (for inline comments, None otherwise)
84
+ - 'line': Line number (for inline comments, None otherwise)
85
+ - 'in_reply_to_id': Parent comment ID if reply (None otherwise)
86
+
87
+ Raises:
88
+ Exception: If the API request fails.
89
+ """
90
+ pass
91
+
92
+ @abstractmethod
93
+ def get_pr_reviews(self, owner: str, repo: str, pr_number: int) -> list[dict]:
94
+ """Fetch all PR reviews.
95
+
96
+ Retrieves all reviews submitted on a PR, including approvals,
97
+ change requests, and comment-only reviews.
98
+
99
+ Args:
100
+ owner: Repository owner (organization or user name).
101
+ repo: Repository name.
102
+ pr_number: Pull request number.
103
+
104
+ Returns:
105
+ List of dictionaries, each containing:
106
+ - 'id': Unique review identifier
107
+ - 'user': Dictionary with 'login' key for reviewer
108
+ - 'body': Review body text (may be empty)
109
+ - 'state': Review state ('APPROVED', 'CHANGES_REQUESTED',
110
+ 'COMMENTED', 'DISMISSED', 'PENDING')
111
+ - 'submitted_at': ISO timestamp
112
+
113
+ Raises:
114
+ Exception: If the API request fails.
115
+ """
116
+ pass
117
+
118
+ @abstractmethod
119
+ def get_pr_threads(self, owner: str, repo: str, pr_number: int) -> list[dict]:
120
+ """Fetch all review threads with resolution status.
121
+
122
+ Retrieves all review threads on a PR using GitHub's GraphQL API,
123
+ which provides thread resolution status not available in REST API.
124
+
125
+ Args:
126
+ owner: Repository owner (organization or user name).
127
+ repo: Repository name.
128
+ pr_number: Pull request number.
129
+
130
+ Returns:
131
+ List of dictionaries, each containing:
132
+ - 'id': Thread node ID
133
+ - 'is_resolved': Boolean indicating if thread is resolved
134
+ - 'is_outdated': Boolean indicating if thread is outdated
135
+ (code has changed since comment)
136
+ - 'path': File path the thread is attached to
137
+ - 'line': Line number in the diff
138
+ - 'comments': List of comments in the thread
139
+
140
+ Raises:
141
+ Exception: If the API request fails.
142
+ """
143
+ pass
144
+
145
+ @abstractmethod
146
+ def get_ci_status(self, owner: str, repo: str, ref: str) -> dict:
147
+ """Fetch CI/CD check status for a commit.
148
+
149
+ Retrieves the combined status of all CI checks for a specific
150
+ commit reference (usually the head SHA of the PR).
151
+
152
+ Args:
153
+ owner: Repository owner (organization or user name).
154
+ repo: Repository name.
155
+ ref: Git reference (commit SHA, branch name, or tag).
156
+
157
+ Returns:
158
+ Dictionary containing:
159
+ - 'state': Overall state ('success', 'failure', 'pending')
160
+ - 'total_count': Total number of checks
161
+ - 'statuses': List of individual status checks
162
+ - 'check_runs': List of check runs (GitHub Actions, etc.)
163
+
164
+ Each status/check_run contains:
165
+ - 'name': Check name
166
+ - 'state' or 'status': Individual check state
167
+ - 'conclusion': Final conclusion (for completed checks)
168
+ - 'target_url' or 'html_url': Link to check details
169
+
170
+ Raises:
171
+ Exception: If the API request fails.
172
+ """
173
+ pass
174
+
175
+
176
+ class CachePort(ABC):
177
+ """Abstract interface for caching.
178
+
179
+ This port defines the contract for caching PR analysis data to reduce
180
+ API calls and improve response times. Implementations may use SQLite,
181
+ Redis, or in-memory storage.
182
+
183
+ All keys are strings constructed from validated inputs. Values are
184
+ serialized as strings (typically JSON). TTL (Time To Live) controls
185
+ automatic expiration.
186
+ """
187
+
188
+ @abstractmethod
189
+ def get(self, key: str) -> Optional[str]:
190
+ """Get cached value.
191
+
192
+ Retrieves a value from the cache if it exists and has not expired.
193
+
194
+ Args:
195
+ key: Cache key (e.g., 'pr:myorg:myrepo:123:meta').
196
+
197
+ Returns:
198
+ Cached value as string if found and not expired, None otherwise.
199
+ """
200
+ pass
201
+
202
+ @abstractmethod
203
+ def set(self, key: str, value: str, ttl_seconds: int) -> None:
204
+ """Set cached value with TTL.
205
+
206
+ Stores a value in the cache with an expiration time.
207
+
208
+ Args:
209
+ key: Cache key (e.g., 'pr:myorg:myrepo:123:meta').
210
+ value: Value to cache (typically JSON string).
211
+ ttl_seconds: Time to live in seconds. After this duration,
212
+ the entry is considered expired.
213
+ """
214
+ pass
215
+
216
+ @abstractmethod
217
+ def delete(self, key: str) -> None:
218
+ """Delete cached value.
219
+
220
+ Removes a specific entry from the cache.
221
+
222
+ Args:
223
+ key: Cache key to delete.
224
+ """
225
+ pass
226
+
227
+ @abstractmethod
228
+ def invalidate_pattern(self, pattern: str) -> None:
229
+ """Invalidate all keys matching pattern.
230
+
231
+ Removes all cache entries whose keys match the given pattern.
232
+ Pattern syntax depends on the implementation but typically
233
+ supports glob-style wildcards (e.g., 'pr:myorg:myrepo:123:*').
234
+
235
+ This is used to invalidate all cached data for a PR when
236
+ a new commit is detected.
237
+
238
+ Args:
239
+ pattern: Pattern to match keys against (e.g., 'pr:*:*:123:*').
240
+ """
241
+ pass
242
+
243
+ @abstractmethod
244
+ def cleanup_expired(self) -> None:
245
+ """Remove expired entries.
246
+
247
+ Deletes all entries whose TTL has expired. This should be called
248
+ periodically to prevent unbounded cache growth.
249
+
250
+ For some implementations (like Redis), this may be a no-op as
251
+ expiration is handled automatically.
252
+ """
253
+ pass
254
+
255
+ @abstractmethod
256
+ def get_stats(self) -> CacheStats:
257
+ """Get cache hit/miss statistics.
258
+
259
+ Returns metrics about cache performance for monitoring and
260
+ debugging purposes.
261
+
262
+ Returns:
263
+ CacheStats object containing:
264
+ - hits: Number of successful cache lookups
265
+ - misses: Number of cache misses
266
+ - hit_rate: Ratio of hits to total lookups (0.0 to 1.0)
267
+ """
268
+ pass
269
+
270
+
271
+ class TimeProvider(ABC):
272
+ """Abstract interface for time operations.
273
+
274
+ This port defines the contract for time-related operations, enabling
275
+ dependency injection of time for deterministic testing. Production
276
+ code uses real system time; tests use a controllable mock.
277
+
278
+ This pattern eliminates flaky tests caused by real time.sleep() calls
279
+ and non-deterministic time.time() values.
280
+ """
281
+
282
+ @abstractmethod
283
+ def now(self) -> float:
284
+ """Get current time as Unix timestamp.
285
+
286
+ Returns:
287
+ Current time as seconds since epoch (float).
288
+ """
289
+ pass
290
+
291
+ @abstractmethod
292
+ def now_int(self) -> int:
293
+ """Get current time as Unix timestamp (integer).
294
+
295
+ Returns:
296
+ Current time as seconds since epoch (int).
297
+ """
298
+ pass
299
+
300
+ @abstractmethod
301
+ def sleep(self, seconds: float) -> None:
302
+ """Sleep for the specified duration.
303
+
304
+ In production, this calls time.sleep().
305
+ In tests, this can advance simulated time instantly.
306
+
307
+ Args:
308
+ seconds: Duration to sleep in seconds.
309
+ """
310
+ pass
311
+
312
+
313
+ class ReviewerParser(ABC):
314
+ """Abstract interface for reviewer-specific parsing.
315
+
316
+ This port defines the contract for parsing comments from different
317
+ automated reviewers (CodeRabbit, Greptile, Claude Code, etc.).
318
+
319
+ Each parser knows how to:
320
+ 1. Identify comments from its reviewer (via author or body patterns)
321
+ 2. Classify comments as ACTIONABLE, NON_ACTIONABLE, or AMBIGUOUS
322
+ 3. Determine comment priority (CRITICAL, MAJOR, MINOR, TRIVIAL)
323
+
324
+ Parsers are registered in the container and selected based on
325
+ can_parse() returning True for a given comment.
326
+ """
327
+
328
+ @property
329
+ @abstractmethod
330
+ def reviewer_type(self) -> ReviewerType:
331
+ """Return the reviewer type this parser handles.
332
+
333
+ Returns:
334
+ ReviewerType enum value (e.g., ReviewerType.CODERABBIT,
335
+ ReviewerType.GREPTILE, ReviewerType.HUMAN).
336
+ """
337
+ pass
338
+
339
+ @abstractmethod
340
+ def can_parse(self, author: str, body: str) -> bool:
341
+ """Check if this parser can handle the comment.
342
+
343
+ Determines whether this parser is appropriate for a given comment
344
+ based on the author name and/or body content patterns.
345
+
346
+ Args:
347
+ author: Comment author's username/login.
348
+ body: Comment body text.
349
+
350
+ Returns:
351
+ True if this parser can handle the comment, False otherwise.
352
+
353
+ Note:
354
+ Multiple parsers may return True for the same comment.
355
+ The analyzer should use the first matching parser based
356
+ on parser priority.
357
+ """
358
+ pass
359
+
360
+ @abstractmethod
361
+ def parse(self, comment: dict) -> tuple[CommentClassification, Priority, bool]:
362
+ """Parse comment and return classification.
363
+
364
+ Analyzes the comment content and determines its classification,
365
+ priority, and whether it requires human investigation.
366
+
367
+ Args:
368
+ comment: Dictionary containing comment data with keys:
369
+ - 'body': Comment text content
370
+ - 'user': Dictionary with 'login' key
371
+ - 'id': Comment identifier
372
+ - Additional keys depending on comment type
373
+
374
+ Returns:
375
+ Tuple of (classification, priority, requires_investigation):
376
+ - classification: CommentClassification enum value
377
+ (ACTIONABLE, NON_ACTIONABLE, or AMBIGUOUS)
378
+ - priority: Priority enum value
379
+ (CRITICAL, MAJOR, MINOR, TRIVIAL, or UNKNOWN)
380
+ - requires_investigation: Boolean, True if the comment
381
+ could not be definitively classified and needs human
382
+ review (always True for AMBIGUOUS classification)
383
+
384
+ Note:
385
+ If classification is AMBIGUOUS, requires_investigation MUST
386
+ be True. Never silently skip ambiguous comments.
387
+ """
388
+ pass