devs-webhook 0.1.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.
- devs_webhook/__init__.py +21 -0
- devs_webhook/app.py +321 -0
- devs_webhook/cli/__init__.py +6 -0
- devs_webhook/cli/worker.py +296 -0
- devs_webhook/config.py +319 -0
- devs_webhook/core/__init__.py +1 -0
- devs_webhook/core/claude_dispatcher.py +420 -0
- devs_webhook/core/container_pool.py +1109 -0
- devs_webhook/core/deduplication.py +113 -0
- devs_webhook/core/repository_manager.py +197 -0
- devs_webhook/core/task_processor.py +286 -0
- devs_webhook/core/test_dispatcher.py +448 -0
- devs_webhook/core/webhook_config.py +16 -0
- devs_webhook/core/webhook_handler.py +57 -0
- devs_webhook/github/__init__.py +15 -0
- devs_webhook/github/app_auth.py +226 -0
- devs_webhook/github/client.py +472 -0
- devs_webhook/github/models.py +424 -0
- devs_webhook/github/parser.py +325 -0
- devs_webhook/main_cli.py +386 -0
- devs_webhook/sources/__init__.py +7 -0
- devs_webhook/sources/base.py +24 -0
- devs_webhook/sources/sqs_source.py +306 -0
- devs_webhook/sources/webhook_source.py +82 -0
- devs_webhook/utils/__init__.py +1 -0
- devs_webhook/utils/async_utils.py +86 -0
- devs_webhook/utils/github.py +43 -0
- devs_webhook/utils/logging.py +34 -0
- devs_webhook/utils/serialization.py +102 -0
- devs_webhook-0.1.0.dist-info/METADATA +664 -0
- devs_webhook-0.1.0.dist-info/RECORD +35 -0
- devs_webhook-0.1.0.dist-info/WHEEL +5 -0
- devs_webhook-0.1.0.dist-info/entry_points.txt +3 -0
- devs_webhook-0.1.0.dist-info/licenses/LICENSE +21 -0
- devs_webhook-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Claude Code CLI integration for executing tasks in containers."""
|
|
2
|
+
|
|
3
|
+
from typing import NamedTuple, Optional
|
|
4
|
+
import structlog
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from devs_common.core.project import Project
|
|
8
|
+
from devs_common.core.container import ContainerManager
|
|
9
|
+
from devs_common.core.workspace import WorkspaceManager
|
|
10
|
+
from ..config import get_config
|
|
11
|
+
from ..github.models import WebhookEvent, IssueEvent, PullRequestEvent, CommentEvent, DevsOptions
|
|
12
|
+
from ..github.client import GitHubClient
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskResult(NamedTuple):
|
|
18
|
+
"""Result of a Claude Code task execution."""
|
|
19
|
+
success: bool
|
|
20
|
+
output: str
|
|
21
|
+
error: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClaudeDispatcher:
|
|
25
|
+
"""Dispatches tasks to Claude Code CLI running in containers."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
"""Initialize Claude dispatcher."""
|
|
29
|
+
self.config = get_config()
|
|
30
|
+
|
|
31
|
+
self.github_client = GitHubClient(self.config)
|
|
32
|
+
|
|
33
|
+
logger.info("Claude dispatcher initialized")
|
|
34
|
+
|
|
35
|
+
async def execute_task(
|
|
36
|
+
self,
|
|
37
|
+
dev_name: str,
|
|
38
|
+
repo_path: Path,
|
|
39
|
+
task_description: str,
|
|
40
|
+
event: WebhookEvent,
|
|
41
|
+
devs_options: Optional[DevsOptions] = None
|
|
42
|
+
) -> TaskResult:
|
|
43
|
+
"""Execute a task using Claude Code CLI in a container.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
dev_name: Name of dev container (e.g., eamonn)
|
|
47
|
+
repo_path: Path to repository on host (already calculated by container_pool)
|
|
48
|
+
task_description: Task description for Claude
|
|
49
|
+
event: Original webhook event
|
|
50
|
+
devs_options: Options from DEVS.yml file
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Task execution result
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
logger.info("Starting Claude Code CLI task",
|
|
57
|
+
container=dev_name,
|
|
58
|
+
repo=event.repository.full_name,
|
|
59
|
+
repo_path=str(repo_path))
|
|
60
|
+
|
|
61
|
+
# Execute Claude directly - prompt building, workspace setup, container startup, Claude execution
|
|
62
|
+
success, output, error = self._execute_claude_sync(
|
|
63
|
+
repo_path,
|
|
64
|
+
dev_name,
|
|
65
|
+
task_description,
|
|
66
|
+
event,
|
|
67
|
+
devs_options
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Build result - ensure we have meaningful error messages
|
|
71
|
+
if not success:
|
|
72
|
+
# If error is empty but we have output, use output as error
|
|
73
|
+
if not error and output:
|
|
74
|
+
error = output
|
|
75
|
+
elif not error:
|
|
76
|
+
error = "Claude execution failed with no error message"
|
|
77
|
+
|
|
78
|
+
result = TaskResult(
|
|
79
|
+
success=success,
|
|
80
|
+
output=output,
|
|
81
|
+
error=error if not success else None
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if result.success:
|
|
85
|
+
# Post-process results
|
|
86
|
+
await self._handle_task_completion(event, result.output)
|
|
87
|
+
logger.info("Claude Code task completed successfully",
|
|
88
|
+
container=dev_name,
|
|
89
|
+
repo=event.repository.full_name)
|
|
90
|
+
else:
|
|
91
|
+
# Handle failure
|
|
92
|
+
await self._handle_task_failure(event, result.error or "Unknown error")
|
|
93
|
+
logger.error("Claude Code task failed",
|
|
94
|
+
container=dev_name,
|
|
95
|
+
error=result.error)
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
error_msg = f"Task execution failed: {str(e)}"
|
|
101
|
+
logger.error("Task execution error",
|
|
102
|
+
container=dev_name,
|
|
103
|
+
error=error_msg,
|
|
104
|
+
exc_info=True)
|
|
105
|
+
|
|
106
|
+
await self._handle_task_failure(event, error_msg)
|
|
107
|
+
return TaskResult(success=False, output="", error=error_msg)
|
|
108
|
+
|
|
109
|
+
def _execute_claude_sync(
|
|
110
|
+
self,
|
|
111
|
+
repo_path: Path,
|
|
112
|
+
dev_name: str,
|
|
113
|
+
task_description: str,
|
|
114
|
+
event: WebhookEvent,
|
|
115
|
+
devs_options: Optional[DevsOptions] = None
|
|
116
|
+
) -> tuple[bool, str, str]:
|
|
117
|
+
"""Execute complete Claude workflow synchronously.
|
|
118
|
+
|
|
119
|
+
This mirrors the CLI approach exactly:
|
|
120
|
+
1. Create project, workspace manager, and container manager
|
|
121
|
+
2. Create/reset workspace (force=True for webhook)
|
|
122
|
+
3. Build prompt
|
|
123
|
+
4. Execute Claude (which handles container startup)
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
repo_path: Path to repository
|
|
127
|
+
dev_name: Development environment name
|
|
128
|
+
task_description: Task description for Claude
|
|
129
|
+
event: Webhook event
|
|
130
|
+
devs_options: Options from DEVS.yml
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Tuple of (success, stdout, stderr)
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
# 1. Create project, workspace manager, and container manager like CLI
|
|
137
|
+
project = Project(repo_path)
|
|
138
|
+
workspace_manager = WorkspaceManager(project, self.config)
|
|
139
|
+
container_manager = ContainerManager(project, self.config)
|
|
140
|
+
|
|
141
|
+
logger.info("Created project and managers",
|
|
142
|
+
container=dev_name,
|
|
143
|
+
project_name=project.info.name)
|
|
144
|
+
|
|
145
|
+
# 2. Ensure workspace exists (force=True for webhook to reset contents like CLI)
|
|
146
|
+
workspace_dir = workspace_manager.create_workspace(dev_name, reset_contents=True)
|
|
147
|
+
|
|
148
|
+
logger.info("Workspace created/reset",
|
|
149
|
+
container=dev_name,
|
|
150
|
+
workspace_dir=str(workspace_dir))
|
|
151
|
+
|
|
152
|
+
# 3. Build Claude prompt
|
|
153
|
+
workspace_name = project.get_workspace_name(dev_name)
|
|
154
|
+
workspace_path = f"/workspaces/{workspace_name}"
|
|
155
|
+
repo_name = event.repository.full_name
|
|
156
|
+
|
|
157
|
+
# Determine if this is a PR or Issue
|
|
158
|
+
is_pr = isinstance(event, PullRequestEvent) or (
|
|
159
|
+
isinstance(event, CommentEvent) and event.pull_request is not None
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Build prompt with appropriate context based on event type
|
|
163
|
+
event_type = "PR" if is_pr else "issue"
|
|
164
|
+
event_type_full = "GitHub PR" if is_pr else "GitHub issue"
|
|
165
|
+
|
|
166
|
+
# Check if we have a prompt override
|
|
167
|
+
if devs_options and devs_options.prompt_override:
|
|
168
|
+
# Use the complete override prompt
|
|
169
|
+
prompt = devs_options.prompt_override.format(
|
|
170
|
+
event_type=event_type,
|
|
171
|
+
event_type_full=event_type_full,
|
|
172
|
+
task_description=task_description,
|
|
173
|
+
repo_name=repo_name,
|
|
174
|
+
workspace_path=workspace_path,
|
|
175
|
+
github_username=self.config.github_mentioned_user
|
|
176
|
+
)
|
|
177
|
+
elif devs_options and devs_options.direct_commit:
|
|
178
|
+
# Use direct commit prompt variant
|
|
179
|
+
prompt = f"""You are an AI developer helping build a software project in a GitHub repository.
|
|
180
|
+
You have been mentioned in a {event_type_full} and need to take action.
|
|
181
|
+
|
|
182
|
+
You should ensure you're on the latest commits in the repo's default branch ({devs_options.default_branch if devs_options else 'main'}).
|
|
183
|
+
Commit your changes directly to the {devs_options.default_branch if devs_options else 'main'} branch unless there would be conflicts.
|
|
184
|
+
Only create a pull request if there would be merge conflicts when committing to {devs_options.default_branch if devs_options else 'main'}.
|
|
185
|
+
|
|
186
|
+
IMPORTANT: Do not close the issue unless the user explicitly instructs you to do so. Even if you implement a solution, leave the issue open for the user to review and close when they're satisfied.
|
|
187
|
+
|
|
188
|
+
If you need to ask for clarification, or if only asked for your thoughts, please respond with a comment on the {event_type}.
|
|
189
|
+
|
|
190
|
+
You should always comment back in any case to say what you've done (unless you are sure it wasn't intended for you). The `gh` CLI is available for GitHub operations, and you can use `git` too.
|
|
191
|
+
|
|
192
|
+
{devs_options.prompt_extra if devs_options and devs_options.prompt_extra else ''}
|
|
193
|
+
|
|
194
|
+
This is the latest update on the {event_type}, but you should just get the full thread for more details:
|
|
195
|
+
<latest_comment>
|
|
196
|
+
{task_description}
|
|
197
|
+
</latest_comment>
|
|
198
|
+
|
|
199
|
+
You are working in the repository `{repo_name}`.
|
|
200
|
+
The workspace path is `{workspace_path}`.
|
|
201
|
+
Your GitHub username is `{self.config.github_mentioned_user}`.
|
|
202
|
+
|
|
203
|
+
Always remember to PUSH your work to origin!
|
|
204
|
+
"""
|
|
205
|
+
else:
|
|
206
|
+
# Use the standard PR-based prompt
|
|
207
|
+
# Add PR-closing instruction only for issues
|
|
208
|
+
pr_closing_instruction = ""
|
|
209
|
+
if not is_pr:
|
|
210
|
+
pr_closing_instruction = " (mention that it closes an issue number if it does)"
|
|
211
|
+
|
|
212
|
+
# Build unified prompt with variable parts
|
|
213
|
+
prompt = f"""You are an AI developer helping build a software project in a GitHub repository.
|
|
214
|
+
You have been mentioned in a {event_type_full} and need to take action.
|
|
215
|
+
|
|
216
|
+
You should ensure you're on the latest commits in the repo's default branch.
|
|
217
|
+
Generally work on feature branches for changes.
|
|
218
|
+
Submit any changes as a pull request when done{pr_closing_instruction}.
|
|
219
|
+
|
|
220
|
+
IMPORTANT: Do not close the issue unless the user explicitly instructs you to do so. Even if you implement a solution, leave the issue open for the user to review and close when they're satisfied.
|
|
221
|
+
|
|
222
|
+
If you need to ask for clarification, or if only asked for your thoughts, please respond with a comment on the {event_type}.
|
|
223
|
+
|
|
224
|
+
You should always comment back in any case to say what you've done (unless you are sure it wasn't intended for you). The `gh` CLI is available for GitHub operations, and you can use `git` too.
|
|
225
|
+
|
|
226
|
+
{devs_options.prompt_extra if devs_options and devs_options.prompt_extra else ''}
|
|
227
|
+
|
|
228
|
+
This is the latest update on the {event_type}, but you should just get the full thread for more details:
|
|
229
|
+
<latest_comment>
|
|
230
|
+
{task_description}
|
|
231
|
+
</latest_comment>
|
|
232
|
+
|
|
233
|
+
You are working in the repository `{repo_name}`.
|
|
234
|
+
The workspace path is `{workspace_path}`.
|
|
235
|
+
Your GitHub username is `{self.config.github_mentioned_user}`.
|
|
236
|
+
|
|
237
|
+
Always remember to PUSH your work to origin!
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
logger.info("Built Claude prompt",
|
|
241
|
+
container=dev_name,
|
|
242
|
+
prompt_length=len(prompt),
|
|
243
|
+
event_type="PR" if is_pr else "Issue")
|
|
244
|
+
|
|
245
|
+
# 4. Execute Claude (like CLI pattern) with environment variables from DEVS.yml
|
|
246
|
+
logger.info("Executing Claude via ContainerManager (like CLI)",
|
|
247
|
+
container=dev_name)
|
|
248
|
+
|
|
249
|
+
extra_env = None
|
|
250
|
+
if devs_options:
|
|
251
|
+
extra_env = devs_options.get_env_vars(dev_name)
|
|
252
|
+
|
|
253
|
+
success, stdout, stderr = container_manager.exec_claude(
|
|
254
|
+
dev_name=dev_name,
|
|
255
|
+
workspace_dir=workspace_dir,
|
|
256
|
+
prompt=prompt,
|
|
257
|
+
debug=self.config.dev_mode,
|
|
258
|
+
stream=False, # Don't stream in webhook mode
|
|
259
|
+
extra_env=extra_env
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Log the actual output for debugging
|
|
263
|
+
if not success:
|
|
264
|
+
logger.error("Claude execution failed",
|
|
265
|
+
container=dev_name,
|
|
266
|
+
stdout=stdout[:1000] if stdout else "",
|
|
267
|
+
stderr=stderr[:1000] if stderr else "",
|
|
268
|
+
success=success)
|
|
269
|
+
|
|
270
|
+
# If failed and no stderr, check stdout for error messages
|
|
271
|
+
# (Claude sometimes outputs errors to stdout)
|
|
272
|
+
if not success and not stderr:
|
|
273
|
+
stderr = stdout
|
|
274
|
+
|
|
275
|
+
return success, stdout, stderr
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
error_msg = f"Claude execution failed: {str(e)}"
|
|
279
|
+
logger.error("Claude execution error",
|
|
280
|
+
container=dev_name,
|
|
281
|
+
error=error_msg,
|
|
282
|
+
exc_info=True)
|
|
283
|
+
return False, "", error_msg
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def _handle_task_completion(
|
|
287
|
+
self,
|
|
288
|
+
event: WebhookEvent,
|
|
289
|
+
claude_output: str
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Handle successful task completion.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
event: Original webhook event
|
|
295
|
+
claude_output: Output from Claude Code execution
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
# Skip GitHub operations for test events
|
|
299
|
+
if event.is_test:
|
|
300
|
+
logger.info("Skipping GitHub comment for test event",
|
|
301
|
+
output_preview=claude_output[:100])
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
# Extract useful information from Claude's output
|
|
305
|
+
#summary = self._extract_summary(claude_output)
|
|
306
|
+
|
|
307
|
+
# Comment on the original issue/PR
|
|
308
|
+
# comment = f"""🤖 **Claude AI Assistant Update**
|
|
309
|
+
|
|
310
|
+
# I've processed your request and taken the following actions:
|
|
311
|
+
|
|
312
|
+
# {summary}
|
|
313
|
+
|
|
314
|
+
# <details>
|
|
315
|
+
# <summary>Full execution log</summary>
|
|
316
|
+
|
|
317
|
+
# ```
|
|
318
|
+
# {claude_output[-2000:]} # Last 2000 chars to avoid huge comments
|
|
319
|
+
# ```
|
|
320
|
+
|
|
321
|
+
# </details>
|
|
322
|
+
|
|
323
|
+
# This response was generated automatically by the devs webhook handler.
|
|
324
|
+
# """
|
|
325
|
+
|
|
326
|
+
# Let's assume the real Claude task already added a comment somewhere
|
|
327
|
+
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.error("Error handling task completion",
|
|
330
|
+
error=str(e))
|
|
331
|
+
|
|
332
|
+
async def _handle_task_failure(
|
|
333
|
+
self,
|
|
334
|
+
event: WebhookEvent,
|
|
335
|
+
error_msg: str
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Handle task failure.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
event: Original webhook event
|
|
341
|
+
error_msg: Error message
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
# Skip GitHub operations for test events
|
|
345
|
+
if event.is_test:
|
|
346
|
+
logger.info("Skipping GitHub comment for test event failure",
|
|
347
|
+
error=error_msg)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
comment = f"""I encountered an error while trying to process your request:
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
{error_msg}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Please check the webhook handler logs for more details, or try mentioning me again with a more specific request.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
await self._post_github_comment(event, comment)
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.error("Error handling task failure",
|
|
363
|
+
error=str(e))
|
|
364
|
+
|
|
365
|
+
async def _post_github_comment(
|
|
366
|
+
self,
|
|
367
|
+
event: WebhookEvent,
|
|
368
|
+
comment: str
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Post a comment to the GitHub issue/PR.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
event: Webhook event
|
|
374
|
+
comment: Comment text
|
|
375
|
+
"""
|
|
376
|
+
repo_name = event.repository.full_name
|
|
377
|
+
|
|
378
|
+
if isinstance(event, IssueEvent):
|
|
379
|
+
await self.github_client.comment_on_issue(
|
|
380
|
+
repo_name, event.issue.number, comment
|
|
381
|
+
)
|
|
382
|
+
elif isinstance(event, PullRequestEvent):
|
|
383
|
+
await self.github_client.comment_on_pr(
|
|
384
|
+
repo_name, event.pull_request.number, comment
|
|
385
|
+
)
|
|
386
|
+
elif isinstance(event, CommentEvent):
|
|
387
|
+
if event.issue:
|
|
388
|
+
await self.github_client.comment_on_issue(
|
|
389
|
+
repo_name, event.issue.number, comment
|
|
390
|
+
)
|
|
391
|
+
elif event.pull_request:
|
|
392
|
+
await self.github_client.comment_on_pr(
|
|
393
|
+
repo_name, event.pull_request.number, comment
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def _extract_summary(self, claude_output: str) -> str:
|
|
397
|
+
"""Extract a summary from Claude's output.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
claude_output: Full output from Claude Code
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Extracted summary
|
|
404
|
+
"""
|
|
405
|
+
# Simple heuristic to extract key actions
|
|
406
|
+
lines = claude_output.split('\n')
|
|
407
|
+
summary_lines = []
|
|
408
|
+
|
|
409
|
+
for line in lines:
|
|
410
|
+
line = line.strip()
|
|
411
|
+
if any(keyword in line.lower() for keyword in [
|
|
412
|
+
'created', 'fixed', 'implemented', 'updated', 'added',
|
|
413
|
+
'pull request', 'branch', 'commit', 'merged'
|
|
414
|
+
]):
|
|
415
|
+
summary_lines.append(f"- {line}")
|
|
416
|
+
|
|
417
|
+
if summary_lines:
|
|
418
|
+
return '\n'.join(summary_lines[:10]) # Limit to 10 items
|
|
419
|
+
else:
|
|
420
|
+
return "Analyzed the request and provided feedback (see full log for details)."
|