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 ADDED
@@ -0,0 +1,66 @@
1
+ """GoodToMerge - Deterministic PR readiness detection for AI agents.
2
+
3
+ GoodToMerge is a Python library (with thin CLI wrapper) that answers the question:
4
+ "Is this PR ready to merge?" It provides deterministic, rule-based analysis of
5
+ CI status, comments, and review threads without requiring AI inference.
6
+
7
+ Usage:
8
+ from goodtogo import PRAnalyzer, Container, PRStatus
9
+
10
+ container = Container.create_default(github_token="ghp_...")
11
+ analyzer = PRAnalyzer(container)
12
+ result = analyzer.analyze(owner="myorg", repo="myrepo", pr_number=123)
13
+
14
+ if result.status == PRStatus.READY:
15
+ print("PR is ready to merge!")
16
+ else:
17
+ for item in result.action_items:
18
+ print(f"- {item}")
19
+
20
+ Exit Codes (CLI):
21
+ 0 - READY: All clear, PR ready to merge
22
+ 1 - ACTION_REQUIRED: Actionable comments need addressing
23
+ 2 - UNRESOLVED_THREADS: Unresolved review threads exist
24
+ 3 - CI_FAILING: CI/CD checks are failing or pending
25
+ 4 - ERROR: Error fetching data from GitHub
26
+
27
+ For more information, see: https://github.com/dsifry/goodtogo
28
+ """
29
+
30
+ from goodtogo.container import Container
31
+ from goodtogo.core.analyzer import PRAnalyzer
32
+ from goodtogo.core.models import (
33
+ CacheStats,
34
+ CICheck,
35
+ CIStatus,
36
+ Comment,
37
+ CommentClassification,
38
+ PRAnalysisResult,
39
+ Priority,
40
+ PRStatus,
41
+ ReviewerType,
42
+ ThreadSummary,
43
+ )
44
+
45
+ __version__ = "0.1.0"
46
+
47
+ __all__ = [
48
+ # Core classes
49
+ "PRAnalyzer",
50
+ "Container",
51
+ # Result models
52
+ "PRAnalysisResult",
53
+ "PRStatus",
54
+ # Comment models
55
+ "Comment",
56
+ "CommentClassification",
57
+ "Priority",
58
+ "ReviewerType",
59
+ # CI models
60
+ "CIStatus",
61
+ "CICheck",
62
+ # Thread models
63
+ "ThreadSummary",
64
+ # Cache models
65
+ "CacheStats",
66
+ ]
@@ -0,0 +1,22 @@
1
+ """Adapters for external services and persistence.
2
+
3
+ This module exports the concrete adapter implementations for the ports
4
+ defined in goodtogo.core.interfaces.
5
+ """
6
+
7
+ from goodtogo.adapters.agent_state import ActionType, AgentAction, AgentState
8
+ from goodtogo.adapters.cache_memory import InMemoryCacheAdapter
9
+ from goodtogo.adapters.cache_sqlite import SqliteCacheAdapter
10
+ from goodtogo.adapters.github import GitHubAdapter
11
+ from goodtogo.adapters.time_provider import MockTimeProvider, SystemTimeProvider
12
+
13
+ __all__ = [
14
+ "ActionType",
15
+ "AgentAction",
16
+ "AgentState",
17
+ "GitHubAdapter",
18
+ "InMemoryCacheAdapter",
19
+ "MockTimeProvider",
20
+ "SqliteCacheAdapter",
21
+ "SystemTimeProvider",
22
+ ]
@@ -0,0 +1,490 @@
1
+ """Agent state persistence for tracking workflow actions across sessions.
2
+
3
+ This module provides SQLite-based persistence for agent actions on PRs.
4
+ Unlike the cache which stores API responses with TTL expiration, agent state
5
+ persists indefinitely to track what an agent has done (comments responded to,
6
+ threads resolved, feedback addressed) so sessions can resume without
7
+ duplicating work.
8
+
9
+ Security features:
10
+ - Database file created with 0600 permissions (owner read/write only)
11
+ - Parent directory created with 0700 permissions (owner only)
12
+ - All inputs validated before use
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sqlite3
18
+ import stat
19
+ import warnings
20
+ from enum import Enum
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, NamedTuple, Optional
23
+
24
+ if TYPE_CHECKING:
25
+ from goodtogo.core.interfaces import TimeProvider
26
+
27
+
28
+ class ActionType(str, Enum):
29
+ """Types of actions an agent can perform on a PR."""
30
+
31
+ RESPONDED = "responded"
32
+ """Agent responded to a comment."""
33
+
34
+ RESOLVED = "resolved"
35
+ """Agent resolved a review thread."""
36
+
37
+ ADDRESSED = "addressed"
38
+ """Agent addressed feedback in a commit."""
39
+
40
+ DISMISSED = "dismissed"
41
+ """Agent determined comment is non-actionable and dismissed it."""
42
+
43
+
44
+ class AgentAction(NamedTuple):
45
+ """Record of an action taken by an agent.
46
+
47
+ Attributes:
48
+ pr_key: PR identifier in format "owner/repo:pr_number".
49
+ action_type: Type of action taken.
50
+ target_id: ID of the comment or thread acted upon.
51
+ result_id: ID of the response (comment ID or commit SHA).
52
+ created_at: Unix timestamp when the action was recorded.
53
+ """
54
+
55
+ pr_key: str
56
+ action_type: ActionType
57
+ target_id: str
58
+ result_id: Optional[str]
59
+ created_at: int
60
+
61
+
62
+ # SQL for schema creation
63
+ _CREATE_TABLES_SQL = """
64
+ CREATE TABLE IF NOT EXISTS agent_actions (
65
+ id INTEGER PRIMARY KEY,
66
+ pr_key TEXT NOT NULL,
67
+ action_type TEXT NOT NULL,
68
+ target_id TEXT NOT NULL,
69
+ result_id TEXT,
70
+ created_at INTEGER NOT NULL,
71
+ UNIQUE(pr_key, action_type, target_id)
72
+ );
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_agent_actions_pr_key ON agent_actions(pr_key);
75
+ CREATE INDEX IF NOT EXISTS idx_agent_actions_type ON agent_actions(pr_key, action_type);
76
+ """
77
+
78
+
79
+ class AgentState:
80
+ """SQLite-based persistence for agent workflow state.
81
+
82
+ Tracks what an agent has done on a PR across sessions, enabling
83
+ session resume without duplicating work. Actions recorded include:
84
+ - Comments responded to
85
+ - Threads resolved
86
+ - Feedback addressed in commits
87
+
88
+ Example:
89
+ >>> state = AgentState(".goodtogo/agent_state.db")
90
+ >>> state.mark_comment_responded("owner/repo:123", "comment_1", "reply_99")
91
+ >>> state.get_pending_comments("owner/repo:123", ["comment_1", "comment_2"])
92
+ ['comment_2']
93
+
94
+ Attributes:
95
+ db_path: Path to the SQLite database file.
96
+ """
97
+
98
+ def __init__(self, db_path: str, time_provider: Optional[TimeProvider] = None) -> None:
99
+ """Initialize the agent state store.
100
+
101
+ Creates the database file with secure permissions if it doesn't exist.
102
+ If the file exists with permissive permissions, they are tightened
103
+ and a warning is issued.
104
+
105
+ Args:
106
+ db_path: Path to the SQLite database file. Parent directories
107
+ will be created if they don't exist.
108
+ time_provider: Optional TimeProvider for time operations.
109
+ Defaults to SystemTimeProvider if not provided.
110
+
111
+ Raises:
112
+ OSError: If unable to create directory or set permissions.
113
+ """
114
+ from goodtogo.adapters.time_provider import SystemTimeProvider
115
+
116
+ self.db_path = db_path
117
+ self._connection: Optional[sqlite3.Connection] = None
118
+ self._time_provider = time_provider or SystemTimeProvider()
119
+ self._ensure_secure_path()
120
+ self._init_database()
121
+
122
+ def _ensure_secure_path(self) -> None:
123
+ """Ensure database directory and file have secure permissions.
124
+
125
+ Creates the directory with 0700 permissions and ensures the file
126
+ (if it exists) has 0600 permissions. Issues a warning if existing
127
+ permissions were too permissive.
128
+ """
129
+ path = Path(self.db_path)
130
+ db_dir = path.parent
131
+
132
+ # Create directory with secure permissions if needed
133
+ # Skip permission modification for current directory (db_path has no dir component)
134
+ if db_dir and db_dir != Path(".") and not db_dir.exists():
135
+ db_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
136
+ elif db_dir and db_dir != Path(".") and db_dir.exists(): # pragma: no branch
137
+ # Ensure existing directory has correct permissions
138
+ current_mode = stat.S_IMODE(db_dir.stat().st_mode)
139
+ if current_mode != 0o700:
140
+ db_dir.chmod(0o700)
141
+
142
+ # Check existing file permissions and fix if necessary
143
+ if path.exists():
144
+ current_mode = stat.S_IMODE(path.stat().st_mode)
145
+ # Check if group or others have any permissions
146
+ if current_mode & (stat.S_IRWXG | stat.S_IRWXO):
147
+ warnings.warn(
148
+ f"Agent state file {self.db_path} had permissive permissions "
149
+ f"({oct(current_mode)}). Fixing to 0600.",
150
+ UserWarning,
151
+ stacklevel=2,
152
+ )
153
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
154
+
155
+ def _init_database(self) -> None:
156
+ """Initialize the database schema.
157
+
158
+ Creates the agent_actions table if it doesn't exist.
159
+ Sets file permissions to 0600 after creation.
160
+ """
161
+ conn = self._get_connection()
162
+ conn.executescript(_CREATE_TABLES_SQL)
163
+ conn.commit()
164
+
165
+ # Ensure file has correct permissions after creation
166
+ path = Path(self.db_path)
167
+ if path.exists(): # pragma: no branch
168
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
169
+
170
+ def _get_connection(self) -> sqlite3.Connection:
171
+ """Get or create a database connection.
172
+
173
+ Returns:
174
+ Active SQLite connection with row factory set to sqlite3.Row.
175
+ """
176
+ if self._connection is None:
177
+ self._connection = sqlite3.connect(self.db_path)
178
+ self._connection.row_factory = sqlite3.Row
179
+ return self._connection
180
+
181
+ def mark_comment_responded(self, pr_key: str, comment_id: str, response_id: str) -> None:
182
+ """Record that the agent responded to a comment.
183
+
184
+ Args:
185
+ pr_key: PR identifier in format "owner/repo:pr_number".
186
+ comment_id: ID of the comment that was responded to.
187
+ response_id: ID of the response comment created.
188
+ """
189
+ self._record_action(pr_key, ActionType.RESPONDED, comment_id, response_id)
190
+
191
+ def mark_thread_resolved(self, pr_key: str, thread_id: str) -> None:
192
+ """Record that the agent resolved a review thread.
193
+
194
+ Args:
195
+ pr_key: PR identifier in format "owner/repo:pr_number".
196
+ thread_id: ID of the thread that was resolved.
197
+ """
198
+ self._record_action(pr_key, ActionType.RESOLVED, thread_id, None)
199
+
200
+ def mark_comment_addressed(self, pr_key: str, comment_id: str, commit_sha: str) -> None:
201
+ """Record that the agent addressed feedback in a commit.
202
+
203
+ Args:
204
+ pr_key: PR identifier in format "owner/repo:pr_number".
205
+ comment_id: ID of the comment with feedback that was addressed.
206
+ commit_sha: SHA of the commit that addressed the feedback.
207
+ """
208
+ self._record_action(pr_key, ActionType.ADDRESSED, comment_id, commit_sha)
209
+
210
+ def dismiss_comment(self, pr_key: str, comment_id: str, reason: Optional[str] = None) -> None:
211
+ """Record that a comment was investigated and determined non-actionable.
212
+
213
+ Use this when a comment has been evaluated and the agent determined
214
+ no action is needed. This persists the decision so future runs skip
215
+ re-evaluation.
216
+
217
+ Args:
218
+ pr_key: PR identifier in format "owner/repo:pr_number".
219
+ comment_id: ID of the comment being dismissed.
220
+ reason: Optional explanation for why the comment was dismissed.
221
+ """
222
+ self._record_action(pr_key, ActionType.DISMISSED, comment_id, reason)
223
+
224
+ def is_comment_dismissed(self, pr_key: str, comment_id: str) -> bool:
225
+ """Check if a comment has been dismissed.
226
+
227
+ Args:
228
+ pr_key: PR identifier in format "owner/repo:pr_number".
229
+ comment_id: ID of the comment to check.
230
+
231
+ Returns:
232
+ True if the comment was previously dismissed, False otherwise.
233
+ """
234
+ dismissed = self._get_acted_upon_targets(pr_key, ActionType.DISMISSED)
235
+ return comment_id in dismissed
236
+
237
+ def get_dismissed_comments(self, pr_key: str) -> list[str]:
238
+ """Get all comment IDs that have been dismissed.
239
+
240
+ Args:
241
+ pr_key: PR identifier in format "owner/repo:pr_number".
242
+
243
+ Returns:
244
+ List of comment IDs that have been dismissed.
245
+ """
246
+ dismissed = self._get_acted_upon_targets(pr_key, ActionType.DISMISSED)
247
+ return list(dismissed)
248
+
249
+ def _record_action(
250
+ self,
251
+ pr_key: str,
252
+ action_type: ActionType,
253
+ target_id: str,
254
+ result_id: Optional[str],
255
+ ) -> None:
256
+ """Record an agent action.
257
+
258
+ Uses INSERT OR REPLACE to handle duplicate actions (e.g., if the same
259
+ comment is responded to multiple times, the latest response is kept).
260
+
261
+ Args:
262
+ pr_key: PR identifier in format "owner/repo:pr_number".
263
+ action_type: Type of action taken.
264
+ target_id: ID of the comment or thread acted upon.
265
+ result_id: ID of the response (comment ID or commit SHA).
266
+ """
267
+ conn = self._get_connection()
268
+ current_time = self._time_provider.now_int()
269
+
270
+ # Use ON CONFLICT to preserve original created_at timestamp
271
+ # Only update result_id when re-recording the same action
272
+ conn.execute(
273
+ """
274
+ INSERT INTO agent_actions
275
+ (pr_key, action_type, target_id, result_id, created_at)
276
+ VALUES (?, ?, ?, ?, ?)
277
+ ON CONFLICT(pr_key, action_type, target_id) DO UPDATE SET
278
+ result_id = excluded.result_id
279
+ """,
280
+ (pr_key, action_type.value, target_id, result_id, current_time),
281
+ )
282
+ conn.commit()
283
+
284
+ def get_pending_comments(
285
+ self, pr_key: str, all_comment_ids: Optional[list[str]] = None
286
+ ) -> list[str]:
287
+ """Get comment IDs that haven't been handled.
288
+
289
+ Args:
290
+ pr_key: PR identifier in format "owner/repo:pr_number".
291
+ all_comment_ids: List of all current comment IDs on the PR.
292
+ If None, returns an empty list.
293
+
294
+ Returns:
295
+ List of comment IDs that have not been responded to, addressed,
296
+ or dismissed.
297
+ """
298
+ if all_comment_ids is None:
299
+ return []
300
+
301
+ responded = self._get_acted_upon_targets(pr_key, ActionType.RESPONDED)
302
+ addressed = self._get_acted_upon_targets(pr_key, ActionType.ADDRESSED)
303
+ dismissed = self._get_acted_upon_targets(pr_key, ActionType.DISMISSED)
304
+ handled = responded | addressed | dismissed
305
+
306
+ return [cid for cid in all_comment_ids if cid not in handled]
307
+
308
+ def get_pending_threads(
309
+ self, pr_key: str, all_thread_ids: Optional[list[str]] = None
310
+ ) -> list[str]:
311
+ """Get thread IDs that haven't been resolved.
312
+
313
+ Args:
314
+ pr_key: PR identifier in format "owner/repo:pr_number".
315
+ all_thread_ids: List of all current thread IDs on the PR.
316
+ If None, returns an empty list.
317
+
318
+ Returns:
319
+ List of thread IDs that have not been resolved.
320
+ """
321
+ if all_thread_ids is None:
322
+ return []
323
+
324
+ resolved = self._get_acted_upon_targets(pr_key, ActionType.RESOLVED)
325
+ return [tid for tid in all_thread_ids if tid not in resolved]
326
+
327
+ def _get_acted_upon_targets(self, pr_key: str, action_type: ActionType) -> set[str]:
328
+ """Get all target IDs that have been acted upon.
329
+
330
+ Args:
331
+ pr_key: PR identifier in format "owner/repo:pr_number".
332
+ action_type: Type of action to query.
333
+
334
+ Returns:
335
+ Set of target IDs (comment or thread) that have been acted upon.
336
+ """
337
+ conn = self._get_connection()
338
+ cursor = conn.execute(
339
+ """
340
+ SELECT target_id FROM agent_actions
341
+ WHERE pr_key = ? AND action_type = ?
342
+ """,
343
+ (pr_key, action_type.value),
344
+ )
345
+ return {row["target_id"] for row in cursor.fetchall()}
346
+
347
+ def get_responded_comments(self, pr_key: str) -> set[str]:
348
+ """Get all comment IDs that have been responded to.
349
+
350
+ Args:
351
+ pr_key: PR identifier in format "owner/repo:pr_number".
352
+
353
+ Returns:
354
+ Set of comment IDs that have been responded to.
355
+ """
356
+ return self._get_acted_upon_targets(pr_key, ActionType.RESPONDED)
357
+
358
+ def get_resolved_threads(self, pr_key: str) -> set[str]:
359
+ """Get all thread IDs that have been resolved.
360
+
361
+ Args:
362
+ pr_key: PR identifier in format "owner/repo:pr_number".
363
+
364
+ Returns:
365
+ Set of thread IDs that have been resolved.
366
+ """
367
+ return self._get_acted_upon_targets(pr_key, ActionType.RESOLVED)
368
+
369
+ def get_addressed_comments(self, pr_key: str) -> set[str]:
370
+ """Get all comment IDs that have been addressed in commits.
371
+
372
+ Args:
373
+ pr_key: PR identifier in format "owner/repo:pr_number".
374
+
375
+ Returns:
376
+ Set of comment IDs that have been addressed.
377
+ """
378
+ return self._get_acted_upon_targets(pr_key, ActionType.ADDRESSED)
379
+
380
+ def get_actions_for_pr(self, pr_key: str) -> list[AgentAction]:
381
+ """Get all actions recorded for a PR.
382
+
383
+ Args:
384
+ pr_key: PR identifier in format "owner/repo:pr_number".
385
+
386
+ Returns:
387
+ List of AgentAction records for the PR, ordered by created_at.
388
+ """
389
+ conn = self._get_connection()
390
+ cursor = conn.execute(
391
+ """
392
+ SELECT pr_key, action_type, target_id, result_id, created_at
393
+ FROM agent_actions
394
+ WHERE pr_key = ?
395
+ ORDER BY created_at
396
+ """,
397
+ (pr_key,),
398
+ )
399
+ return [
400
+ AgentAction(
401
+ pr_key=row["pr_key"],
402
+ action_type=ActionType(row["action_type"]),
403
+ target_id=row["target_id"],
404
+ result_id=row["result_id"],
405
+ created_at=row["created_at"],
406
+ )
407
+ for row in cursor.fetchall()
408
+ ]
409
+
410
+ def get_progress_summary(
411
+ self,
412
+ pr_key: str,
413
+ total_comments: int,
414
+ total_threads: int,
415
+ ) -> dict[str, int]:
416
+ """Get a progress summary for a PR.
417
+
418
+ Useful for reporting "X of Y comments addressed" style progress.
419
+
420
+ Args:
421
+ pr_key: PR identifier in format "owner/repo:pr_number".
422
+ total_comments: Total number of comments on the PR.
423
+ total_threads: Total number of threads on the PR.
424
+
425
+ Returns:
426
+ Dictionary with progress counts:
427
+ - comments_responded: Number of comments responded to
428
+ - comments_addressed: Number of comments addressed in commits
429
+ - comments_total: Total comments (echoed back)
430
+ - threads_resolved: Number of threads resolved
431
+ - threads_total: Total threads (echoed back)
432
+ """
433
+ conn = self._get_connection()
434
+
435
+ # Count distinct targets for each action type
436
+ cursor = conn.execute(
437
+ """
438
+ SELECT action_type, COUNT(DISTINCT target_id) as count
439
+ FROM agent_actions
440
+ WHERE pr_key = ?
441
+ GROUP BY action_type
442
+ """,
443
+ (pr_key,),
444
+ )
445
+ counts = {row["action_type"]: row["count"] for row in cursor.fetchall()}
446
+
447
+ return {
448
+ "comments_responded": counts.get(ActionType.RESPONDED.value, 0),
449
+ "comments_addressed": counts.get(ActionType.ADDRESSED.value, 0),
450
+ "comments_total": total_comments,
451
+ "threads_resolved": counts.get(ActionType.RESOLVED.value, 0),
452
+ "threads_total": total_threads,
453
+ }
454
+
455
+ def clear_pr_actions(self, pr_key: str) -> int:
456
+ """Clear all recorded actions for a PR.
457
+
458
+ Useful when starting fresh on a PR or when the PR is closed/merged.
459
+
460
+ Args:
461
+ pr_key: PR identifier in format "owner/repo:pr_number".
462
+
463
+ Returns:
464
+ Number of actions deleted.
465
+ """
466
+ conn = self._get_connection()
467
+ cursor = conn.execute(
468
+ "DELETE FROM agent_actions WHERE pr_key = ?",
469
+ (pr_key,),
470
+ )
471
+ conn.commit()
472
+ return cursor.rowcount
473
+
474
+ def close(self) -> None:
475
+ """Close the database connection.
476
+
477
+ Should be called when the state store is no longer needed to release
478
+ database resources.
479
+ """
480
+ if self._connection is not None:
481
+ self._connection.close()
482
+ self._connection = None
483
+
484
+ def __del__(self) -> None:
485
+ """Ensure connection is closed on garbage collection."""
486
+ self.close()
487
+
488
+ def __repr__(self) -> str:
489
+ """Return string representation of the state store."""
490
+ return f"AgentState(db_path={self.db_path!r})"