diagram-to-iac 0.7.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.
- diagram_to_iac/__init__.py +10 -0
- diagram_to_iac/actions/__init__.py +7 -0
- diagram_to_iac/actions/git_entry.py +174 -0
- diagram_to_iac/actions/supervisor_entry.py +116 -0
- diagram_to_iac/actions/terraform_agent_entry.py +207 -0
- diagram_to_iac/agents/__init__.py +26 -0
- diagram_to_iac/agents/demonstrator_langgraph/__init__.py +10 -0
- diagram_to_iac/agents/demonstrator_langgraph/agent.py +826 -0
- diagram_to_iac/agents/git_langgraph/__init__.py +10 -0
- diagram_to_iac/agents/git_langgraph/agent.py +1018 -0
- diagram_to_iac/agents/git_langgraph/pr.py +146 -0
- diagram_to_iac/agents/hello_langgraph/__init__.py +9 -0
- diagram_to_iac/agents/hello_langgraph/agent.py +621 -0
- diagram_to_iac/agents/policy_agent/__init__.py +15 -0
- diagram_to_iac/agents/policy_agent/agent.py +507 -0
- diagram_to_iac/agents/policy_agent/integration_example.py +191 -0
- diagram_to_iac/agents/policy_agent/tools/__init__.py +14 -0
- diagram_to_iac/agents/policy_agent/tools/tfsec_tool.py +259 -0
- diagram_to_iac/agents/shell_langgraph/__init__.py +21 -0
- diagram_to_iac/agents/shell_langgraph/agent.py +122 -0
- diagram_to_iac/agents/shell_langgraph/detector.py +50 -0
- diagram_to_iac/agents/supervisor_langgraph/__init__.py +17 -0
- diagram_to_iac/agents/supervisor_langgraph/agent.py +1947 -0
- diagram_to_iac/agents/supervisor_langgraph/demonstrator.py +22 -0
- diagram_to_iac/agents/supervisor_langgraph/guards.py +23 -0
- diagram_to_iac/agents/supervisor_langgraph/pat_loop.py +49 -0
- diagram_to_iac/agents/supervisor_langgraph/router.py +9 -0
- diagram_to_iac/agents/terraform_langgraph/__init__.py +15 -0
- diagram_to_iac/agents/terraform_langgraph/agent.py +1216 -0
- diagram_to_iac/agents/terraform_langgraph/parser.py +76 -0
- diagram_to_iac/core/__init__.py +7 -0
- diagram_to_iac/core/agent_base.py +19 -0
- diagram_to_iac/core/enhanced_memory.py +302 -0
- diagram_to_iac/core/errors.py +4 -0
- diagram_to_iac/core/issue_tracker.py +49 -0
- diagram_to_iac/core/memory.py +132 -0
- diagram_to_iac/services/__init__.py +10 -0
- diagram_to_iac/services/observability.py +59 -0
- diagram_to_iac/services/step_summary.py +77 -0
- diagram_to_iac/tools/__init__.py +11 -0
- diagram_to_iac/tools/api_utils.py +108 -26
- diagram_to_iac/tools/git/__init__.py +45 -0
- diagram_to_iac/tools/git/git.py +956 -0
- diagram_to_iac/tools/hello/__init__.py +30 -0
- diagram_to_iac/tools/hello/cal_utils.py +31 -0
- diagram_to_iac/tools/hello/text_utils.py +97 -0
- diagram_to_iac/tools/llm_utils/__init__.py +20 -0
- diagram_to_iac/tools/llm_utils/anthropic_driver.py +87 -0
- diagram_to_iac/tools/llm_utils/base_driver.py +90 -0
- diagram_to_iac/tools/llm_utils/gemini_driver.py +89 -0
- diagram_to_iac/tools/llm_utils/openai_driver.py +93 -0
- diagram_to_iac/tools/llm_utils/router.py +303 -0
- diagram_to_iac/tools/sec_utils.py +4 -2
- diagram_to_iac/tools/shell/__init__.py +17 -0
- diagram_to_iac/tools/shell/shell.py +415 -0
- diagram_to_iac/tools/text_utils.py +277 -0
- diagram_to_iac/tools/tf/terraform.py +851 -0
- diagram_to_iac-0.8.0.dist-info/METADATA +99 -0
- diagram_to_iac-0.8.0.dist-info/RECORD +64 -0
- {diagram_to_iac-0.7.0.dist-info → diagram_to_iac-0.8.0.dist-info}/WHEEL +1 -1
- diagram_to_iac-0.8.0.dist-info/entry_points.txt +4 -0
- diagram_to_iac/agents/codegen_agent.py +0 -0
- diagram_to_iac/agents/consensus_agent.py +0 -0
- diagram_to_iac/agents/deployment_agent.py +0 -0
- diagram_to_iac/agents/github_agent.py +0 -0
- diagram_to_iac/agents/interpretation_agent.py +0 -0
- diagram_to_iac/agents/question_agent.py +0 -0
- diagram_to_iac/agents/supervisor.py +0 -0
- diagram_to_iac/agents/vision_agent.py +0 -0
- diagram_to_iac/core/config.py +0 -0
- diagram_to_iac/tools/cv_utils.py +0 -0
- diagram_to_iac/tools/gh_utils.py +0 -0
- diagram_to_iac/tools/tf_utils.py +0 -0
- diagram_to_iac-0.7.0.dist-info/METADATA +0 -16
- diagram_to_iac-0.7.0.dist-info/RECORD +0 -32
- diagram_to_iac-0.7.0.dist-info/entry_points.txt +0 -2
- {diagram_to_iac-0.7.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)}"
|