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
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})"
|