diagram-to-iac 0.6.0__py3-none-any.whl → 0.8.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.
Files changed (77) hide show
  1. diagram_to_iac/__init__.py +10 -0
  2. diagram_to_iac/actions/__init__.py +7 -0
  3. diagram_to_iac/actions/git_entry.py +174 -0
  4. diagram_to_iac/actions/supervisor_entry.py +116 -0
  5. diagram_to_iac/actions/terraform_agent_entry.py +207 -0
  6. diagram_to_iac/agents/__init__.py +26 -0
  7. diagram_to_iac/agents/demonstrator_langgraph/__init__.py +10 -0
  8. diagram_to_iac/agents/demonstrator_langgraph/agent.py +826 -0
  9. diagram_to_iac/agents/git_langgraph/__init__.py +10 -0
  10. diagram_to_iac/agents/git_langgraph/agent.py +1018 -0
  11. diagram_to_iac/agents/git_langgraph/pr.py +146 -0
  12. diagram_to_iac/agents/hello_langgraph/__init__.py +9 -0
  13. diagram_to_iac/agents/hello_langgraph/agent.py +621 -0
  14. diagram_to_iac/agents/policy_agent/__init__.py +15 -0
  15. diagram_to_iac/agents/policy_agent/agent.py +507 -0
  16. diagram_to_iac/agents/policy_agent/integration_example.py +191 -0
  17. diagram_to_iac/agents/policy_agent/tools/__init__.py +14 -0
  18. diagram_to_iac/agents/policy_agent/tools/tfsec_tool.py +259 -0
  19. diagram_to_iac/agents/shell_langgraph/__init__.py +21 -0
  20. diagram_to_iac/agents/shell_langgraph/agent.py +122 -0
  21. diagram_to_iac/agents/shell_langgraph/detector.py +50 -0
  22. diagram_to_iac/agents/supervisor_langgraph/__init__.py +17 -0
  23. diagram_to_iac/agents/supervisor_langgraph/agent.py +1947 -0
  24. diagram_to_iac/agents/supervisor_langgraph/demonstrator.py +22 -0
  25. diagram_to_iac/agents/supervisor_langgraph/guards.py +23 -0
  26. diagram_to_iac/agents/supervisor_langgraph/pat_loop.py +49 -0
  27. diagram_to_iac/agents/supervisor_langgraph/router.py +9 -0
  28. diagram_to_iac/agents/terraform_langgraph/__init__.py +15 -0
  29. diagram_to_iac/agents/terraform_langgraph/agent.py +1216 -0
  30. diagram_to_iac/agents/terraform_langgraph/parser.py +76 -0
  31. diagram_to_iac/core/__init__.py +7 -0
  32. diagram_to_iac/core/agent_base.py +19 -0
  33. diagram_to_iac/core/enhanced_memory.py +302 -0
  34. diagram_to_iac/core/errors.py +4 -0
  35. diagram_to_iac/core/issue_tracker.py +49 -0
  36. diagram_to_iac/core/memory.py +132 -0
  37. diagram_to_iac/services/__init__.py +10 -0
  38. diagram_to_iac/services/observability.py +59 -0
  39. diagram_to_iac/services/step_summary.py +77 -0
  40. diagram_to_iac/tools/__init__.py +11 -0
  41. diagram_to_iac/tools/api_utils.py +108 -26
  42. diagram_to_iac/tools/git/__init__.py +45 -0
  43. diagram_to_iac/tools/git/git.py +956 -0
  44. diagram_to_iac/tools/hello/__init__.py +30 -0
  45. diagram_to_iac/tools/hello/cal_utils.py +31 -0
  46. diagram_to_iac/tools/hello/text_utils.py +97 -0
  47. diagram_to_iac/tools/llm_utils/__init__.py +20 -0
  48. diagram_to_iac/tools/llm_utils/anthropic_driver.py +87 -0
  49. diagram_to_iac/tools/llm_utils/base_driver.py +90 -0
  50. diagram_to_iac/tools/llm_utils/gemini_driver.py +89 -0
  51. diagram_to_iac/tools/llm_utils/openai_driver.py +93 -0
  52. diagram_to_iac/tools/llm_utils/router.py +303 -0
  53. diagram_to_iac/tools/sec_utils.py +4 -2
  54. diagram_to_iac/tools/shell/__init__.py +17 -0
  55. diagram_to_iac/tools/shell/shell.py +415 -0
  56. diagram_to_iac/tools/text_utils.py +277 -0
  57. diagram_to_iac/tools/tf/terraform.py +851 -0
  58. diagram_to_iac-0.8.0.dist-info/METADATA +99 -0
  59. diagram_to_iac-0.8.0.dist-info/RECORD +64 -0
  60. {diagram_to_iac-0.6.0.dist-info → diagram_to_iac-0.8.0.dist-info}/WHEEL +1 -1
  61. diagram_to_iac-0.8.0.dist-info/entry_points.txt +4 -0
  62. diagram_to_iac/agents/codegen_agent.py +0 -0
  63. diagram_to_iac/agents/consensus_agent.py +0 -0
  64. diagram_to_iac/agents/deployment_agent.py +0 -0
  65. diagram_to_iac/agents/github_agent.py +0 -0
  66. diagram_to_iac/agents/interpretation_agent.py +0 -0
  67. diagram_to_iac/agents/question_agent.py +0 -0
  68. diagram_to_iac/agents/supervisor.py +0 -0
  69. diagram_to_iac/agents/vision_agent.py +0 -0
  70. diagram_to_iac/core/config.py +0 -0
  71. diagram_to_iac/tools/cv_utils.py +0 -0
  72. diagram_to_iac/tools/gh_utils.py +0 -0
  73. diagram_to_iac/tools/tf_utils.py +0 -0
  74. diagram_to_iac-0.6.0.dist-info/METADATA +0 -16
  75. diagram_to_iac-0.6.0.dist-info/RECORD +0 -32
  76. diagram_to_iac-0.6.0.dist-info/entry_points.txt +0 -2
  77. {diagram_to_iac-0.6.0.dist-info → diagram_to_iac-0.8.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,956 @@
1
+ """
2
+ Git tools implementation following our established comprehensive pattern.
3
+
4
+ This module provides secure git operations with:
5
+ - Configuration-driven behavior via YAML
6
+ - Comprehensive logging with structured messages
7
+ - Memory integration for operation tracking
8
+ - Robust error handling with graceful fallbacks
9
+ - Integration with our ShellExecutor for safe command execution
10
+ - Pydantic schemas for type safety and validation
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import logging
16
+ import yaml
17
+ from typing import Optional, Dict, Any, List
18
+ from pathlib import Path
19
+ from urllib.parse import urlparse
20
+ from pydantic import BaseModel, Field, field_validator
21
+ from langchain_core.tools import tool
22
+
23
+ from diagram_to_iac.core.memory import create_memory
24
+ from diagram_to_iac.tools.shell import get_shell_executor, ShellExecInput
25
+
26
+
27
+ # --- Pydantic Schemas for Git Operations ---
28
+ class GitCloneInput(BaseModel):
29
+ """Input schema for git clone operations following our established pattern."""
30
+ repo_url: str = Field(..., description="Git repository URL to clone")
31
+ depth: Optional[int] = Field(None, description="Clone depth (overrides config default)")
32
+ branch: Optional[str] = Field(None, description="Specific branch to clone")
33
+ target_dir: Optional[str] = Field(None, description="Target directory name (overrides auto-detection)")
34
+ workspace: Optional[str] = Field(None, description="Workspace path (overrides config default)")
35
+
36
+ @field_validator('repo_url')
37
+ @classmethod
38
+ def validate_repo_url(cls, v):
39
+ """Validate that repo_url is a valid URL."""
40
+ if not v or not isinstance(v, str):
41
+ raise ValueError("Repository URL must be a non-empty string")
42
+
43
+ # Basic URL validation
44
+ parsed = urlparse(v)
45
+ if not parsed.scheme or not parsed.netloc:
46
+ raise ValueError("Repository URL must be a valid URL with scheme and host")
47
+
48
+ return v
49
+
50
+
51
+ class GitCloneOutput(BaseModel):
52
+ """Output schema for git clone operations following our established pattern."""
53
+ status: str = Field(..., description="Operation status: SUCCESS, AUTH_FAILED, ERROR, TIMEOUT, ALREADY_EXISTS")
54
+ repo_path: Optional[str] = Field(None, description="Path to cloned repository if successful")
55
+ repo_name: Optional[str] = Field(None, description="Extracted repository name")
56
+ error_message: Optional[str] = Field(None, description="Error details if operation failed")
57
+ duration: float = Field(..., description="Operation duration in seconds")
58
+ command_executed: Optional[str] = Field(None, description="Git command that was executed")
59
+ workspace_used: str = Field(..., description="Workspace directory used for operation")
60
+
61
+
62
+ class GhOpenIssueInput(BaseModel):
63
+ """Input schema for GitHub CLI issue creation following our established pattern."""
64
+ repo_url: str = Field(..., description="GitHub repository URL (https://github.com/owner/repo)")
65
+ title: str = Field(..., description="Issue title")
66
+ body: str = Field(..., description="Issue body/description")
67
+ labels: Optional[List[str]] = Field(None, description="Issue labels to apply")
68
+ assignees: Optional[List[str]] = Field(None, description="Users to assign to the issue")
69
+ milestone: Optional[str] = Field(None, description="Milestone to associate with the issue")
70
+ issue_id: Optional[int] = Field(None, description="Existing issue number to comment on")
71
+
72
+ @field_validator('repo_url')
73
+ @classmethod
74
+ def validate_github_repo_url(cls, v):
75
+ """Validate that repo_url is a valid GitHub repository URL."""
76
+ if not v or not isinstance(v, str):
77
+ raise ValueError("Repository URL must be a non-empty string")
78
+
79
+ # Validate GitHub URL format
80
+ if not v.startswith(('https://github.com/', 'git@github.com:')):
81
+ raise ValueError("Repository URL must be a valid GitHub repository URL")
82
+
83
+ return v
84
+
85
+ @field_validator('title')
86
+ @classmethod
87
+ def validate_title(cls, v):
88
+ """Validate issue title."""
89
+ if not v or not isinstance(v, str) or not v.strip():
90
+ raise ValueError("Issue title must be a non-empty string")
91
+ return v.strip()
92
+
93
+ @field_validator('body')
94
+ @classmethod
95
+ def validate_body(cls, v):
96
+ """Validate issue body."""
97
+ if not v or not isinstance(v, str) or not v.strip():
98
+ raise ValueError("Issue body must be a non-empty string")
99
+ return v.strip()
100
+
101
+
102
+ class GhOpenIssueOutput(BaseModel):
103
+ """Output schema for GitHub CLI issue creation following our established pattern."""
104
+ status: str = Field(..., description="Operation status: GH_SUCCESS, GH_AUTH_FAILED, GH_REPO_NOT_FOUND, GH_PERMISSION_DENIED, GH_ERROR")
105
+ issue_url: Optional[str] = Field(None, description="URL of created issue if successful")
106
+ issue_number: Optional[int] = Field(None, description="Issue number if creation was successful")
107
+ repo_owner: Optional[str] = Field(None, description="Repository owner extracted from URL")
108
+ repo_name: Optional[str] = Field(None, description="Repository name extracted from URL")
109
+ error_message: Optional[str] = Field(None, description="Error details if operation failed")
110
+ duration: float = Field(..., description="Operation duration in seconds")
111
+ command_executed: Optional[str] = Field(None, description="GitHub CLI command that was executed")
112
+
113
+
114
+ class GitExecutor:
115
+ """
116
+ GitExecutor provides secure git operations following our established pattern.
117
+
118
+ Features:
119
+ - Configuration-driven behavior (clone settings, auth patterns, workspace)
120
+ - Comprehensive logging with structured messages
121
+ - Memory integration for operation tracking
122
+ - Robust error handling with graceful fallbacks
123
+ - Integration with ShellExecutor for secure command execution
124
+ - Authentication failure detection and handling
125
+ """
126
+
127
+ def __init__(self, config_path: str = None, memory_type: str = "persistent"):
128
+ """
129
+ Initialize GitExecutor following our established pattern.
130
+
131
+ Args:
132
+ config_path: Optional path to git tools configuration file
133
+ memory_type: Type of memory to use ("persistent", "memory", or "langgraph")
134
+ """
135
+ # Configure logger following our pattern
136
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
137
+ if not logging.getLogger().hasHandlers():
138
+ logging.basicConfig(
139
+ level=logging.INFO,
140
+ format='%(asctime)s - %(name)s - %(levelname)s - %(threadName)s - %(message)s',
141
+ datefmt='%Y-%m-%d %H:%M:%S'
142
+ )
143
+
144
+ # Load configuration following our pattern
145
+ if config_path is None:
146
+ base_dir = os.path.dirname(os.path.abspath(__file__))
147
+ config_path = os.path.join(base_dir, 'git_config.yaml')
148
+ self.logger.debug(f"Default config path set to: {config_path}")
149
+
150
+ try:
151
+ with open(config_path, 'r') as f:
152
+ self.config = yaml.safe_load(f)
153
+ if self.config is None:
154
+ self.logger.warning(f"Configuration file at {config_path} is empty. Using default values.")
155
+ self._set_default_config()
156
+ else:
157
+ self.logger.info(f"Git configuration loaded successfully from {config_path}")
158
+ except FileNotFoundError:
159
+ self.logger.warning(f"Configuration file not found at {config_path}. Using default values.")
160
+ self._set_default_config()
161
+ except yaml.YAMLError as e:
162
+ self.logger.error(f"Error parsing YAML configuration from {config_path}: {e}. Using default values.", exc_info=True)
163
+ self._set_default_config()
164
+
165
+ # Initialize memory system following our pattern
166
+ self.memory = create_memory(memory_type)
167
+ self.logger.info(f"Git executor memory system initialized: {type(self.memory).__name__}")
168
+
169
+ # Initialize shell executor dependency
170
+ self.shell_executor = get_shell_executor()
171
+ self.logger.info("Git executor initialized with shell executor dependency")
172
+
173
+ # Log configuration summary
174
+ git_config = self.config.get('git_executor', {})
175
+ self.logger.info(f"Git executor initialized with workspace: {git_config.get('default_workspace', '/workspace')}")
176
+ self.logger.info(f"Default clone depth: {git_config.get('default_clone_depth', 1)}")
177
+ self.logger.info(f"Auth failure patterns: {len(git_config.get('auth_failure_patterns', []))}")
178
+
179
+ def _set_default_config(self):
180
+ """Set default configuration following our established pattern."""
181
+ self.logger.info("Setting default configuration for GitExecutor.")
182
+ self.config = {
183
+ 'git_executor': {
184
+ 'default_workspace': '/workspace',
185
+ 'default_clone_depth': 1,
186
+ 'default_timeout': 300,
187
+ 'auth_failure_patterns': [
188
+ 'Authentication failed',
189
+ 'Permission denied',
190
+ 'Could not read from remote repository',
191
+ 'fatal: unable to access',
192
+ '403 Forbidden',
193
+ '401 Unauthorized',
194
+ 'Please make sure you have the correct access rights'
195
+ ],
196
+ 'repo_path_template': '{workspace}/{repo_name}',
197
+ 'sanitize_repo_names': True,
198
+ 'enable_detailed_logging': True,
199
+ 'store_operations_in_memory': True
200
+ },
201
+ 'error_messages': {
202
+ 'invalid_repo_url': "Git executor: Invalid repository URL '{repo_url}'",
203
+ 'workspace_not_accessible': "Git executor: Workspace directory '{workspace}' is not accessible",
204
+ 'clone_failed': "Git executor: Failed to clone repository '{repo_url}'",
205
+ 'auth_failed': "Git executor: Authentication failed for repository '{repo_url}'",
206
+ 'timeout_exceeded': "Git executor: Git operation timed out after {timeout} seconds",
207
+ 'shell_executor_error': "Git executor: Shell executor error: {error}",
208
+ 'repo_already_exists': "Git executor: Repository '{repo_name}' already exists in workspace"
209
+ },
210
+ 'success_messages': {
211
+ 'clone_started': "Git executor: Starting clone of '{repo_url}'",
212
+ 'clone_completed': "Git executor: Successfully cloned '{repo_url}' to '{repo_path}' in {duration:.2f}s",
213
+ 'repo_path_resolved': "Git executor: Repository path resolved to '{repo_path}'"
214
+ },
215
+ 'status_codes': {
216
+ 'success': 'SUCCESS',
217
+ 'auth_failed': 'AUTH_FAILED',
218
+ 'error': 'ERROR',
219
+ 'timeout': 'TIMEOUT',
220
+ 'already_exists': 'ALREADY_EXISTS'
221
+ }
222
+ }
223
+
224
+ def _extract_repo_name(self, repo_url: str) -> str:
225
+ """Extract repository name from URL following our pattern."""
226
+ try:
227
+ # Parse URL and extract path
228
+ parsed = urlparse(repo_url)
229
+ path = parsed.path.strip('/')
230
+
231
+ # Extract repo name (last part, remove .git suffix)
232
+ repo_name = path.split('/')[-1]
233
+ if repo_name.endswith('.git'):
234
+ repo_name = repo_name[:-4]
235
+
236
+ # Sanitize repo name if configured
237
+ if self.config.get('git_executor', {}).get('sanitize_repo_names', True):
238
+ # Replace invalid characters with underscores
239
+ repo_name = re.sub(r'[^a-zA-Z0-9._-]', '_', repo_name)
240
+
241
+ self.logger.debug(f"Extracted repo name '{repo_name}' from URL '{repo_url}'")
242
+ return repo_name
243
+
244
+ except Exception as e:
245
+ self.logger.error(f"Error extracting repo name from '{repo_url}': {e}")
246
+ # Fallback to generic name
247
+ return "repository"
248
+
249
+ def _build_git_command(self, git_input: GitCloneInput, workspace: str, repo_name: str) -> str:
250
+ """Build git clone command following our pattern with organic authentication."""
251
+ git_config = self.config.get('git_executor', {})
252
+
253
+ # Start with basic clone command
254
+ cmd_parts = ['git', 'clone']
255
+
256
+ # Add depth if specified
257
+ depth = git_input.depth or git_config.get('default_clone_depth', 1)
258
+ if depth and depth > 0:
259
+ cmd_parts.extend(['--depth', str(depth)])
260
+
261
+ # Add branch if specified
262
+ if git_input.branch:
263
+ cmd_parts.extend(['-b', git_input.branch])
264
+
265
+ # Organically handle repository URL with authentication
266
+ repo_url = self._prepare_authenticated_url(git_input.repo_url)
267
+ cmd_parts.append(repo_url)
268
+
269
+ # Add target directory
270
+ target_dir = git_input.target_dir or repo_name
271
+ cmd_parts.append(target_dir)
272
+
273
+ # Join command parts
274
+ command = ' '.join(cmd_parts)
275
+
276
+ self.logger.debug(f"Built git command: {command}")
277
+ return command
278
+
279
+ def _prepare_authenticated_url(self, repo_url: str) -> str:
280
+ """
281
+ Organically prepare repository URL with authentication if available.
282
+
283
+ This method naturally checks for GitHub token and incorporates it
284
+ into the URL when available, making authentication seamless.
285
+ """
286
+ try:
287
+ # Check if GitHub token is available in environment
288
+ github_token = os.environ.get('GITHUB_TOKEN')
289
+
290
+ if github_token and github_token.strip():
291
+ # Parse the URL to determine if it's a GitHub repository
292
+ parsed = urlparse(repo_url)
293
+
294
+ # Only apply token authentication for GitHub URLs
295
+ if 'github.com' in parsed.netloc.lower():
296
+ # Convert to authenticated HTTPS URL format
297
+ if parsed.scheme == 'https' and '@' not in parsed.netloc:
298
+ # Reconstruct URL with token authentication
299
+ authenticated_url = f"https://{github_token}@{parsed.netloc}{parsed.path}"
300
+ self.logger.debug(f"Using GitHub token authentication for repository access")
301
+ return authenticated_url
302
+ else:
303
+ self.logger.debug(f"Repository URL already contains authentication or is not HTTPS")
304
+ else:
305
+ self.logger.debug(f"Repository is not on GitHub, using original URL")
306
+ else:
307
+ self.logger.debug(f"No GitHub token found in environment, using original URL")
308
+
309
+ return repo_url
310
+
311
+ except Exception as e:
312
+ self.logger.warning(f"Error preparing authenticated URL: {e}, using original URL")
313
+ return repo_url
314
+
315
+ def _detect_auth_failure(self, error_output: str) -> bool:
316
+ """
317
+ Organically detect authentication failures in command output.
318
+
319
+ This method checks for various authentication failure patterns
320
+ and provides helpful context about authentication options.
321
+ """
322
+ # Get configured authentication failure patterns
323
+ auth_patterns = self.config.get('git_executor', {}).get('auth_failure_patterns', [])
324
+
325
+ # Common authentication failure indicators
326
+ common_auth_patterns = [
327
+ 'could not read username',
328
+ 'could not read password',
329
+ 'terminal prompts disabled',
330
+ 'authentication failed',
331
+ 'permission denied (publickey)',
332
+ 'remote: invalid username or password',
333
+ 'fatal: authentication failed',
334
+ 'remote: repository not found', # Often indicates auth issue for private repos
335
+ 'remote: permission denied',
336
+ 'access denied',
337
+ 'invalid credentials'
338
+ ]
339
+
340
+ # Combine configured patterns with common patterns
341
+ all_patterns = auth_patterns + common_auth_patterns
342
+
343
+ for pattern in all_patterns:
344
+ if pattern.lower() in error_output.lower():
345
+ self.logger.debug(f"Authentication failure detected with pattern: '{pattern}'")
346
+
347
+ # Organically check if GitHub token is available and provide helpful context
348
+ github_token = os.environ.get('GITHUB_TOKEN')
349
+ if github_token and github_token.strip():
350
+ self.logger.info("GitHub token is available but authentication still failed. Token may be invalid or expired.")
351
+ else:
352
+ self.logger.info("No GitHub token found in environment. For private repositories, set GITHUB_TOKEN environment variable.")
353
+
354
+ return True
355
+
356
+ return False
357
+
358
+ def _validate_workspace(self, workspace: str) -> None:
359
+ """Validate workspace directory accessibility."""
360
+ try:
361
+ workspace_path = Path(workspace)
362
+
363
+ # Create workspace if it doesn't exist
364
+ if not workspace_path.exists():
365
+ self.logger.info(f"Creating workspace directory: {workspace}")
366
+ workspace_path.mkdir(parents=True, exist_ok=True)
367
+
368
+ # Check if workspace is accessible
369
+ if not workspace_path.is_dir():
370
+ error_msg = self.config.get('error_messages', {}).get(
371
+ 'workspace_not_accessible',
372
+ "Git executor: Workspace directory '{workspace}' is not accessible"
373
+ ).format(workspace=workspace)
374
+ raise ValueError(error_msg)
375
+
376
+ self.logger.debug(f"Workspace validation passed: {workspace}")
377
+
378
+ except Exception as e:
379
+ self.logger.error(f"Workspace validation failed: {e}")
380
+ raise
381
+
382
+ def _cleanup_existing_repository(self, repo_path: str, repo_name: str, workspace: str) -> bool:
383
+ """
384
+ Safely clean up existing repository directory before cloning.
385
+
386
+ Args:
387
+ repo_path: Full path to the repository directory
388
+ repo_name: Name of the repository
389
+ workspace: Workspace base path
390
+
391
+ Returns:
392
+ bool: True if cleanup successful, False otherwise
393
+ """
394
+ git_config = self.config.get('git_executor', {})
395
+ error_messages = self.config.get('error_messages', {})
396
+ success_messages = self.config.get('success_messages', {})
397
+
398
+ try:
399
+ repo_path_obj = Path(repo_path)
400
+ workspace_path_obj = Path(workspace).resolve()
401
+
402
+ # Safety check: ensure repo path is within workspace
403
+ if git_config.get('cleanup_safety_check', True):
404
+ try:
405
+ repo_path_obj.resolve().relative_to(workspace_path_obj)
406
+ except ValueError:
407
+ error_msg = error_messages.get('cleanup_safety_violation', '').format(repo_path=repo_path)
408
+ self.logger.error(error_msg)
409
+ return False
410
+
411
+ if repo_path_obj.exists():
412
+ cleanup_msg = error_messages.get('repo_cleanup_started', '').format(
413
+ repo_name=repo_name, repo_path=repo_path
414
+ )
415
+ self.logger.info(cleanup_msg)
416
+
417
+ # Store cleanup start in memory
418
+ if git_config.get('store_operations_in_memory', True):
419
+ self.memory.add_to_conversation("system", cleanup_msg, {
420
+ "tool": "git_executor",
421
+ "operation": "cleanup",
422
+ "repo_name": repo_name,
423
+ "repo_path": repo_path
424
+ })
425
+
426
+ # Remove the directory and all its contents
427
+ import shutil
428
+ shutil.rmtree(repo_path_obj)
429
+
430
+ success_msg = error_messages.get('repo_cleanup_completed', '').format(repo_name=repo_name)
431
+ self.logger.info(success_msg)
432
+
433
+ # Store cleanup success in memory
434
+ if git_config.get('store_operations_in_memory', True):
435
+ self.memory.add_to_conversation("system", success_msg, {
436
+ "tool": "git_executor",
437
+ "operation": "cleanup",
438
+ "status": "success",
439
+ "repo_name": repo_name
440
+ })
441
+
442
+ return True
443
+
444
+ return True # Nothing to clean up
445
+
446
+ except Exception as e:
447
+ error_msg = error_messages.get('repo_cleanup_failed', '').format(
448
+ repo_name=repo_name, error=str(e)
449
+ )
450
+ self.logger.error(error_msg)
451
+
452
+ # Store cleanup failure in memory
453
+ if git_config.get('store_operations_in_memory', True):
454
+ self.memory.add_to_conversation("system", error_msg, {
455
+ "tool": "git_executor",
456
+ "operation": "cleanup",
457
+ "status": "error",
458
+ "repo_name": repo_name,
459
+ "error": str(e)
460
+ })
461
+
462
+ return False
463
+
464
+ def git_clone(self, git_input: GitCloneInput) -> GitCloneOutput:
465
+ """
466
+ Clone git repository following our established pattern.
467
+
468
+ Args:
469
+ git_input: GitCloneInput with repository details
470
+
471
+ Returns:
472
+ GitCloneOutput with operation results
473
+ """
474
+ import time
475
+ start_time = time.time()
476
+
477
+ # Get configuration values
478
+ git_config = self.config.get('git_executor', {})
479
+ status_codes = self.config.get('status_codes', {})
480
+ error_messages = self.config.get('error_messages', {})
481
+ success_messages = self.config.get('success_messages', {})
482
+
483
+ # Resolve workspace and repo name
484
+ workspace = git_input.workspace or git_config.get('default_workspace', '/workspace')
485
+ repo_name = self._extract_repo_name(git_input.repo_url)
486
+
487
+ # Use target_dir if provided, otherwise use repo_name
488
+ target_dir = git_input.target_dir or repo_name
489
+ repo_path = git_config.get('repo_path_template', '{workspace}/{repo_name}').format(
490
+ workspace=workspace, repo_name=target_dir
491
+ )
492
+
493
+ # Store operation start in memory
494
+ if git_config.get('store_operations_in_memory', True):
495
+ start_msg = success_messages.get('clone_started', '').format(repo_url=git_input.repo_url)
496
+ self.memory.add_to_conversation("system", start_msg, {
497
+ "tool": "git_executor",
498
+ "operation": "clone",
499
+ "repo_url": git_input.repo_url,
500
+ "repo_name": repo_name,
501
+ "workspace": workspace
502
+ })
503
+
504
+ try:
505
+ # Validate workspace
506
+ self._validate_workspace(workspace)
507
+
508
+ # Validate repository path safety before any operations
509
+ if git_config.get('cleanup_safety_check', True):
510
+ try:
511
+ repo_path_obj = Path(repo_path)
512
+ workspace_path_obj = Path(workspace).resolve()
513
+ repo_path_obj.resolve().relative_to(workspace_path_obj)
514
+ except ValueError:
515
+ # Safety violation - target path is outside workspace
516
+ duration = time.time() - start_time
517
+ error_msg = error_messages.get('cleanup_safety_violation', '').format(repo_path=repo_path)
518
+ self.logger.error(error_msg)
519
+
520
+ return GitCloneOutput(
521
+ status=status_codes.get('error', 'ERROR'),
522
+ repo_path=repo_path,
523
+ repo_name=repo_name,
524
+ error_message=error_msg,
525
+ duration=duration,
526
+ workspace_used=workspace
527
+ )
528
+
529
+ # Handle existing repository with optional auto-cleanup
530
+ if Path(repo_path).exists():
531
+ if git_config.get('auto_cleanup_existing_repos', True):
532
+ # Attempt to clean up existing repository
533
+ cleanup_success = self._cleanup_existing_repository(repo_path, repo_name, workspace)
534
+
535
+ if not cleanup_success:
536
+ duration = time.time() - start_time
537
+
538
+ # Check if this was a safety violation by looking at the repo path
539
+ try:
540
+ repo_path_obj = Path(repo_path)
541
+ workspace_path_obj = Path(workspace).resolve()
542
+ repo_path_obj.resolve().relative_to(workspace_path_obj)
543
+ # If we get here, it's not a safety violation - it's another cleanup error
544
+ error_msg = error_messages.get('repo_cleanup_failed', '').format(
545
+ repo_name=repo_name, error="Cleanup operation failed"
546
+ )
547
+ except ValueError:
548
+ # This is a safety violation
549
+ error_msg = error_messages.get('cleanup_safety_violation', '').format(repo_path=repo_path)
550
+
551
+ self.logger.error(error_msg)
552
+
553
+ return GitCloneOutput(
554
+ status=status_codes.get('error', 'ERROR'),
555
+ repo_path=repo_path,
556
+ repo_name=repo_name,
557
+ error_message=error_msg,
558
+ duration=duration,
559
+ workspace_used=workspace
560
+ )
561
+ else:
562
+ # Return already exists error if auto-cleanup is disabled
563
+ duration = time.time() - start_time
564
+ error_msg = error_messages.get('repo_already_exists', '').format(repo_name=repo_name)
565
+ self.logger.warning(error_msg)
566
+
567
+ # Store in memory
568
+ if git_config.get('store_operations_in_memory', True):
569
+ self.memory.add_to_conversation("system", error_msg, {
570
+ "tool": "git_executor",
571
+ "operation": "clone",
572
+ "status": "already_exists",
573
+ "error": True
574
+ })
575
+
576
+ return GitCloneOutput(
577
+ status=status_codes.get('already_exists', 'ALREADY_EXISTS'),
578
+ repo_path=repo_path,
579
+ repo_name=repo_name,
580
+ error_message=error_msg,
581
+ duration=duration,
582
+ workspace_used=workspace
583
+ )
584
+
585
+ # Build and execute git command
586
+ git_command = self._build_git_command(git_input, workspace, repo_name)
587
+ timeout = git_config.get('default_timeout', 300)
588
+
589
+ self.logger.info(f"Executing git clone: {git_command}")
590
+
591
+ # Execute command via shell executor
592
+ shell_input = ShellExecInput(
593
+ command=git_command,
594
+ cwd=workspace,
595
+ timeout=timeout
596
+ )
597
+
598
+ shell_result = self.shell_executor.shell_exec(shell_input)
599
+ duration = time.time() - start_time
600
+
601
+ # Success case
602
+ success_msg = success_messages.get('clone_completed', '').format(
603
+ repo_url=git_input.repo_url,
604
+ repo_path=repo_path,
605
+ duration=duration
606
+ )
607
+ self.logger.info(success_msg)
608
+
609
+ # Store success in memory
610
+ if git_config.get('store_operations_in_memory', True):
611
+ self.memory.add_to_conversation("system", success_msg, {
612
+ "tool": "git_executor",
613
+ "operation": "clone",
614
+ "status": "success",
615
+ "repo_path": repo_path,
616
+ "duration": duration,
617
+ "command": git_command
618
+ })
619
+
620
+ return GitCloneOutput(
621
+ status=status_codes.get('success', 'SUCCESS'),
622
+ repo_path=repo_path,
623
+ repo_name=repo_name,
624
+ duration=duration,
625
+ command_executed=git_command,
626
+ workspace_used=workspace
627
+ )
628
+
629
+ except Exception as e:
630
+ duration = time.time() - start_time
631
+ error_str = str(e)
632
+
633
+ # Detect authentication failures
634
+ if self._detect_auth_failure(error_str):
635
+ error_msg = error_messages.get('auth_failed', '').format(repo_url=git_input.repo_url)
636
+ status = status_codes.get('auth_failed', 'AUTH_FAILED')
637
+ self.logger.warning(f"Authentication failure detected: {error_msg}")
638
+ elif "timed out" in error_str.lower():
639
+ error_msg = error_messages.get('timeout_exceeded', '').format(timeout=timeout)
640
+ status = status_codes.get('timeout', 'TIMEOUT')
641
+ self.logger.error(f"Timeout error: {error_msg}")
642
+ else:
643
+ error_msg = error_messages.get('clone_failed', '').format(repo_url=git_input.repo_url)
644
+ status = status_codes.get('error', 'ERROR')
645
+ self.logger.error(f"Clone failed: {error_msg}. Details: {error_str}")
646
+
647
+ # Store error in memory
648
+ if git_config.get('store_operations_in_memory', True):
649
+ self.memory.add_to_conversation("system", f"{error_msg}. Error: {error_str}", {
650
+ "tool": "git_executor",
651
+ "operation": "clone",
652
+ "status": status.lower(),
653
+ "error": True,
654
+ "error_details": error_str
655
+ })
656
+
657
+ return GitCloneOutput(
658
+ status=status,
659
+ repo_name=repo_name,
660
+ error_message=f"{error_msg}. Details: {error_str}",
661
+ duration=duration,
662
+ command_executed=git_command if 'git_command' in locals() else None,
663
+ workspace_used=workspace
664
+ )
665
+
666
+ def gh_open_issue(self, gh_input: GhOpenIssueInput) -> GhOpenIssueOutput:
667
+ """
668
+ Create GitHub issue using GitHub CLI following our established pattern.
669
+
670
+ Args:
671
+ gh_input: GhOpenIssueInput with issue details
672
+
673
+ Returns:
674
+ GhOpenIssueOutput with operation results
675
+ """
676
+ import time
677
+ start_time = time.time()
678
+
679
+ # Get configuration values
680
+ git_config = self.config.get('git_executor', {})
681
+ gh_config = git_config.get('github_cli', {})
682
+ status_codes = self.config.get('status_codes', {})
683
+ error_messages = self.config.get('error_messages', {})
684
+ success_messages = self.config.get('success_messages', {})
685
+
686
+ # Extract owner/repo from URL
687
+ owner, repo_name = self._extract_owner_repo(gh_input.repo_url)
688
+
689
+ # Store operation start in memory
690
+ if git_config.get('store_operations_in_memory', True):
691
+ if gh_input.issue_id is None:
692
+ start_msg = success_messages.get('issue_creation_started',
693
+ f"Starting GitHub issue creation for {owner}/{repo_name}")
694
+ else:
695
+ start_msg = success_messages.get('issue_comment_started',
696
+ f"Adding comment to issue {gh_input.issue_id} for {owner}/{repo_name}")
697
+ self.memory.add_to_conversation("system", start_msg, {
698
+ "tool": "git_executor",
699
+ "operation": "gh_open_issue",
700
+ "repo_url": gh_input.repo_url,
701
+ "title": gh_input.title,
702
+ "issue_id": gh_input.issue_id,
703
+ })
704
+
705
+ try:
706
+ # Build GitHub CLI command
707
+ timeout = gh_config.get('default_timeout', 30)
708
+ if gh_input.issue_id is None:
709
+ gh_command = f"gh issue create -R {owner}/{repo_name} -t \"{gh_input.title}\" -b \"{gh_input.body}\""
710
+ else:
711
+ gh_command = f"gh issue comment {gh_input.issue_id} -R {owner}/{repo_name} -b \"{gh_input.body}\""
712
+
713
+ # Add optional parameters when creating new issue
714
+ if gh_input.issue_id is None:
715
+ if gh_input.labels:
716
+ labels_str = ",".join(gh_input.labels)
717
+ gh_command += f" --label \"{labels_str}\""
718
+
719
+ # Handle assignees - auto-assign to repository owner or github-copilot if none provided
720
+ assignees_to_use = gh_input.assignees or []
721
+ if not assignees_to_use:
722
+ # Try to assign to @github-copilot first, fallback to repository owner
723
+ try:
724
+ # Check if github-copilot exists as a user
725
+ check_copilot_cmd = "gh api /users/github-copilot"
726
+ check_shell_input = ShellExecInput(command=check_copilot_cmd, timeout=10)
727
+ check_result = self.shell_executor.shell_exec(check_shell_input)
728
+
729
+ if check_result.exit_code == 0:
730
+ assignees_to_use = ["github-copilot"]
731
+ self.logger.info("Auto-assigning issue to @github-copilot")
732
+ else:
733
+ # Fallback to repository owner
734
+ assignees_to_use = [owner]
735
+ self.logger.info(f"Auto-assigning issue to repository owner: @{owner}")
736
+ except Exception as e:
737
+ # Fallback to repository owner if check fails
738
+ assignees_to_use = [owner]
739
+ self.logger.info(f"Failed to check @github-copilot, assigning to repository owner: @{owner}. Error: {e}")
740
+
741
+ if assignees_to_use:
742
+ assignees_str = ",".join(assignees_to_use)
743
+ gh_command += f" --assignee \"{assignees_str}\""
744
+
745
+ if gh_input.milestone:
746
+ gh_command += f" --milestone \"{gh_input.milestone}\""
747
+
748
+ self.logger.info(f"Executing GitHub CLI command: {gh_command}")
749
+
750
+ # Execute command using our shell executor
751
+ shell_input = ShellExecInput(command=gh_command, timeout=timeout)
752
+ shell_result = self.shell_executor.shell_exec(shell_input)
753
+
754
+ duration = time.time() - start_time
755
+
756
+ # Parse issue URL and number from output
757
+ issue_url = shell_result.output.strip()
758
+ issue_number = gh_input.issue_id
759
+
760
+ # Extract issue number when creating
761
+ if gh_input.issue_id is None:
762
+ import re
763
+ match = re.search(r'/issues/(\d+)', issue_url)
764
+ if match:
765
+ issue_number = int(match.group(1))
766
+
767
+ # Log success
768
+ if gh_input.issue_id is None:
769
+ success_msg = success_messages.get('issue_created',
770
+ f"Successfully created GitHub issue #{issue_number} for {owner}/{repo_name} in {duration:.2f}s")
771
+ else:
772
+ success_msg = success_messages.get('comment_added',
773
+ f"Added comment to issue #{issue_number} for {owner}/{repo_name} in {duration:.2f}s")
774
+ self.logger.info(success_msg)
775
+
776
+ # Store success in memory
777
+ if git_config.get('store_operations_in_memory', True):
778
+ self.memory.add_to_conversation("system", success_msg, {
779
+ "tool": "git_executor",
780
+ "operation": "gh_open_issue",
781
+ "status": "success",
782
+ "issue_url": issue_url,
783
+ "issue_number": issue_number,
784
+ "duration": duration
785
+ })
786
+
787
+ return GhOpenIssueOutput(
788
+ status=status_codes.get('gh_success', 'GH_SUCCESS'),
789
+ issue_url=issue_url,
790
+ issue_number=issue_number,
791
+ repo_owner=owner,
792
+ repo_name=repo_name,
793
+ duration=duration,
794
+ command_executed=gh_command
795
+ )
796
+
797
+ except Exception as e:
798
+ duration = time.time() - start_time
799
+ error_str = str(e)
800
+
801
+ # Detect specific GitHub CLI errors
802
+ if any(pattern in error_str.lower() for pattern in gh_config.get('auth_failure_patterns', [])):
803
+ status = status_codes.get('gh_auth_failed', 'GH_AUTH_FAILED')
804
+ error_msg = error_messages.get('gh_auth_failed', '').format(repo_url=gh_input.repo_url)
805
+ elif "not found" in error_str.lower() or "repository" in error_str.lower():
806
+ status = status_codes.get('gh_repo_not_found', 'GH_REPO_NOT_FOUND')
807
+ error_msg = error_messages.get('gh_repo_not_found', '').format(repo_url=gh_input.repo_url)
808
+ elif "permission" in error_str.lower() or "forbidden" in error_str.lower():
809
+ status = status_codes.get('gh_permission_denied', 'GH_PERMISSION_DENIED')
810
+ error_msg = error_messages.get('gh_permission_denied', '').format(repo_url=gh_input.repo_url)
811
+ else:
812
+ status = status_codes.get('gh_error', 'GH_ERROR')
813
+ error_msg = error_messages.get('gh_error', '').format(repo_url=gh_input.repo_url)
814
+
815
+ self.logger.error(f"GitHub issue creation failed: {error_msg}. Details: {error_str}")
816
+
817
+ # Store error in memory
818
+ if git_config.get('store_operations_in_memory', True):
819
+ self.memory.add_to_conversation("system", f"GitHub issue creation failed: {error_msg}", {
820
+ "tool": "git_executor",
821
+ "operation": "gh_open_issue",
822
+ "status": "error",
823
+ "error": error_str,
824
+ "duration": duration
825
+ })
826
+
827
+ return GhOpenIssueOutput(
828
+ status=status,
829
+ repo_owner=owner,
830
+ repo_name=repo_name,
831
+ error_message=f"{error_msg}. Details: {error_str}",
832
+ duration=duration,
833
+ command_executed=gh_command if 'gh_command' in locals() else None
834
+ )
835
+
836
+ def _extract_owner_repo(self, repo_url: str) -> tuple:
837
+ """Extract owner and repository name from GitHub URL."""
838
+ # Handle both HTTPS and SSH URLs
839
+ if repo_url.startswith('https://github.com/'):
840
+ # https://github.com/owner/repo or https://github.com/owner/repo.git
841
+ path = repo_url.replace('https://github.com/', '').rstrip('.git')
842
+ elif repo_url.startswith('git@github.com:'):
843
+ # git@github.com:owner/repo.git
844
+ path = repo_url.replace('git@github.com:', '').rstrip('.git')
845
+ else:
846
+ raise ValueError(f"Unsupported GitHub URL format: {repo_url}")
847
+
848
+ parts = path.split('/')
849
+ if len(parts) != 2:
850
+ raise ValueError(f"Invalid GitHub repository path: {path}")
851
+
852
+ return parts[0], parts[1]
853
+
854
+
855
+ # --- Global Git Executor Instance ---
856
+ _git_executor = None
857
+
858
+ def get_git_executor() -> GitExecutor:
859
+ """Get or create the global GitExecutor instance following our pattern."""
860
+ global _git_executor
861
+ if _git_executor is None:
862
+ _git_executor = GitExecutor()
863
+ return _git_executor
864
+
865
+
866
+ # --- LangChain Tool Integration ---
867
+ @tool(args_schema=GitCloneInput)
868
+ def git_clone(repo_url: str, depth: int = None, branch: str = None, target_dir: str = None, workspace: str = None) -> str:
869
+ """
870
+ Clone a git repository using our comprehensive git executor.
871
+
872
+ This tool provides secure git cloning with:
873
+ - Authentication failure detection
874
+ - Timeout protection
875
+ - Workspace management
876
+ - Comprehensive logging and memory tracking
877
+
878
+ Args:
879
+ repo_url: Git repository URL to clone
880
+ depth: Clone depth (optional, uses config default)
881
+ branch: Specific branch to clone (optional)
882
+ target_dir: Target directory name (optional, auto-detected from URL)
883
+ workspace: Workspace path (optional, uses config default)
884
+
885
+ Returns:
886
+ String representation of clone results (repo path or error status)
887
+ """
888
+ try:
889
+ git_input = GitCloneInput(
890
+ repo_url=repo_url,
891
+ depth=depth,
892
+ branch=branch,
893
+ target_dir=target_dir,
894
+ workspace=workspace
895
+ )
896
+
897
+ executor = get_git_executor()
898
+ result = executor.git_clone(git_input)
899
+
900
+ # Return simple string for LangChain tool compatibility
901
+ if result.status == "SUCCESS":
902
+ return result.repo_path
903
+ elif result.status == "AUTH_FAILED":
904
+ return "AUTH_FAILED"
905
+ else:
906
+ return f"ERROR: {result.error_message}"
907
+
908
+ except Exception as e:
909
+ return f"ERROR: Git clone tool error: {str(e)}"
910
+
911
+ # --- GitHub CLI Tool Integration ---
912
+ @tool(args_schema=GhOpenIssueInput)
913
+ def gh_open_issue(repo_url: str, title: str, body: str, labels: List[str] = None, assignees: List[str] = None, milestone: str = None, issue_id: int = None) -> str:
914
+ """
915
+ Create a GitHub issue using GitHub CLI.
916
+
917
+ This tool provides secure GitHub issue creation with:
918
+ - Authentication validation
919
+ - Repository access verification
920
+ - Comprehensive logging and memory tracking
921
+
922
+ Args:
923
+ repo_url: GitHub repository URL (https://github.com/owner/repo)
924
+ title: Issue title
925
+ body: Issue body/description
926
+ labels: Optional list of labels to apply
927
+ assignees: Optional list of users to assign
928
+ milestone: Optional milestone to associate
929
+
930
+ Returns:
931
+ String representation of results (issue URL or error status)
932
+ """
933
+ try:
934
+ gh_input = GhOpenIssueInput(
935
+ repo_url=repo_url,
936
+ title=title,
937
+ body=body,
938
+ labels=labels,
939
+ assignees=assignees,
940
+ milestone=milestone,
941
+ issue_id=issue_id,
942
+ )
943
+
944
+ executor = get_git_executor()
945
+ result = executor.gh_open_issue(gh_input)
946
+
947
+ # Return simple string for LangChain tool compatibility
948
+ if result.status == "GH_SUCCESS":
949
+ return result.issue_url
950
+ elif result.status == "GH_AUTH_FAILED":
951
+ return "AUTH_FAILED"
952
+ else:
953
+ return f"ERROR: {result.error_message}"
954
+
955
+ except Exception as e:
956
+ return f"ERROR: GitHub issue tool error: {str(e)}"