monoco-toolkit 0.3.10__py3-none-any.whl → 0.3.12__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.
- monoco/__main__.py +8 -0
- monoco/core/artifacts/__init__.py +16 -0
- monoco/core/artifacts/manager.py +575 -0
- monoco/core/artifacts/models.py +161 -0
- monoco/core/automation/__init__.py +51 -0
- monoco/core/automation/config.py +338 -0
- monoco/core/automation/field_watcher.py +296 -0
- monoco/core/automation/handlers.py +723 -0
- monoco/core/config.py +31 -4
- monoco/core/executor/__init__.py +38 -0
- monoco/core/executor/agent_action.py +254 -0
- monoco/core/executor/git_action.py +303 -0
- monoco/core/executor/im_action.py +309 -0
- monoco/core/executor/pytest_action.py +218 -0
- monoco/core/git.py +38 -0
- monoco/core/hooks/context.py +74 -13
- monoco/core/ingestion/__init__.py +20 -0
- monoco/core/ingestion/discovery.py +248 -0
- monoco/core/ingestion/watcher.py +343 -0
- monoco/core/ingestion/worker.py +436 -0
- monoco/core/loader.py +633 -0
- monoco/core/registry.py +34 -25
- monoco/core/router/__init__.py +55 -0
- monoco/core/router/action.py +341 -0
- monoco/core/router/router.py +392 -0
- monoco/core/scheduler/__init__.py +63 -0
- monoco/core/scheduler/base.py +152 -0
- monoco/core/scheduler/engines.py +175 -0
- monoco/core/scheduler/events.py +171 -0
- monoco/core/scheduler/local.py +377 -0
- monoco/core/skills.py +119 -80
- monoco/core/watcher/__init__.py +57 -0
- monoco/core/watcher/base.py +365 -0
- monoco/core/watcher/dropzone.py +152 -0
- monoco/core/watcher/issue.py +303 -0
- monoco/core/watcher/memo.py +200 -0
- monoco/core/watcher/task.py +238 -0
- monoco/daemon/app.py +77 -1
- monoco/daemon/commands.py +10 -0
- monoco/daemon/events.py +34 -0
- monoco/daemon/mailroom_service.py +196 -0
- monoco/daemon/models.py +1 -0
- monoco/daemon/scheduler.py +207 -0
- monoco/daemon/services.py +27 -58
- monoco/daemon/triggers.py +55 -0
- monoco/features/agent/__init__.py +25 -7
- monoco/features/agent/adapter.py +17 -7
- monoco/features/agent/cli.py +91 -57
- monoco/features/agent/engines.py +31 -170
- monoco/{core/resources/en/skills/monoco_core → features/agent/resources/en/skills/monoco_atom_core}/SKILL.md +2 -2
- monoco/features/agent/resources/en/skills/{flow_engineer → monoco_workflow_agent_engineer}/SKILL.md +2 -2
- monoco/features/agent/resources/en/skills/{flow_manager → monoco_workflow_agent_manager}/SKILL.md +2 -2
- monoco/features/agent/resources/en/skills/{flow_planner → monoco_workflow_agent_planner}/SKILL.md +2 -2
- monoco/features/agent/resources/en/skills/{flow_reviewer → monoco_workflow_agent_reviewer}/SKILL.md +2 -2
- monoco/features/agent/resources/{roles/role-engineer.yaml → zh/roles/monoco_role_engineer.yaml} +3 -3
- monoco/features/agent/resources/{roles/role-manager.yaml → zh/roles/monoco_role_manager.yaml} +8 -8
- monoco/features/agent/resources/{roles/role-planner.yaml → zh/roles/monoco_role_planner.yaml} +8 -8
- monoco/features/agent/resources/{roles/role-reviewer.yaml → zh/roles/monoco_role_reviewer.yaml} +8 -8
- monoco/{core/resources/zh/skills/monoco_core → features/agent/resources/zh/skills/monoco_atom_core}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/{flow_engineer → monoco_workflow_agent_engineer}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/{flow_manager → monoco_workflow_agent_manager}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/{flow_planner → monoco_workflow_agent_planner}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/{flow_reviewer → monoco_workflow_agent_reviewer}/SKILL.md +2 -2
- monoco/features/agent/worker.py +1 -1
- monoco/features/artifact/__init__.py +0 -0
- monoco/features/artifact/adapter.py +33 -0
- monoco/features/artifact/resources/zh/AGENTS.md +14 -0
- monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +278 -0
- monoco/features/glossary/adapter.py +18 -7
- monoco/features/glossary/resources/en/skills/{monoco_glossary → monoco_atom_glossary}/SKILL.md +2 -2
- monoco/features/glossary/resources/zh/skills/{monoco_glossary → monoco_atom_glossary}/SKILL.md +2 -2
- monoco/features/hooks/__init__.py +11 -0
- monoco/features/hooks/adapter.py +67 -0
- monoco/features/hooks/commands.py +309 -0
- monoco/features/hooks/core.py +441 -0
- monoco/features/hooks/resources/ADDING_HOOKS.md +234 -0
- monoco/features/i18n/adapter.py +18 -5
- monoco/features/i18n/core.py +482 -17
- monoco/features/i18n/resources/en/skills/{monoco_i18n → monoco_atom_i18n}/SKILL.md +2 -2
- monoco/features/i18n/resources/en/skills/{i18n_scan_workflow → monoco_workflow_i18n_scan}/SKILL.md +2 -2
- monoco/features/i18n/resources/zh/skills/{monoco_i18n → monoco_atom_i18n}/SKILL.md +2 -2
- monoco/features/i18n/resources/zh/skills/{i18n_scan_workflow → monoco_workflow_i18n_scan}/SKILL.md +2 -2
- monoco/features/issue/adapter.py +19 -6
- monoco/features/issue/commands.py +352 -20
- monoco/features/issue/core.py +475 -16
- monoco/features/issue/engine/machine.py +114 -4
- monoco/features/issue/linter.py +60 -5
- monoco/features/issue/models.py +2 -2
- monoco/features/issue/resources/en/AGENTS.md +109 -0
- monoco/features/issue/resources/en/skills/{monoco_issue → monoco_atom_issue}/SKILL.md +2 -2
- monoco/features/issue/resources/en/skills/{issue_create_workflow → monoco_workflow_issue_creation}/SKILL.md +2 -2
- monoco/features/issue/resources/en/skills/{issue_develop_workflow → monoco_workflow_issue_development}/SKILL.md +2 -2
- monoco/features/issue/resources/en/skills/{issue_lifecycle_workflow → monoco_workflow_issue_management}/SKILL.md +2 -2
- monoco/features/issue/resources/en/skills/{issue_refine_workflow → monoco_workflow_issue_refinement}/SKILL.md +2 -2
- monoco/features/issue/resources/hooks/post-checkout.sh +39 -0
- monoco/features/issue/resources/hooks/pre-commit.sh +41 -0
- monoco/features/issue/resources/hooks/pre-push.sh +35 -0
- monoco/features/issue/resources/zh/AGENTS.md +109 -0
- monoco/features/issue/resources/zh/skills/{monoco_issue → monoco_atom_issue_lifecycle}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/{issue_create_workflow → monoco_workflow_issue_creation}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/{issue_develop_workflow → monoco_workflow_issue_development}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/{issue_lifecycle_workflow → monoco_workflow_issue_management}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/{issue_refine_workflow → monoco_workflow_issue_refinement}/SKILL.md +2 -2
- monoco/features/issue/validator.py +101 -1
- monoco/features/memo/adapter.py +21 -8
- monoco/features/memo/cli.py +103 -10
- monoco/features/memo/core.py +178 -92
- monoco/features/memo/models.py +53 -0
- monoco/features/memo/resources/en/skills/{monoco_memo → monoco_atom_memo}/SKILL.md +2 -2
- monoco/features/memo/resources/en/skills/{note_processing_workflow → monoco_workflow_note_processing}/SKILL.md +2 -2
- monoco/features/memo/resources/zh/skills/{monoco_memo → monoco_atom_memo}/SKILL.md +2 -2
- monoco/features/memo/resources/zh/skills/{note_processing_workflow → monoco_workflow_note_processing}/SKILL.md +2 -2
- monoco/features/spike/adapter.py +18 -5
- monoco/features/spike/commands.py +5 -3
- monoco/features/spike/resources/en/skills/{monoco_spike → monoco_atom_spike}/SKILL.md +2 -2
- monoco/features/spike/resources/en/skills/{research_workflow → monoco_workflow_research}/SKILL.md +2 -2
- monoco/features/spike/resources/zh/skills/{monoco_spike → monoco_atom_spike}/SKILL.md +2 -2
- monoco/features/spike/resources/zh/skills/{research_workflow → monoco_workflow_research}/SKILL.md +2 -2
- monoco/main.py +38 -1
- {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/METADATA +7 -1
- monoco_toolkit-0.3.12.dist-info/RECORD +202 -0
- monoco/features/agent/apoptosis.py +0 -44
- monoco/features/agent/manager.py +0 -91
- monoco/features/agent/session.py +0 -121
- monoco_toolkit-0.3.10.dist-info/RECORD +0 -156
- /monoco/{core → features/agent}/resources/en/AGENTS.md +0 -0
- /monoco/{core → features/agent}/resources/zh/AGENTS.md +0 -0
- {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SendIMAction - Action for sending notifications.
|
|
3
|
+
|
|
4
|
+
Part of Layer 3 (Action Executor) in the event automation framework.
|
|
5
|
+
Provides action for sending IM/webhook notifications.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from monoco.core.scheduler import AgentEvent
|
|
16
|
+
from monoco.core.router import Action, ActionResult
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotificationResult:
|
|
22
|
+
"""Result of a notification send."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
success: bool,
|
|
27
|
+
message: str,
|
|
28
|
+
response: Optional[Any] = None,
|
|
29
|
+
):
|
|
30
|
+
self.success = success
|
|
31
|
+
self.message = message
|
|
32
|
+
self.response = response
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SendIMAction(Action):
|
|
36
|
+
"""
|
|
37
|
+
Action that sends notifications via IM or webhook.
|
|
38
|
+
|
|
39
|
+
This action sends notifications to various channels:
|
|
40
|
+
- Webhook (HTTP POST)
|
|
41
|
+
- Console (stdout)
|
|
42
|
+
- File (append to log file)
|
|
43
|
+
|
|
44
|
+
Future: Slack, Discord, Email, etc.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> action = SendIMAction(
|
|
48
|
+
... channel="webhook",
|
|
49
|
+
... webhook_url="https://hooks.example.com/notify",
|
|
50
|
+
... message_template="Issue {issue_id} updated to {new_stage}",
|
|
51
|
+
... )
|
|
52
|
+
>>> result = await action(event)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
channel: str = "console",
|
|
58
|
+
message_template: str = "{event_type}: {payload}",
|
|
59
|
+
webhook_url: Optional[str] = None,
|
|
60
|
+
webhook_headers: Optional[Dict[str, str]] = None,
|
|
61
|
+
log_file: Optional[str] = None,
|
|
62
|
+
timeout: int = 30,
|
|
63
|
+
config: Optional[Dict[str, Any]] = None,
|
|
64
|
+
):
|
|
65
|
+
super().__init__(config)
|
|
66
|
+
self.channel = channel
|
|
67
|
+
self.message_template = message_template
|
|
68
|
+
self.webhook_url = webhook_url
|
|
69
|
+
self.webhook_headers = webhook_headers or {}
|
|
70
|
+
self.log_file = log_file
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
self._last_result: Optional[NotificationResult] = None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def name(self) -> str:
|
|
76
|
+
return f"SendIMAction({self.channel})"
|
|
77
|
+
|
|
78
|
+
async def can_execute(self, event: AgentEvent) -> bool:
|
|
79
|
+
"""Check if the channel is available."""
|
|
80
|
+
if self.channel == "webhook":
|
|
81
|
+
return self.webhook_url is not None
|
|
82
|
+
elif self.channel == "file":
|
|
83
|
+
return self.log_file is not None
|
|
84
|
+
elif self.channel == "console":
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
async def execute(self, event: AgentEvent) -> ActionResult:
|
|
89
|
+
"""Send notification."""
|
|
90
|
+
# Format message
|
|
91
|
+
message = self._format_message(event)
|
|
92
|
+
|
|
93
|
+
logger.debug(f"Sending {self.channel} notification: {message[:100]}...")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
if self.channel == "webhook":
|
|
97
|
+
result = await self._send_webhook(message, event)
|
|
98
|
+
elif self.channel == "file":
|
|
99
|
+
result = await self._write_to_file(message)
|
|
100
|
+
else: # console
|
|
101
|
+
result = await self._send_console(message)
|
|
102
|
+
|
|
103
|
+
self._last_result = result
|
|
104
|
+
|
|
105
|
+
if result.success:
|
|
106
|
+
return ActionResult.success_result(
|
|
107
|
+
output={
|
|
108
|
+
"channel": self.channel,
|
|
109
|
+
"message_sent": True,
|
|
110
|
+
},
|
|
111
|
+
metadata={
|
|
112
|
+
"message_preview": message[:200],
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
return ActionResult.failure_result(
|
|
117
|
+
error=result.message,
|
|
118
|
+
metadata={
|
|
119
|
+
"channel": self.channel,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.error(f"Failed to send notification: {e}")
|
|
125
|
+
return ActionResult.failure_result(error=str(e))
|
|
126
|
+
|
|
127
|
+
def _format_message(self, event: AgentEvent) -> str:
|
|
128
|
+
"""Format notification message with event data."""
|
|
129
|
+
try:
|
|
130
|
+
return self.message_template.format(
|
|
131
|
+
event_type=event.type.value,
|
|
132
|
+
timestamp=event.timestamp.isoformat(),
|
|
133
|
+
source=event.source or "unknown",
|
|
134
|
+
**event.payload,
|
|
135
|
+
)
|
|
136
|
+
except (KeyError, ValueError) as e:
|
|
137
|
+
# If formatting fails, return a simple message
|
|
138
|
+
return f"Event: {event.type.value} at {event.timestamp.isoformat()}"
|
|
139
|
+
|
|
140
|
+
async def _send_webhook(
|
|
141
|
+
self,
|
|
142
|
+
message: str,
|
|
143
|
+
event: AgentEvent,
|
|
144
|
+
) -> NotificationResult:
|
|
145
|
+
"""Send notification via webhook."""
|
|
146
|
+
try:
|
|
147
|
+
import aiohttp
|
|
148
|
+
except ImportError:
|
|
149
|
+
# Fallback to sync requests
|
|
150
|
+
return await self._send_webhook_sync(message, event)
|
|
151
|
+
|
|
152
|
+
payload = {
|
|
153
|
+
"message": message,
|
|
154
|
+
"event_type": event.type.value,
|
|
155
|
+
"timestamp": event.timestamp.isoformat(),
|
|
156
|
+
"source": event.source,
|
|
157
|
+
"payload": event.payload,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
headers = {
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
**self.webhook_headers,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
async with aiohttp.ClientSession() as session:
|
|
167
|
+
async with session.post(
|
|
168
|
+
self.webhook_url,
|
|
169
|
+
json=payload,
|
|
170
|
+
headers=headers,
|
|
171
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
172
|
+
) as response:
|
|
173
|
+
if response.status < 400:
|
|
174
|
+
return NotificationResult(
|
|
175
|
+
success=True,
|
|
176
|
+
message=f"Webhook sent: HTTP {response.status}",
|
|
177
|
+
response={
|
|
178
|
+
"status": response.status,
|
|
179
|
+
"body": await response.text(),
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
return NotificationResult(
|
|
184
|
+
success=False,
|
|
185
|
+
message=f"Webhook failed: HTTP {response.status}",
|
|
186
|
+
response={"status": response.status},
|
|
187
|
+
)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
return NotificationResult(
|
|
190
|
+
success=False,
|
|
191
|
+
message=f"Webhook error: {str(e)}",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def _send_webhook_sync(
|
|
195
|
+
self,
|
|
196
|
+
message: str,
|
|
197
|
+
event: AgentEvent,
|
|
198
|
+
) -> NotificationResult:
|
|
199
|
+
"""Send webhook using sync requests (fallback)."""
|
|
200
|
+
try:
|
|
201
|
+
import requests
|
|
202
|
+
except ImportError:
|
|
203
|
+
return NotificationResult(
|
|
204
|
+
success=False,
|
|
205
|
+
message="Neither aiohttp nor requests available for webhook",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
payload = {
|
|
209
|
+
"message": message,
|
|
210
|
+
"event_type": event.type.value,
|
|
211
|
+
"timestamp": event.timestamp.isoformat(),
|
|
212
|
+
"source": event.source,
|
|
213
|
+
"payload": event.payload,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
headers = {
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
**self.webhook_headers,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
response = requests.post(
|
|
223
|
+
self.webhook_url,
|
|
224
|
+
json=payload,
|
|
225
|
+
headers=headers,
|
|
226
|
+
timeout=self.timeout,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if response.status_code < 400:
|
|
230
|
+
return NotificationResult(
|
|
231
|
+
success=True,
|
|
232
|
+
message=f"Webhook sent: HTTP {response.status_code}",
|
|
233
|
+
response={
|
|
234
|
+
"status": response.status_code,
|
|
235
|
+
"body": response.text,
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
return NotificationResult(
|
|
240
|
+
success=False,
|
|
241
|
+
message=f"Webhook failed: HTTP {response.status_code}",
|
|
242
|
+
)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
return NotificationResult(
|
|
245
|
+
success=False,
|
|
246
|
+
message=f"Webhook error: {str(e)}",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
async def _write_to_file(self, message: str) -> NotificationResult:
|
|
250
|
+
"""Write notification to log file."""
|
|
251
|
+
try:
|
|
252
|
+
import aiofiles
|
|
253
|
+
except ImportError:
|
|
254
|
+
# Fallback to sync file write
|
|
255
|
+
return await self._write_to_file_sync(message)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
timestamp = asyncio.get_event_loop().time()
|
|
259
|
+
log_line = f"[{timestamp}] {message}\n"
|
|
260
|
+
|
|
261
|
+
async with aiofiles.open(self.log_file, "a") as f:
|
|
262
|
+
await f.write(log_line)
|
|
263
|
+
|
|
264
|
+
return NotificationResult(
|
|
265
|
+
success=True,
|
|
266
|
+
message=f"Written to {self.log_file}",
|
|
267
|
+
)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
return NotificationResult(
|
|
270
|
+
success=False,
|
|
271
|
+
message=f"File write error: {str(e)}",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
async def _write_to_file_sync(self, message: str) -> NotificationResult:
|
|
275
|
+
"""Write to file synchronously (fallback)."""
|
|
276
|
+
try:
|
|
277
|
+
import time
|
|
278
|
+
log_line = f"[{time.time()}] {message}\n"
|
|
279
|
+
|
|
280
|
+
with open(self.log_file, "a") as f:
|
|
281
|
+
f.write(log_line)
|
|
282
|
+
|
|
283
|
+
return NotificationResult(
|
|
284
|
+
success=True,
|
|
285
|
+
message=f"Written to {self.log_file}",
|
|
286
|
+
)
|
|
287
|
+
except Exception as e:
|
|
288
|
+
return NotificationResult(
|
|
289
|
+
success=False,
|
|
290
|
+
message=f"File write error: {str(e)}",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def _send_console(self, message: str) -> NotificationResult:
|
|
294
|
+
"""Print notification to console."""
|
|
295
|
+
print(f"[NOTIFICATION] {message}")
|
|
296
|
+
return NotificationResult(
|
|
297
|
+
success=True,
|
|
298
|
+
message="Printed to console",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
302
|
+
"""Get action statistics."""
|
|
303
|
+
stats = super().get_stats()
|
|
304
|
+
stats.update({
|
|
305
|
+
"channel": self.channel,
|
|
306
|
+
"webhook_configured": self.webhook_url is not None,
|
|
307
|
+
"log_file": self.log_file,
|
|
308
|
+
})
|
|
309
|
+
return stats
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RunPytestAction - Action for running pytest tests.
|
|
3
|
+
|
|
4
|
+
Part of Layer 3 (Action Executor) in the event automation framework.
|
|
5
|
+
Executes pytest and parses results.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from monoco.core.scheduler import AgentEvent
|
|
18
|
+
from monoco.core.router import Action, ActionResult
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PytestResult:
|
|
24
|
+
"""Result of a pytest execution."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
returncode: int,
|
|
29
|
+
stdout: str,
|
|
30
|
+
stderr: str,
|
|
31
|
+
summary: Optional[Dict[str, Any]] = None,
|
|
32
|
+
):
|
|
33
|
+
self.returncode = returncode
|
|
34
|
+
self.stdout = stdout
|
|
35
|
+
self.stderr = stderr
|
|
36
|
+
self.summary = summary or {}
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def passed(self) -> bool:
|
|
40
|
+
return self.returncode == 0
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def failed_count(self) -> int:
|
|
44
|
+
return self.summary.get("failed", 0)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def passed_count(self) -> int:
|
|
48
|
+
return self.summary.get("passed", 0)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def total_count(self) -> int:
|
|
52
|
+
return self.summary.get("total", 0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RunPytestAction(Action):
|
|
56
|
+
"""
|
|
57
|
+
Action that runs pytest tests.
|
|
58
|
+
|
|
59
|
+
This action executes pytest with configurable options and
|
|
60
|
+
parses the results for downstream processing.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
>>> action = RunPytestAction(
|
|
64
|
+
... test_path="tests/",
|
|
65
|
+
... markers=["unit"],
|
|
66
|
+
... cov=True,
|
|
67
|
+
... )
|
|
68
|
+
>>> result = await action(event)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
test_path: Optional[str] = None,
|
|
74
|
+
markers: Optional[List[str]] = None,
|
|
75
|
+
cov: bool = False,
|
|
76
|
+
cov_report: Optional[str] = None,
|
|
77
|
+
verbose: bool = True,
|
|
78
|
+
timeout: int = 300,
|
|
79
|
+
config: Optional[Dict[str, Any]] = None,
|
|
80
|
+
):
|
|
81
|
+
super().__init__(config)
|
|
82
|
+
self.test_path = test_path or "."
|
|
83
|
+
self.markers = markers or []
|
|
84
|
+
self.cov = cov
|
|
85
|
+
self.cov_report = cov_report
|
|
86
|
+
self.verbose = verbose
|
|
87
|
+
self.timeout = timeout
|
|
88
|
+
self._last_result: Optional[PytestResult] = None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def name(self) -> str:
|
|
92
|
+
return "RunPytestAction"
|
|
93
|
+
|
|
94
|
+
async def can_execute(self, event: AgentEvent) -> bool:
|
|
95
|
+
"""Always can execute (no preconditions)."""
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
async def execute(self, event: AgentEvent) -> ActionResult:
|
|
99
|
+
"""Run pytest tests."""
|
|
100
|
+
logger.info(f"Running pytest for {self.test_path}")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = await self._run_pytest()
|
|
104
|
+
self._last_result = result
|
|
105
|
+
|
|
106
|
+
if result.passed:
|
|
107
|
+
return ActionResult.success_result(
|
|
108
|
+
output={
|
|
109
|
+
"passed": result.passed_count,
|
|
110
|
+
"failed": result.failed_count,
|
|
111
|
+
"total": result.total_count,
|
|
112
|
+
},
|
|
113
|
+
metadata={
|
|
114
|
+
"stdout_preview": result.stdout[:500] if result.stdout else None,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
return ActionResult.failure_result(
|
|
119
|
+
error=f"Tests failed: {result.failed_count} failures",
|
|
120
|
+
metadata={
|
|
121
|
+
"passed": result.passed_count,
|
|
122
|
+
"failed": result.failed_count,
|
|
123
|
+
"total": result.total_count,
|
|
124
|
+
"stderr_preview": result.stderr[:500] if result.stderr else None,
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error(f"Pytest execution failed: {e}")
|
|
130
|
+
return ActionResult.failure_result(error=str(e))
|
|
131
|
+
|
|
132
|
+
async def _run_pytest(self) -> PytestResult:
|
|
133
|
+
"""Execute pytest subprocess."""
|
|
134
|
+
cmd = ["python", "-m", "pytest"]
|
|
135
|
+
|
|
136
|
+
# Add test path
|
|
137
|
+
cmd.append(self.test_path)
|
|
138
|
+
|
|
139
|
+
# Add markers
|
|
140
|
+
if self.markers:
|
|
141
|
+
marker_expr = " and ".join(self.markers)
|
|
142
|
+
cmd.extend(["-m", marker_expr])
|
|
143
|
+
|
|
144
|
+
# Add coverage
|
|
145
|
+
if self.cov:
|
|
146
|
+
cmd.append("--cov")
|
|
147
|
+
if self.cov_report:
|
|
148
|
+
cmd.extend(["--cov-report", self.cov_report])
|
|
149
|
+
|
|
150
|
+
# Add verbosity
|
|
151
|
+
if self.verbose:
|
|
152
|
+
cmd.append("-v")
|
|
153
|
+
|
|
154
|
+
# Add JSON report for parsing
|
|
155
|
+
cmd.extend(["--tb=short", "-q"])
|
|
156
|
+
|
|
157
|
+
logger.debug(f"Running command: {' '.join(cmd)}")
|
|
158
|
+
|
|
159
|
+
# Run subprocess
|
|
160
|
+
process = await asyncio.create_subprocess_exec(
|
|
161
|
+
*cmd,
|
|
162
|
+
stdout=asyncio.subprocess.PIPE,
|
|
163
|
+
stderr=asyncio.subprocess.PIPE,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
stdout, stderr = await asyncio.wait_for(
|
|
168
|
+
process.communicate(),
|
|
169
|
+
timeout=self.timeout,
|
|
170
|
+
)
|
|
171
|
+
except asyncio.TimeoutError:
|
|
172
|
+
process.kill()
|
|
173
|
+
raise RuntimeError(f"Pytest timed out after {self.timeout}s")
|
|
174
|
+
|
|
175
|
+
stdout_str = stdout.decode("utf-8", errors="replace")
|
|
176
|
+
stderr_str = stderr.decode("utf-8", errors="replace")
|
|
177
|
+
|
|
178
|
+
# Parse summary
|
|
179
|
+
summary = self._parse_summary(stdout_str)
|
|
180
|
+
|
|
181
|
+
return PytestResult(
|
|
182
|
+
returncode=process.returncode,
|
|
183
|
+
stdout=stdout_str,
|
|
184
|
+
stderr=stderr_str,
|
|
185
|
+
summary=summary,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _parse_summary(self, output: str) -> Dict[str, int]:
|
|
189
|
+
"""Parse pytest summary from output."""
|
|
190
|
+
summary = {"passed": 0, "failed": 0, "error": 0, "skipped": 0, "total": 0}
|
|
191
|
+
|
|
192
|
+
# Look for summary line like "5 passed, 2 failed in 0.5s"
|
|
193
|
+
import re
|
|
194
|
+
pattern = r"(\d+)\s+(passed|failed|error|skipped)"
|
|
195
|
+
matches = re.findall(pattern, output)
|
|
196
|
+
|
|
197
|
+
for count, status in matches:
|
|
198
|
+
summary[status] = int(count)
|
|
199
|
+
summary["total"] += int(count)
|
|
200
|
+
|
|
201
|
+
return summary
|
|
202
|
+
|
|
203
|
+
def get_last_result(self) -> Optional[PytestResult]:
|
|
204
|
+
"""Get the last pytest result."""
|
|
205
|
+
return self._last_result
|
|
206
|
+
|
|
207
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
208
|
+
"""Get action statistics."""
|
|
209
|
+
stats = super().get_stats()
|
|
210
|
+
stats.update({
|
|
211
|
+
"test_path": self.test_path,
|
|
212
|
+
"markers": self.markers,
|
|
213
|
+
"last_result": {
|
|
214
|
+
"passed": self._last_result.passed if self._last_result else None,
|
|
215
|
+
"failed": self._last_result.failed_count if self._last_result else None,
|
|
216
|
+
} if self._last_result else None,
|
|
217
|
+
})
|
|
218
|
+
return stats
|
monoco/core/git.py
CHANGED
|
@@ -149,6 +149,29 @@ def delete_branch(path: Path, branch_name: str, force: bool = False):
|
|
|
149
149
|
raise RuntimeError(f"Failed to delete branch {branch_name}: {stderr}")
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
def get_merge_base(path: Path, ref1: str, ref2: str) -> str:
|
|
153
|
+
code, stdout, stderr = _run_git(["merge-base", ref1, ref2], path)
|
|
154
|
+
if code != 0:
|
|
155
|
+
raise RuntimeError(f"Failed to find merge base: {stderr}")
|
|
156
|
+
return stdout.strip()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def git_checkout_files(path: Path, ref: str, files: List[str]):
|
|
160
|
+
if not files:
|
|
161
|
+
return
|
|
162
|
+
code, _, stderr = _run_git(["checkout", ref, "--"] + files, path)
|
|
163
|
+
if code != 0:
|
|
164
|
+
raise RuntimeError(f"Failed to checkout files from {ref}: {stderr}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def has_diff(path: Path, ref1: str, ref2: str, files: List[str]) -> bool:
|
|
168
|
+
"""Check if there are differences between two refs for specific files."""
|
|
169
|
+
if not files:
|
|
170
|
+
return False
|
|
171
|
+
code, stdout, _ = _run_git(["diff", "--name-only", ref1, ref2, "--"] + files, path)
|
|
172
|
+
return code == 0 and bool(stdout.strip())
|
|
173
|
+
|
|
174
|
+
|
|
152
175
|
def get_worktrees(path: Path) -> List[Tuple[str, str, str]]:
|
|
153
176
|
"""Returns list of (path, head, branch)"""
|
|
154
177
|
code, stdout, stderr = _run_git(["worktree", "list", "--porcelain"], path)
|
|
@@ -207,6 +230,21 @@ def worktree_remove(path: Path, worktree_path: Path, force: bool = False):
|
|
|
207
230
|
raise RuntimeError(f"Failed to remove worktree: {stderr}")
|
|
208
231
|
|
|
209
232
|
|
|
233
|
+
def get_current_head(path: Path) -> str:
|
|
234
|
+
"""Get the current HEAD commit hash."""
|
|
235
|
+
code, stdout, stderr = _run_git(["rev-parse", "HEAD"], path)
|
|
236
|
+
if code != 0:
|
|
237
|
+
raise RuntimeError(f"Failed to get current HEAD: {stderr}")
|
|
238
|
+
return stdout.strip()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def git_reset_hard(path: Path, ref: str):
|
|
242
|
+
"""Reset the repository to a specific ref using --hard."""
|
|
243
|
+
code, _, stderr = _run_git(["reset", "--hard", ref], path)
|
|
244
|
+
if code != 0:
|
|
245
|
+
raise RuntimeError(f"Git reset --hard to {ref} failed: {stderr}")
|
|
246
|
+
|
|
247
|
+
|
|
210
248
|
class GitMonitor:
|
|
211
249
|
"""
|
|
212
250
|
Polls the Git repository for HEAD changes and triggers updates.
|
monoco/core/hooks/context.py
CHANGED
|
@@ -74,29 +74,90 @@ class HookContext:
|
|
|
74
74
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
75
75
|
|
|
76
76
|
@classmethod
|
|
77
|
-
def
|
|
77
|
+
def from_agent_task(
|
|
78
78
|
cls,
|
|
79
|
-
|
|
79
|
+
task: Any,
|
|
80
80
|
project_root: Optional[Path] = None,
|
|
81
81
|
) -> "HookContext":
|
|
82
82
|
"""
|
|
83
|
-
Create a HookContext from
|
|
83
|
+
Create a HookContext from an AgentTask.
|
|
84
84
|
|
|
85
85
|
Args:
|
|
86
|
-
|
|
86
|
+
task: The AgentTask object
|
|
87
87
|
project_root: Optional project root path
|
|
88
88
|
|
|
89
89
|
Returns:
|
|
90
90
|
A populated HookContext
|
|
91
91
|
"""
|
|
92
|
-
|
|
92
|
+
# Build IssueInfo if we have an issue_id
|
|
93
|
+
issue_info = None
|
|
94
|
+
issue_id = getattr(task, "issue_id", None)
|
|
95
|
+
if issue_id:
|
|
96
|
+
issue_info = IssueInfo(
|
|
97
|
+
id=issue_id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Try to load full issue metadata
|
|
101
|
+
try:
|
|
102
|
+
from monoco.features.issue.core import find_issue_path, parse_issue
|
|
103
|
+
from monoco.core.config import find_monoco_root
|
|
104
|
+
|
|
105
|
+
if project_root is None:
|
|
106
|
+
project_root = find_monoco_root()
|
|
107
|
+
|
|
108
|
+
issues_root = project_root / "Issues"
|
|
109
|
+
issue_path = find_issue_path(issues_root, issue_id)
|
|
110
|
+
if issue_path:
|
|
111
|
+
metadata = parse_issue(issue_path)
|
|
112
|
+
if metadata:
|
|
113
|
+
issue_info = IssueInfo.from_metadata(metadata)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass # Use basic issue info
|
|
93
116
|
|
|
117
|
+
# Build GitInfo
|
|
118
|
+
git_info = None
|
|
119
|
+
if project_root:
|
|
120
|
+
git_info = GitInfo(project_root=project_root)
|
|
121
|
+
|
|
122
|
+
return cls(
|
|
123
|
+
session_id=getattr(task, "task_id", "unknown"),
|
|
124
|
+
role_name=getattr(task, "role_name", "unknown"),
|
|
125
|
+
session_status="pending",
|
|
126
|
+
created_at=getattr(task, "created_at", datetime.now()),
|
|
127
|
+
issue=issue_info,
|
|
128
|
+
git=git_info,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_session_state(
|
|
133
|
+
cls,
|
|
134
|
+
session_id: str,
|
|
135
|
+
role_name: str,
|
|
136
|
+
issue_id: Optional[str],
|
|
137
|
+
status: str,
|
|
138
|
+
project_root: Optional[Path] = None,
|
|
139
|
+
) -> "HookContext":
|
|
140
|
+
"""
|
|
141
|
+
Create a HookContext from session state parameters.
|
|
142
|
+
|
|
143
|
+
This is a more flexible factory method that doesn't depend on
|
|
144
|
+
specific session implementations.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
session_id: The session/task ID
|
|
148
|
+
role_name: The role name
|
|
149
|
+
issue_id: Optional issue ID
|
|
150
|
+
status: Session status
|
|
151
|
+
project_root: Optional project root path
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
A populated HookContext
|
|
155
|
+
"""
|
|
94
156
|
# Build IssueInfo if we have an issue_id
|
|
95
157
|
issue_info = None
|
|
96
|
-
if
|
|
158
|
+
if issue_id:
|
|
97
159
|
issue_info = IssueInfo(
|
|
98
|
-
id=
|
|
99
|
-
branch_name=model.branch_name,
|
|
160
|
+
id=issue_id,
|
|
100
161
|
)
|
|
101
162
|
|
|
102
163
|
# Try to load full issue metadata
|
|
@@ -108,7 +169,7 @@ class HookContext:
|
|
|
108
169
|
project_root = find_monoco_root()
|
|
109
170
|
|
|
110
171
|
issues_root = project_root / "Issues"
|
|
111
|
-
issue_path = find_issue_path(issues_root,
|
|
172
|
+
issue_path = find_issue_path(issues_root, issue_id)
|
|
112
173
|
if issue_path:
|
|
113
174
|
metadata = parse_issue(issue_path)
|
|
114
175
|
if metadata:
|
|
@@ -122,10 +183,10 @@ class HookContext:
|
|
|
122
183
|
git_info = GitInfo(project_root=project_root)
|
|
123
184
|
|
|
124
185
|
return cls(
|
|
125
|
-
session_id=
|
|
126
|
-
role_name=
|
|
127
|
-
session_status=
|
|
128
|
-
created_at=
|
|
186
|
+
session_id=session_id,
|
|
187
|
+
role_name=role_name,
|
|
188
|
+
session_status=status,
|
|
189
|
+
created_at=datetime.now(),
|
|
129
190
|
issue=issue_info,
|
|
130
191
|
git=git_info,
|
|
131
192
|
)
|