gitops-by-veera 1.0.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,12 @@
1
+ """gitops-by-veera — conversational autonomous Git and GitHub operations coordinator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "1.0.0"
6
+ __author__ = "Veera"
7
+ __license__ = "MIT"
8
+
9
+ from gitops_by_veera.agent import GitOpsAgent
10
+ from gitops_by_veera.exceptions import GitOpsError
11
+
12
+ __all__ = ["GitOpsAgent", "GitOpsError", "__version__"]
@@ -0,0 +1,436 @@
1
+ """Central orchestration engine: LLM calling, model cascading, execution, and self-healing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ import requests
12
+ from pydantic import ValidationError
13
+
14
+ from gitops_by_veera.config import find_repo_root
15
+ from gitops_by_veera.constants import (
16
+ DIRECT_EVAL_MAP,
17
+ DIRECT_EVAL_PATTERNS,
18
+ GITHUB_API_BASE,
19
+ GITHUB_RATE_LIMIT_REMAINING_HEADER,
20
+ GITHUB_RATE_LIMIT_RESET_HEADER,
21
+ GROQ_API_URL,
22
+ GROQ_CONNECT_TIMEOUT,
23
+ GROQ_MAX_RETRIES,
24
+ GROQ_READ_TIMEOUT,
25
+ GROQ_RETRY_STATUSES,
26
+ MODEL_CASCADE,
27
+ MODEL_TIER_3,
28
+ REMEDIATION_PROMPT,
29
+ SYSTEM_PROMPT,
30
+ )
31
+ from gitops_by_veera.exceptions import (
32
+ ExecutionError,
33
+ GitHubAPIError,
34
+ GitHubRateLimitError,
35
+ GroqAPIError,
36
+ GroqRateLimitError,
37
+ GroqTimeoutError,
38
+ ModelCascadeExhaustedError,
39
+ ParseError,
40
+ RemediationLimitError,
41
+ )
42
+ from gitops_by_veera.logger import get_logger
43
+ from gitops_by_veera.models import (
44
+ CloudOperation,
45
+ ExecutionPlan,
46
+ LocalOperation,
47
+ OperationResult,
48
+ SessionMetrics,
49
+ )
50
+ from gitops_by_veera.validator import validate_plan
51
+
52
+ logger = get_logger("gitops.agent")
53
+
54
+ MAX_REMEDIATION_CYCLES = 2
55
+
56
+
57
+ class GitOpsAgent:
58
+ """Orchestrates LLM planning, validation, execution, and remediation."""
59
+
60
+ def __init__(
61
+ self,
62
+ groq_api_key: str,
63
+ github_token: str,
64
+ repo_root: Optional[Path] = None,
65
+ debug: bool = False,
66
+ ) -> None:
67
+ self._groq_key = groq_api_key
68
+ self._github_token = github_token
69
+ self._repo_root = repo_root or find_repo_root() or Path.cwd()
70
+ self._debug = debug
71
+ self._prompt_cache: dict[str, ExecutionPlan] = {}
72
+ self.metrics = SessionMetrics()
73
+
74
+ # ------------------------------------------------------------------
75
+ # Public interface
76
+ # ------------------------------------------------------------------
77
+
78
+ def build_plan(self, prompt: str) -> ExecutionPlan:
79
+ """Convert a natural-language prompt into a validated execution plan.
80
+
81
+ Uses the direct-evaluation bypass for trivial inspection commands.
82
+ Falls back to LLM cascade otherwise. Results are cached per prompt.
83
+ """
84
+ direct = self._try_direct_eval(prompt)
85
+ if direct is not None:
86
+ logger.debug("Direct evaluation bypassed LLM for prompt: %r", prompt)
87
+ return direct
88
+
89
+ if prompt in self._prompt_cache:
90
+ logger.debug("Cache hit for prompt: %r", prompt)
91
+ return self._prompt_cache[prompt]
92
+
93
+ plan = self._call_llm_cascade(prompt, SYSTEM_PROMPT)
94
+ self._prompt_cache[prompt] = plan
95
+ return plan
96
+
97
+ def validate(self, plan: ExecutionPlan) -> list[str]:
98
+ """Validate the plan and return warning messages for risky operations."""
99
+ return validate_plan(plan, self._repo_root)
100
+
101
+ def execute(self, plan: ExecutionPlan, dry_run: bool = False) -> list[OperationResult]:
102
+ """Execute all operations in the plan sequentially.
103
+
104
+ If dry_run is True, operations are validated but not executed.
105
+ Failures trigger the self-healing remediation loop.
106
+ """
107
+ start = time.time()
108
+ results: list[OperationResult] = []
109
+ remediation_cycles = 0
110
+
111
+ for idx, op in enumerate(plan.operations):
112
+ if dry_run:
113
+ logger.info("[DRY-RUN] Would execute operation %d: %s", idx, op.type)
114
+ continue
115
+
116
+ if isinstance(op, LocalOperation):
117
+ result = self._run_local(idx, op)
118
+ else:
119
+ result = self._run_cloud(idx, op) # type: ignore[arg-type]
120
+
121
+ results.append(result)
122
+
123
+ if not result.success:
124
+ self.metrics.record_failure()
125
+ if remediation_cycles >= MAX_REMEDIATION_CYCLES:
126
+ raise RemediationLimitError(
127
+ f"Remediation limit ({MAX_REMEDIATION_CYCLES}) reached. "
128
+ "Please resolve the issue manually."
129
+ )
130
+ remediation_cycles += 1
131
+ return results # caller handles remediation loop
132
+
133
+ self.metrics.record_success()
134
+
135
+ self.metrics.total_execution_duration_seconds = time.time() - start
136
+ return results
137
+
138
+ def remediate(self, failed_result: OperationResult) -> ExecutionPlan:
139
+ """Ask the Tier 3 model for a remediation plan for a failed operation."""
140
+ self.metrics.record_remediation()
141
+ safe_context = {
142
+ "operation_type": failed_result.operation_type,
143
+ "error_message": failed_result.error_message,
144
+ "returncode": failed_result.returncode,
145
+ "stderr": failed_result.stderr[:500],
146
+ }
147
+ prompt = (
148
+ f"The following operation failed:\n{json.dumps(safe_context, indent=2)}\n\n"
149
+ "Suggest a safe remediation plan."
150
+ )
151
+ return self._call_llm_cascade(prompt, REMEDIATION_PROMPT, prefer_tier3=True)
152
+
153
+ # ------------------------------------------------------------------
154
+ # Direct evaluation
155
+ # ------------------------------------------------------------------
156
+
157
+ def _try_direct_eval(self, prompt: str) -> Optional[ExecutionPlan]:
158
+ for pattern in DIRECT_EVAL_PATTERNS:
159
+ if pattern.match(prompt.strip()):
160
+ key = self._classify_direct(prompt)
161
+ args = DIRECT_EVAL_MAP.get(key, ["status"])
162
+ raw = {
163
+ "operations": [
164
+ {
165
+ "type": "local",
166
+ "binary": "git",
167
+ "args": args,
168
+ "risk_level": "safe",
169
+ "reason": f"Direct evaluation of '{prompt.strip()}'.",
170
+ }
171
+ ]
172
+ }
173
+ return ExecutionPlan(**raw)
174
+ return None
175
+
176
+ @staticmethod
177
+ def _classify_direct(prompt: str) -> str:
178
+ p = prompt.strip().lower()
179
+ if "diff" in p:
180
+ return "diff"
181
+ if "log" in p or "history" in p:
182
+ return "log"
183
+ return "status"
184
+
185
+ # ------------------------------------------------------------------
186
+ # LLM cascade
187
+ # ------------------------------------------------------------------
188
+
189
+ def _call_llm_cascade(
190
+ self, prompt: str, system_prompt: str, prefer_tier3: bool = False
191
+ ) -> ExecutionPlan:
192
+ models = [MODEL_TIER_3] if prefer_tier3 else MODEL_CASCADE
193
+
194
+ last_error: Exception = ModelCascadeExhaustedError("No models available.")
195
+
196
+ for model in models:
197
+ try:
198
+ logger.debug("Trying model: %s", model)
199
+ self.metrics.record_llm_call(model)
200
+ raw_json = self._call_groq(model, prompt, system_prompt)
201
+ plan = self._parse_plan(raw_json)
202
+ return plan
203
+ except (ParseError, ValidationError) as exc:
204
+ logger.warning("Model %s produced invalid JSON. Cascading. %s", model, exc)
205
+ self.metrics.record_fallback()
206
+ last_error = exc
207
+ except (GroqRateLimitError, GroqTimeoutError, GroqAPIError) as exc:
208
+ logger.warning("Model %s failed: %s. Cascading.", model, exc)
209
+ self.metrics.record_fallback()
210
+ last_error = exc
211
+
212
+ raise ModelCascadeExhaustedError(
213
+ f"All model tiers exhausted. Last error: {last_error}"
214
+ ) from last_error
215
+
216
+ def _call_groq(self, model: str, prompt: str, system_prompt: str) -> dict[str, Any]:
217
+ headers = {
218
+ "Authorization": f"Bearer {self._groq_key}",
219
+ "Content-Type": "application/json",
220
+ }
221
+ body = {
222
+ "model": model,
223
+ "messages": [
224
+ {"role": "system", "content": system_prompt},
225
+ {"role": "user", "content": prompt},
226
+ ],
227
+ "response_format": {"type": "json_object"},
228
+ "temperature": 0.1,
229
+ }
230
+
231
+ backoff = 1.0
232
+ for attempt in range(GROQ_MAX_RETRIES):
233
+ try:
234
+ response = requests.post(
235
+ GROQ_API_URL,
236
+ headers=headers,
237
+ json=body,
238
+ timeout=(GROQ_CONNECT_TIMEOUT, GROQ_READ_TIMEOUT),
239
+ )
240
+ except requests.Timeout:
241
+ logger.warning(
242
+ "Groq API timeout (attempt %d/%d). Retrying...", attempt + 1, GROQ_MAX_RETRIES
243
+ )
244
+ if attempt < GROQ_MAX_RETRIES - 1:
245
+ time.sleep(backoff)
246
+ backoff *= 2
247
+ continue
248
+ raise GroqTimeoutError("Groq API timed out after all retries.")
249
+ except requests.RequestException as exc:
250
+ raise GroqAPIError(f"Groq network error: {exc}") from exc
251
+
252
+ if response.status_code == 200:
253
+ try:
254
+ return response.json()["choices"][0]["message"]["content"]
255
+ except (KeyError, IndexError, json.JSONDecodeError) as exc:
256
+ raise ParseError(f"Unexpected Groq response structure: {exc}") from exc
257
+
258
+ if response.status_code in GROQ_RETRY_STATUSES:
259
+ logger.warning(
260
+ "Groq returned %d (attempt %d/%d). Retrying in %.0fs...",
261
+ response.status_code,
262
+ attempt + 1,
263
+ GROQ_MAX_RETRIES,
264
+ backoff,
265
+ )
266
+ if attempt < GROQ_MAX_RETRIES - 1:
267
+ time.sleep(backoff)
268
+ backoff *= 2
269
+ continue
270
+ raise GroqRateLimitError(
271
+ f"Groq rate limit persists after {GROQ_MAX_RETRIES} retries.",
272
+ status_code=response.status_code,
273
+ )
274
+
275
+ raise GroqAPIError(
276
+ f"Groq API error {response.status_code}: {response.text[:200]}",
277
+ status_code=response.status_code,
278
+ )
279
+
280
+ raise GroqAPIError("Groq retry loop exited without a result.") # pragma: no cover
281
+
282
+ def _parse_plan(self, raw: Any) -> ExecutionPlan:
283
+ if isinstance(raw, str):
284
+ try:
285
+ data = json.loads(raw)
286
+ except json.JSONDecodeError as exc:
287
+ raise ParseError(f"LLM returned non-JSON string: {exc}") from exc
288
+ elif isinstance(raw, dict):
289
+ data = raw
290
+ else:
291
+ raise ParseError(f"Unexpected LLM output type: {type(raw)}")
292
+
293
+ try:
294
+ return ExecutionPlan(**data)
295
+ except (ValidationError, TypeError) as exc:
296
+ raise ParseError(f"Pydantic validation failed: {exc}") from exc
297
+
298
+ # ------------------------------------------------------------------
299
+ # Local execution
300
+ # ------------------------------------------------------------------
301
+
302
+ def _run_local(self, idx: int, op: LocalOperation) -> OperationResult:
303
+ cmd = [op.binary] + op.args
304
+ logger.info("Executing local operation %d: %s", idx, " ".join(cmd))
305
+ try:
306
+ proc = subprocess.run(
307
+ cmd,
308
+ cwd=str(self._repo_root),
309
+ capture_output=True,
310
+ text=True,
311
+ shell=False,
312
+ timeout=60,
313
+ )
314
+ success = proc.returncode == 0
315
+ if not success:
316
+ logger.warning(
317
+ "Local operation %d failed (rc=%d): %s", idx, proc.returncode, proc.stderr[:300]
318
+ )
319
+ return OperationResult(
320
+ operation_index=idx,
321
+ operation_type="local",
322
+ success=success,
323
+ stdout=proc.stdout,
324
+ stderr=proc.stderr,
325
+ returncode=proc.returncode,
326
+ error_message=proc.stderr if not success else "",
327
+ )
328
+ except subprocess.TimeoutExpired:
329
+ return OperationResult(
330
+ operation_index=idx,
331
+ operation_type="local",
332
+ success=False,
333
+ error_message="Command timed out after 60 seconds.",
334
+ returncode=-1,
335
+ )
336
+ except FileNotFoundError as exc:
337
+ return OperationResult(
338
+ operation_index=idx,
339
+ operation_type="local",
340
+ success=False,
341
+ error_message=f"Executable not found: {exc}",
342
+ returncode=-1,
343
+ )
344
+
345
+ # ------------------------------------------------------------------
346
+ # Cloud execution
347
+ # ------------------------------------------------------------------
348
+
349
+ def _run_cloud(self, idx: int, op: CloudOperation) -> OperationResult:
350
+ url = f"{GITHUB_API_BASE}{op.endpoint}"
351
+ headers = {
352
+ "Authorization": f"Bearer {self._github_token}",
353
+ "Accept": "application/vnd.github+json",
354
+ "X-GitHub-Api-Version": "2022-11-28",
355
+ "Content-Type": "application/json",
356
+ }
357
+ logger.info("Executing cloud operation %d: %s %s", idx, op.method, op.endpoint)
358
+
359
+ backoff = 2.0
360
+ for attempt in range(3):
361
+ try:
362
+ response = requests.request(
363
+ op.method,
364
+ url,
365
+ headers=headers,
366
+ json=op.payload if op.payload else None,
367
+ timeout=(10, 30),
368
+ )
369
+ except requests.Timeout:
370
+ return OperationResult(
371
+ operation_index=idx,
372
+ operation_type="cloud",
373
+ success=False,
374
+ error_message="GitHub API request timed out.",
375
+ status_code=0,
376
+ )
377
+ except requests.RequestException as exc:
378
+ return OperationResult(
379
+ operation_index=idx,
380
+ operation_type="cloud",
381
+ success=False,
382
+ error_message=f"Network error: {exc}",
383
+ status_code=0,
384
+ )
385
+
386
+ if response.status_code in (403, 429):
387
+ remaining = response.headers.get(GITHUB_RATE_LIMIT_REMAINING_HEADER)
388
+ reset = response.headers.get(GITHUB_RATE_LIMIT_RESET_HEADER)
389
+
390
+ if remaining == "0" and reset:
391
+ wait = max(0, int(reset) - int(time.time())) + 1
392
+ logger.warning("GitHub rate limit hit. Waiting %ds until reset.", wait)
393
+ time.sleep(min(wait, 60))
394
+ else:
395
+ logger.warning(
396
+ "GitHub %d response (attempt %d/3). Retrying in %.0fs...",
397
+ response.status_code,
398
+ attempt + 1,
399
+ backoff,
400
+ )
401
+ time.sleep(backoff)
402
+ backoff *= 2
403
+ continue
404
+
405
+ success = response.status_code in (200, 201, 204)
406
+ body_text = ""
407
+ try:
408
+ body_text = json.dumps(response.json())
409
+ except Exception:
410
+ body_text = response.text[:500]
411
+
412
+ if not success:
413
+ logger.warning(
414
+ "Cloud operation %d failed (%d): %s",
415
+ idx,
416
+ response.status_code,
417
+ body_text[:300],
418
+ )
419
+
420
+ return OperationResult(
421
+ operation_index=idx,
422
+ operation_type="cloud",
423
+ success=success,
424
+ stdout=body_text if success else "",
425
+ stderr=body_text if not success else "",
426
+ status_code=response.status_code,
427
+ error_message=body_text if not success else "",
428
+ )
429
+
430
+ return OperationResult(
431
+ operation_index=idx,
432
+ operation_type="cloud",
433
+ success=False,
434
+ error_message="GitHub API rate limit persists after retries. Please try later.",
435
+ status_code=429,
436
+ )