monoco-toolkit 0.3.11__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/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 +1 -1
- 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 +15 -0
- monoco/core/hooks/context.py +74 -13
- 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/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/events.py +34 -0
- monoco/daemon/scheduler.py +172 -201
- monoco/daemon/services.py +27 -243
- monoco/features/agent/__init__.py +25 -7
- monoco/features/agent/cli.py +91 -57
- monoco/features/agent/engines.py +31 -170
- monoco/features/agent/worker.py +1 -1
- monoco/features/issue/commands.py +90 -32
- monoco/features/issue/core.py +249 -4
- monoco/features/spike/commands.py +5 -3
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/METADATA +1 -1
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/RECORD +41 -20
- monoco/features/agent/apoptosis.py +0 -44
- monoco/features/agent/manager.py +0 -127
- monoco/features/agent/session.py +0 -169
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/licenses/LICENSE +0 -0
monoco/core/config.py
CHANGED
|
@@ -160,7 +160,7 @@ class AgentConcurrencyConfig(BaseModel):
|
|
|
160
160
|
engineer: int = Field(default=1, description="Maximum concurrent Engineer agents")
|
|
161
161
|
architect: int = Field(default=1, description="Maximum concurrent Architect agents")
|
|
162
162
|
reviewer: int = Field(default=1, description="Maximum concurrent Reviewer agents")
|
|
163
|
-
|
|
163
|
+
# Note: Planner role removed in FEAT-0155 (was never used)
|
|
164
164
|
# Cool-down configuration
|
|
165
165
|
failure_cooldown_seconds: int = Field(default=60, description="Cooldown period after a failure before retrying")
|
|
166
166
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Executor Module - Layer 3 of the Event Automation Framework.
|
|
3
|
+
|
|
4
|
+
This module provides concrete Action implementations for various execution types.
|
|
5
|
+
It is part of the three-layer architecture:
|
|
6
|
+
- Layer 1: File Watcher
|
|
7
|
+
- Layer 2: Action Router
|
|
8
|
+
- Layer 3: Action Executor (this module)
|
|
9
|
+
|
|
10
|
+
Available Actions:
|
|
11
|
+
- SpawnAgentAction: Spawn agent sessions
|
|
12
|
+
- RunPytestAction: Execute pytest tests
|
|
13
|
+
- GitCommitAction: Perform git commits
|
|
14
|
+
- GitPushAction: Push to remote
|
|
15
|
+
- SendIMAction: Send notifications
|
|
16
|
+
|
|
17
|
+
Example Usage:
|
|
18
|
+
>>> from monoco.core.executor import SpawnAgentAction
|
|
19
|
+
>>> from monoco.core.scheduler import AgentEventType
|
|
20
|
+
>>> from monoco.core.router import ActionRouter
|
|
21
|
+
>>>
|
|
22
|
+
>>> action = SpawnAgentAction(role="Engineer")
|
|
23
|
+
>>> router = ActionRouter()
|
|
24
|
+
>>> router.register(AgentEventType.ISSUE_STAGE_CHANGED, action)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .agent_action import SpawnAgentAction
|
|
28
|
+
from .pytest_action import RunPytestAction
|
|
29
|
+
from .git_action import GitCommitAction, GitPushAction
|
|
30
|
+
from .im_action import SendIMAction
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"SpawnAgentAction",
|
|
34
|
+
"RunPytestAction",
|
|
35
|
+
"GitCommitAction",
|
|
36
|
+
"GitPushAction",
|
|
37
|
+
"SendIMAction",
|
|
38
|
+
]
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SpawnAgentAction - Action for spawning agent sessions.
|
|
3
|
+
|
|
4
|
+
Part of Layer 3 (Action Executor) in the event automation framework.
|
|
5
|
+
Uses AgentScheduler to spawn and manage agent sessions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from monoco.core.scheduler import AgentEvent, AgentEventType, AgentScheduler, AgentTask
|
|
14
|
+
from monoco.core.router import Action, ActionResult
|
|
15
|
+
from monoco.features.agent.models import RoleTemplate
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SpawnAgentAction(Action):
|
|
21
|
+
"""
|
|
22
|
+
Action that spawns an agent session.
|
|
23
|
+
|
|
24
|
+
This action creates and starts a new agent session based on the event.
|
|
25
|
+
It supports different roles (Architect, Engineer, Reviewer, etc.)
|
|
26
|
+
and integrates with the AgentScheduler for lifecycle management.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
>>> action = SpawnAgentAction(
|
|
30
|
+
... role="Engineer",
|
|
31
|
+
... scheduler=scheduler,
|
|
32
|
+
... )
|
|
33
|
+
>>> result = await action(event)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
ROLE_TEMPLATES = {
|
|
37
|
+
"Architect": {
|
|
38
|
+
"description": "System Architect",
|
|
39
|
+
"trigger": "memo.accumulation",
|
|
40
|
+
"goal": "Process memo inbox and create issues.",
|
|
41
|
+
"system_prompt": "You are the Architect. Process the Memo inbox.",
|
|
42
|
+
"engine": "gemini",
|
|
43
|
+
},
|
|
44
|
+
"Engineer": {
|
|
45
|
+
"description": "Software Engineer",
|
|
46
|
+
"trigger": "issue.stage_changed",
|
|
47
|
+
"goal": "Implement feature requirements.",
|
|
48
|
+
"system_prompt": "You are a Software Engineer. Read the issue and implement requirements.",
|
|
49
|
+
"engine": "gemini",
|
|
50
|
+
},
|
|
51
|
+
"Reviewer": {
|
|
52
|
+
"description": "Code Reviewer",
|
|
53
|
+
"trigger": "pr.created",
|
|
54
|
+
"goal": "Review code changes thoroughly.",
|
|
55
|
+
"system_prompt": "You are a Code Reviewer. Review the code changes thoroughly.",
|
|
56
|
+
"engine": "gemini",
|
|
57
|
+
},
|
|
58
|
+
"Coroner": {
|
|
59
|
+
"description": "Session Autopsy",
|
|
60
|
+
"trigger": "session.failed",
|
|
61
|
+
"goal": "Analyze failed session and create incident report.",
|
|
62
|
+
"system_prompt": "You are the Coroner. Analyze the failed session.",
|
|
63
|
+
"engine": "gemini",
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
role: str,
|
|
70
|
+
scheduler: AgentScheduler,
|
|
71
|
+
config: Optional[Dict[str, Any]] = None,
|
|
72
|
+
custom_role_template: Optional[Dict[str, str]] = None,
|
|
73
|
+
):
|
|
74
|
+
super().__init__(config)
|
|
75
|
+
self.role = role
|
|
76
|
+
self.scheduler = scheduler
|
|
77
|
+
self.custom_role_template = custom_role_template
|
|
78
|
+
self._spawned_sessions: List[str] = []
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def name(self) -> str:
|
|
82
|
+
return f"SpawnAgentAction({self.role})"
|
|
83
|
+
|
|
84
|
+
async def can_execute(self, event: AgentEvent) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Check if we should spawn an agent.
|
|
87
|
+
|
|
88
|
+
Conditions:
|
|
89
|
+
- Scheduler has capacity for new tasks
|
|
90
|
+
"""
|
|
91
|
+
# Check scheduler capacity
|
|
92
|
+
stats = self.scheduler.get_stats()
|
|
93
|
+
active_tasks = stats.get("active_tasks", 0)
|
|
94
|
+
max_concurrent = stats.get("max_concurrent", 5)
|
|
95
|
+
|
|
96
|
+
if active_tasks >= max_concurrent:
|
|
97
|
+
logger.warning(f"Scheduler at capacity ({active_tasks}/{max_concurrent}), skipping")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
async def execute(self, event: AgentEvent) -> ActionResult:
|
|
103
|
+
"""Spawn an agent session."""
|
|
104
|
+
issue_id = event.payload.get("issue_id", "unknown")
|
|
105
|
+
issue_title = event.payload.get("issue_title", "Unknown")
|
|
106
|
+
|
|
107
|
+
logger.info(f"Spawning {self.role} agent for {issue_id}")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
# Create AgentTask
|
|
111
|
+
task = AgentTask(
|
|
112
|
+
task_id=f"{self.role.lower()}-{issue_id}-{event.timestamp.timestamp()}",
|
|
113
|
+
role_name=self.role,
|
|
114
|
+
issue_id=issue_id,
|
|
115
|
+
prompt=self._build_prompt(issue_id, issue_title),
|
|
116
|
+
engine=self._get_engine(),
|
|
117
|
+
timeout=self.config.get("timeout", 1800),
|
|
118
|
+
metadata={
|
|
119
|
+
"trigger": event.type.value,
|
|
120
|
+
"issue_title": issue_title,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Schedule task
|
|
125
|
+
session_id = await self.scheduler.schedule(task)
|
|
126
|
+
|
|
127
|
+
# Track spawned session
|
|
128
|
+
self._spawned_sessions.append(session_id)
|
|
129
|
+
|
|
130
|
+
logger.info(f"{self.role} scheduled: session={session_id}")
|
|
131
|
+
|
|
132
|
+
return ActionResult.success_result(
|
|
133
|
+
output={
|
|
134
|
+
"session_id": session_id,
|
|
135
|
+
"issue_id": issue_id,
|
|
136
|
+
"role": self.role,
|
|
137
|
+
},
|
|
138
|
+
metadata={
|
|
139
|
+
"task_id": task.task_id,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Failed to spawn {self.role} for {issue_id}: {e}")
|
|
145
|
+
return ActionResult.failure_result(
|
|
146
|
+
error=str(e),
|
|
147
|
+
metadata={
|
|
148
|
+
"issue_id": issue_id,
|
|
149
|
+
"role": self.role,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _build_prompt(self, issue_id: str, issue_title: str) -> str:
|
|
154
|
+
"""Build the prompt for the agent."""
|
|
155
|
+
template = self.ROLE_TEMPLATES.get(self.role, self.ROLE_TEMPLATES["Engineer"])
|
|
156
|
+
|
|
157
|
+
return f"""You are a {template['description']}.
|
|
158
|
+
|
|
159
|
+
Issue: {issue_id} - {issue_title}
|
|
160
|
+
|
|
161
|
+
Goal: {template['goal']}
|
|
162
|
+
|
|
163
|
+
{template['system_prompt']}
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def _get_engine(self) -> str:
|
|
167
|
+
"""Get the engine for this role."""
|
|
168
|
+
if self.custom_role_template:
|
|
169
|
+
return self.custom_role_template.get("engine", "gemini")
|
|
170
|
+
template = self.ROLE_TEMPLATES.get(self.role, self.ROLE_TEMPLATES["Engineer"])
|
|
171
|
+
return template["engine"]
|
|
172
|
+
|
|
173
|
+
def get_spawned_sessions(self) -> List[str]:
|
|
174
|
+
"""Get list of session IDs spawned by this action."""
|
|
175
|
+
return self._spawned_sessions.copy()
|
|
176
|
+
|
|
177
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
178
|
+
"""Get action statistics."""
|
|
179
|
+
stats = super().get_stats()
|
|
180
|
+
stats.update({
|
|
181
|
+
"role": self.role,
|
|
182
|
+
"spawned_sessions": len(self._spawned_sessions),
|
|
183
|
+
})
|
|
184
|
+
return stats
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class SpawnArchitectAction(SpawnAgentAction):
|
|
188
|
+
"""Convenience action for spawning Architect agents."""
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
scheduler: AgentScheduler,
|
|
193
|
+
config: Optional[Dict[str, Any]] = None,
|
|
194
|
+
):
|
|
195
|
+
super().__init__(
|
|
196
|
+
role="Architect",
|
|
197
|
+
scheduler=scheduler,
|
|
198
|
+
config=config,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def can_execute(self, event: AgentEvent) -> bool:
|
|
202
|
+
"""Only execute for MEMO_THRESHOLD events."""
|
|
203
|
+
if event.type != AgentEventType.MEMO_THRESHOLD:
|
|
204
|
+
return False
|
|
205
|
+
return await super().can_execute(event)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class SpawnEngineerAction(SpawnAgentAction):
|
|
209
|
+
"""Convenience action for spawning Engineer agents."""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
scheduler: AgentScheduler,
|
|
214
|
+
config: Optional[Dict[str, Any]] = None,
|
|
215
|
+
):
|
|
216
|
+
super().__init__(
|
|
217
|
+
role="Engineer",
|
|
218
|
+
scheduler=scheduler,
|
|
219
|
+
config=config,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
async def can_execute(self, event: AgentEvent) -> bool:
|
|
223
|
+
"""Only execute for ISSUE_STAGE_CHANGED to 'doing' events."""
|
|
224
|
+
if event.type != AgentEventType.ISSUE_STAGE_CHANGED:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
new_stage = event.payload.get("new_stage")
|
|
228
|
+
issue_status = event.payload.get("issue_status")
|
|
229
|
+
|
|
230
|
+
if new_stage != "doing" or issue_status != "open":
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
return await super().can_execute(event)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class SpawnReviewerAction(SpawnAgentAction):
|
|
237
|
+
"""Convenience action for spawning Reviewer agents."""
|
|
238
|
+
|
|
239
|
+
def __init__(
|
|
240
|
+
self,
|
|
241
|
+
scheduler: AgentScheduler,
|
|
242
|
+
config: Optional[Dict[str, Any]] = None,
|
|
243
|
+
):
|
|
244
|
+
super().__init__(
|
|
245
|
+
role="Reviewer",
|
|
246
|
+
scheduler=scheduler,
|
|
247
|
+
config=config,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def can_execute(self, event: AgentEvent) -> bool:
|
|
251
|
+
"""Only execute for PR_CREATED events."""
|
|
252
|
+
if event.type != AgentEventType.PR_CREATED:
|
|
253
|
+
return False
|
|
254
|
+
return await super().can_execute(event)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git Actions - Actions for git operations.
|
|
3
|
+
|
|
4
|
+
Part of Layer 3 (Action Executor) in the event automation framework.
|
|
5
|
+
Provides actions for git commit and push operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
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 GitResult:
|
|
22
|
+
"""Result of a git command execution."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
returncode: int,
|
|
27
|
+
stdout: str,
|
|
28
|
+
stderr: str,
|
|
29
|
+
):
|
|
30
|
+
self.returncode = returncode
|
|
31
|
+
self.stdout = stdout
|
|
32
|
+
self.stderr = stderr
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def success(self) -> bool:
|
|
36
|
+
return self.returncode == 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GitCommitAction(Action):
|
|
40
|
+
"""
|
|
41
|
+
Action that performs git commit.
|
|
42
|
+
|
|
43
|
+
This action stages files and creates a git commit with a message.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> action = GitCommitAction(
|
|
47
|
+
... message="Auto-commit: {issue_id}",
|
|
48
|
+
... files=["*.py"],
|
|
49
|
+
... add_all=False,
|
|
50
|
+
... )
|
|
51
|
+
>>> result = await action(event)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
message: str,
|
|
57
|
+
files: Optional[List[str]] = None,
|
|
58
|
+
add_all: bool = False,
|
|
59
|
+
working_dir: Optional[Path] = None,
|
|
60
|
+
timeout: int = 30,
|
|
61
|
+
config: Optional[Dict[str, Any]] = None,
|
|
62
|
+
):
|
|
63
|
+
super().__init__(config)
|
|
64
|
+
self.message = message
|
|
65
|
+
self.files = files or []
|
|
66
|
+
self.add_all = add_all
|
|
67
|
+
self.working_dir = working_dir or Path.cwd()
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
self._last_result: Optional[GitResult] = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def name(self) -> str:
|
|
73
|
+
return "GitCommitAction"
|
|
74
|
+
|
|
75
|
+
async def can_execute(self, event: AgentEvent) -> bool:
|
|
76
|
+
"""Check if we're in a git repository."""
|
|
77
|
+
git_dir = self.working_dir / ".git"
|
|
78
|
+
return git_dir.exists()
|
|
79
|
+
|
|
80
|
+
async def execute(self, event: AgentEvent) -> ActionResult:
|
|
81
|
+
"""Perform git commit."""
|
|
82
|
+
# Format message with event data
|
|
83
|
+
formatted_message = self._format_message(event)
|
|
84
|
+
|
|
85
|
+
logger.info(f"Performing git commit: {formatted_message[:50]}...")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
# Stage files
|
|
89
|
+
if self.add_all:
|
|
90
|
+
await self._run_git_command(["git", "add", "-A"])
|
|
91
|
+
elif self.files:
|
|
92
|
+
for file_pattern in self.files:
|
|
93
|
+
await self._run_git_command(["git", "add", file_pattern])
|
|
94
|
+
|
|
95
|
+
# Check if there are changes to commit
|
|
96
|
+
status_result = await self._run_git_command(
|
|
97
|
+
["git", "status", "--porcelain"]
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not status_result.stdout.strip():
|
|
101
|
+
logger.info("No changes to commit")
|
|
102
|
+
return ActionResult.success_result(
|
|
103
|
+
output={"committed": False, "reason": "no_changes"},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Commit
|
|
107
|
+
commit_result = await self._run_git_command(
|
|
108
|
+
["git", "commit", "-m", formatted_message]
|
|
109
|
+
)
|
|
110
|
+
self._last_result = commit_result
|
|
111
|
+
|
|
112
|
+
if commit_result.success:
|
|
113
|
+
# Get commit hash
|
|
114
|
+
hash_result = await self._run_git_command(
|
|
115
|
+
["git", "rev-parse", "HEAD"]
|
|
116
|
+
)
|
|
117
|
+
commit_hash = hash_result.stdout.strip()
|
|
118
|
+
|
|
119
|
+
return ActionResult.success_result(
|
|
120
|
+
output={
|
|
121
|
+
"committed": True,
|
|
122
|
+
"commit_hash": commit_hash,
|
|
123
|
+
"message": formatted_message,
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
return ActionResult.failure_result(
|
|
128
|
+
error=f"Git commit failed: {commit_result.stderr}",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Git commit failed: {e}")
|
|
133
|
+
return ActionResult.failure_result(error=str(e))
|
|
134
|
+
|
|
135
|
+
def _format_message(self, event: AgentEvent) -> str:
|
|
136
|
+
"""Format commit message with event data."""
|
|
137
|
+
try:
|
|
138
|
+
return self.message.format(**event.payload)
|
|
139
|
+
except (KeyError, ValueError):
|
|
140
|
+
# If formatting fails, return original message
|
|
141
|
+
return self.message
|
|
142
|
+
|
|
143
|
+
async def _run_git_command(self, cmd: List[str]) -> GitResult:
|
|
144
|
+
"""Execute a git command."""
|
|
145
|
+
process = await asyncio.create_subprocess_exec(
|
|
146
|
+
*cmd,
|
|
147
|
+
stdout=asyncio.subprocess.PIPE,
|
|
148
|
+
stderr=asyncio.subprocess.PIPE,
|
|
149
|
+
cwd=self.working_dir,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
stdout, stderr = await asyncio.wait_for(
|
|
154
|
+
process.communicate(),
|
|
155
|
+
timeout=self.timeout,
|
|
156
|
+
)
|
|
157
|
+
except asyncio.TimeoutError:
|
|
158
|
+
process.kill()
|
|
159
|
+
raise RuntimeError(f"Git command timed out: {' '.join(cmd)}")
|
|
160
|
+
|
|
161
|
+
return GitResult(
|
|
162
|
+
returncode=process.returncode,
|
|
163
|
+
stdout=stdout.decode("utf-8", errors="replace"),
|
|
164
|
+
stderr=stderr.decode("utf-8", errors="replace"),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
168
|
+
"""Get action statistics."""
|
|
169
|
+
stats = super().get_stats()
|
|
170
|
+
stats.update({
|
|
171
|
+
"working_dir": str(self.working_dir),
|
|
172
|
+
"message_template": self.message,
|
|
173
|
+
})
|
|
174
|
+
return stats
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class GitPushAction(Action):
|
|
178
|
+
"""
|
|
179
|
+
Action that performs git push.
|
|
180
|
+
|
|
181
|
+
This action pushes commits to a remote repository.
|
|
182
|
+
|
|
183
|
+
Example:
|
|
184
|
+
>>> action = GitPushAction(
|
|
185
|
+
... remote="origin",
|
|
186
|
+
... branch="main",
|
|
187
|
+
... )
|
|
188
|
+
>>> result = await action(event)
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
remote: str = "origin",
|
|
194
|
+
branch: Optional[str] = None,
|
|
195
|
+
force: bool = False,
|
|
196
|
+
working_dir: Optional[Path] = None,
|
|
197
|
+
timeout: int = 60,
|
|
198
|
+
config: Optional[Dict[str, Any]] = None,
|
|
199
|
+
):
|
|
200
|
+
super().__init__(config)
|
|
201
|
+
self.remote = remote
|
|
202
|
+
self.branch = branch
|
|
203
|
+
self.force = force
|
|
204
|
+
self.working_dir = working_dir or Path.cwd()
|
|
205
|
+
self.timeout = timeout
|
|
206
|
+
self._last_result: Optional[GitResult] = None
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def name(self) -> str:
|
|
210
|
+
return "GitPushAction"
|
|
211
|
+
|
|
212
|
+
async def can_execute(self, event: AgentEvent) -> bool:
|
|
213
|
+
"""Check if we're in a git repository with a remote."""
|
|
214
|
+
git_dir = self.working_dir / ".git"
|
|
215
|
+
if not git_dir.exists():
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
# Check if remote exists
|
|
219
|
+
try:
|
|
220
|
+
result = await self._run_git_command(
|
|
221
|
+
["git", "remote", "get-url", self.remote]
|
|
222
|
+
)
|
|
223
|
+
return result.success
|
|
224
|
+
except Exception:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
async def execute(self, event: AgentEvent) -> ActionResult:
|
|
228
|
+
"""Perform git push."""
|
|
229
|
+
# Determine branch
|
|
230
|
+
branch = self.branch
|
|
231
|
+
if not branch:
|
|
232
|
+
# Get current branch
|
|
233
|
+
result = await self._run_git_command(
|
|
234
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"]
|
|
235
|
+
)
|
|
236
|
+
if result.success:
|
|
237
|
+
branch = result.stdout.strip()
|
|
238
|
+
else:
|
|
239
|
+
return ActionResult.failure_result(
|
|
240
|
+
error="Could not determine current branch"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
logger.info(f"Pushing to {self.remote}/{branch}")
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
# Build command
|
|
247
|
+
cmd = ["git", "push", self.remote, branch]
|
|
248
|
+
if self.force:
|
|
249
|
+
cmd.append("--force-with-lease")
|
|
250
|
+
|
|
251
|
+
result = await self._run_git_command(cmd)
|
|
252
|
+
self._last_result = result
|
|
253
|
+
|
|
254
|
+
if result.success:
|
|
255
|
+
return ActionResult.success_result(
|
|
256
|
+
output={
|
|
257
|
+
"pushed": True,
|
|
258
|
+
"remote": self.remote,
|
|
259
|
+
"branch": branch,
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
return ActionResult.failure_result(
|
|
264
|
+
error=f"Git push failed: {result.stderr}",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.error(f"Git push failed: {e}")
|
|
269
|
+
return ActionResult.failure_result(error=str(e))
|
|
270
|
+
|
|
271
|
+
async def _run_git_command(self, cmd: List[str]) -> GitResult:
|
|
272
|
+
"""Execute a git command."""
|
|
273
|
+
process = await asyncio.create_subprocess_exec(
|
|
274
|
+
*cmd,
|
|
275
|
+
stdout=asyncio.subprocess.PIPE,
|
|
276
|
+
stderr=asyncio.subprocess.PIPE,
|
|
277
|
+
cwd=self.working_dir,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
stdout, stderr = await asyncio.wait_for(
|
|
282
|
+
process.communicate(),
|
|
283
|
+
timeout=self.timeout,
|
|
284
|
+
)
|
|
285
|
+
except asyncio.TimeoutError:
|
|
286
|
+
process.kill()
|
|
287
|
+
raise RuntimeError(f"Git command timed out: {' '.join(cmd)}")
|
|
288
|
+
|
|
289
|
+
return GitResult(
|
|
290
|
+
returncode=process.returncode,
|
|
291
|
+
stdout=stdout.decode("utf-8", errors="replace"),
|
|
292
|
+
stderr=stderr.decode("utf-8", errors="replace"),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
296
|
+
"""Get action statistics."""
|
|
297
|
+
stats = super().get_stats()
|
|
298
|
+
stats.update({
|
|
299
|
+
"working_dir": str(self.working_dir),
|
|
300
|
+
"remote": self.remote,
|
|
301
|
+
"branch": self.branch or "auto",
|
|
302
|
+
})
|
|
303
|
+
return stats
|