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.
@@ -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)."