prismor-cli 1.3.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.
prismor/api.py ADDED
@@ -0,0 +1,770 @@
1
+ """API client for Prismor security scanning service."""
2
+
3
+ import os
4
+ import re
5
+ import time
6
+ import requests
7
+ from typing import Optional, Dict, Any
8
+ from urllib.parse import urlparse
9
+
10
+
11
+ def _active_org_id() -> Optional[str]:
12
+ """The org the user switched to via `prismor org switch`, if any."""
13
+ try:
14
+ from prismor import cli_config
15
+ return cli_config.active_org_id()
16
+ except Exception:
17
+ return None
18
+
19
+ DEFAULT_SCAN_POLL_INTERVAL_SECONDS = max(int(os.environ.get("PRISMOR_SCAN_POLL_INTERVAL_SECONDS", "5")), 1)
20
+ DEFAULT_SCAN_MAX_WAIT_SECONDS = max(int(os.environ.get("PRISMOR_SCAN_MAX_WAIT_SECONDS", "1800")), 60)
21
+ DEFAULT_SCAN_STATUS_RETRY_LIMIT = max(int(os.environ.get("PRISMOR_SCAN_STATUS_RETRY_LIMIT", "5")), 0)
22
+
23
+
24
+ class PrismorAPIError(Exception):
25
+ """Custom exception for Prismor API errors."""
26
+ pass
27
+
28
+
29
+ def retry_request(func, max_retries=3, backoff_factor=2):
30
+ """Retry a request function with exponential backoff.
31
+
32
+ Args:
33
+ func: Function to retry (should return requests.Response)
34
+ max_retries: Maximum number of retry attempts
35
+ backoff_factor: Multiplier for delay between retries
36
+
37
+ Returns:
38
+ Response from the function
39
+
40
+ Raises:
41
+ Exception: If all retries fail
42
+ """
43
+ last_exception = None
44
+
45
+ for attempt in range(max_retries):
46
+ try:
47
+ return func()
48
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
49
+ last_exception = e
50
+ if attempt < max_retries - 1:
51
+ delay = backoff_factor ** attempt
52
+ time.sleep(delay)
53
+ continue
54
+ raise
55
+ except Exception as e:
56
+ # Don't retry on other exceptions
57
+ raise
58
+
59
+ # If we get here, all retries failed
60
+ raise last_exception
61
+
62
+
63
+ def parse_github_repo(repo_input: str) -> str:
64
+ """Extract user/repo_name from various GitHub URL formats or return as-is if already in correct format.
65
+
66
+ This function handles multiple GitHub URL formats:
67
+ - user/repo_name (already in correct format)
68
+ - https://github.com/user/repo_name
69
+ - https://www.github.com/user/repo_name
70
+ - http://github.com/user/repo_name
71
+ - http://www.github.com/user/repo_name
72
+ - github.com/user/repo_name
73
+ - www.github.com/user/repo_name
74
+ - git@github.com:user/repo_name.git
75
+ - https://github.com/user/repo_name.git
76
+ - https://github.com/user/repo_name/
77
+ - https://github.com/user/repo_name#branch
78
+ - https://github.com/user/repo_name/tree/branch
79
+ - https://github.com/user/repo_name/blob/branch/file
80
+
81
+ Args:
82
+ repo_input: Repository input in any of the supported formats
83
+
84
+ Returns:
85
+ Repository in "user/repo_name" format
86
+
87
+ Raises:
88
+ PrismorAPIError: If the input format is not recognized or invalid
89
+ """
90
+ if not repo_input or not isinstance(repo_input, str):
91
+ raise PrismorAPIError("Repository input cannot be empty")
92
+
93
+ # Validate repository name characters (GitHub allows alphanumeric, hyphens, underscores, dots)
94
+ def validate_repo_part(part: str, part_name: str) -> None:
95
+ if not part:
96
+ raise PrismorAPIError(f"{part_name} cannot be empty")
97
+ if len(part) > 100:
98
+ raise PrismorAPIError(f"{part_name} is too long (max 100 characters)")
99
+ # GitHub allows alphanumeric, hyphens, underscores, dots, but not starting/ending with special chars
100
+ if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$', part):
101
+ raise PrismorAPIError(
102
+ f"Invalid {part_name}: '{part}'. Must contain only alphanumeric characters, "
103
+ "hyphens, underscores, or dots, and cannot start or end with special characters."
104
+ )
105
+
106
+ repo_input = repo_input.strip()
107
+
108
+ # If it's already in user/repo format (no protocol, no domain), return as-is
109
+ if "/" in repo_input and not any(repo_input.startswith(prefix) for prefix in
110
+ ["http://", "https://", "git@", "github.com", "www.github.com"]):
111
+ # Validate it has exactly one slash and both parts are non-empty
112
+ parts = repo_input.split("/")
113
+ if len(parts) == 2 and parts[0] and parts[1]:
114
+ validate_repo_part(parts[0], "Username")
115
+ validate_repo_part(parts[1], "Repository name")
116
+ return repo_input
117
+ else:
118
+ raise PrismorAPIError(f"Invalid repository format: {repo_input}. Expected 'user/repo_name'")
119
+
120
+ # Handle SSH format: git@github.com:user/repo.git
121
+ if repo_input.startswith("git@github.com:"):
122
+ repo_part = repo_input[15:] # Remove "git@github.com:"
123
+ # Remove .git suffix if present
124
+ if repo_part.endswith(".git"):
125
+ repo_part = repo_part[:-4]
126
+ if "/" in repo_part:
127
+ return repo_part
128
+ else:
129
+ raise PrismorAPIError(f"Invalid SSH repository format: {repo_input}")
130
+
131
+ # Handle HTTP/HTTPS URLs
132
+ if repo_input.startswith(("http://", "https://")):
133
+ try:
134
+ parsed = urlparse(repo_input)
135
+ hostname = parsed.hostname.lower()
136
+
137
+ # Check if it's a GitHub URL
138
+ if hostname in ["github.com", "www.github.com"]:
139
+ path = parsed.path.strip("/")
140
+
141
+ # Remove .git suffix if present
142
+ if path.endswith(".git"):
143
+ path = path[:-4]
144
+
145
+ # Split path and extract user/repo
146
+ path_parts = path.split("/")
147
+ if len(path_parts) >= 2:
148
+ user = path_parts[0]
149
+ repo = path_parts[1]
150
+
151
+ # Handle special GitHub paths like /tree/branch, /blob/branch/file
152
+ if len(path_parts) > 2 and path_parts[2] in ["tree", "blob"]:
153
+ # This is a branch/file reference, just take user/repo
154
+ pass
155
+
156
+ if user and repo:
157
+ return f"{user}/{repo}"
158
+ else:
159
+ raise PrismorAPIError(f"Invalid GitHub URL format: {repo_input}")
160
+ else:
161
+ raise PrismorAPIError(f"Invalid GitHub URL format: {repo_input}")
162
+ else:
163
+ raise PrismorAPIError(f"Not a GitHub URL: {repo_input}")
164
+
165
+ except Exception as e:
166
+ raise PrismorAPIError(f"Failed to parse URL: {repo_input}. Error: {str(e)}")
167
+
168
+ # Handle bare domain formats: github.com/user/repo or www.github.com/user/repo
169
+ if repo_input.startswith(("github.com/", "www.github.com/")):
170
+ # Remove domain prefix
171
+ if repo_input.startswith("github.com/"):
172
+ repo_part = repo_input[11:] # Remove "github.com/"
173
+ else: # www.github.com/
174
+ repo_part = repo_input[15:] # Remove "www.github.com/"
175
+
176
+ # Remove .git suffix if present
177
+ if repo_part.endswith(".git"):
178
+ repo_part = repo_part[:-4]
179
+
180
+ # Remove trailing slash
181
+ repo_part = repo_part.rstrip("/")
182
+
183
+ # Split and validate
184
+ parts = repo_part.split("/")
185
+ if len(parts) >= 2:
186
+ user = parts[0]
187
+ repo = parts[1]
188
+
189
+ # Handle special GitHub paths like /tree/branch, /blob/branch/file
190
+ if len(parts) > 2 and parts[2] in ["tree", "blob"]:
191
+ # This is a branch/file reference, just take user/repo
192
+ pass
193
+
194
+ if user and repo:
195
+ return f"{user}/{repo}"
196
+ else:
197
+ raise PrismorAPIError(f"Invalid repository format: {repo_input}")
198
+ else:
199
+ raise PrismorAPIError(f"Invalid repository format: {repo_input}")
200
+
201
+ # If we get here, the format is not recognized
202
+ raise PrismorAPIError(
203
+ f"Unrecognized repository format: {repo_input}. "
204
+ "Supported formats: 'user/repo', 'https://github.com/user/repo', "
205
+ "'git@github.com:user/repo.git', or 'github.com/user/repo'"
206
+ )
207
+
208
+
209
+ class PrismorClient:
210
+ """Client for interacting with Prismor API."""
211
+
212
+ def __init__(self, api_key: Optional[str] = None):
213
+ """Initialize the Prismor API client.
214
+
215
+ Args:
216
+ api_key: Prismor API key. If not provided, will look for PRISMOR_API_KEY env var.
217
+ """
218
+ self.api_key = api_key or os.environ.get("PRISMOR_API_KEY")
219
+ if not self.api_key:
220
+ raise PrismorAPIError(
221
+ "PRISMOR_API_KEY environment variable is not set. "
222
+ "Please specify your API key. You can generate one for free at https://www.prismor.dev/cli\n"
223
+ "Set it with: export PRISMOR_API_KEY=your_api_key"
224
+ )
225
+
226
+ # self.base_url = "http://localhost:3000"
227
+ self.base_url = os.environ.get("PRISMOR_CLI_URL", "https://prismor.dev")
228
+ self.headers = {
229
+ "Authorization": f"Bearer {self.api_key}",
230
+ "Content-Type": "application/json"
231
+ }
232
+
233
+ def authenticate(self) -> Dict[str, Any]:
234
+ """Authenticate with the Prismor API using the API key.
235
+
236
+ Returns:
237
+ Dictionary containing user information and repositories
238
+
239
+ Raises:
240
+ PrismorAPIError: If authentication fails
241
+ """
242
+ try:
243
+ response = requests.post(
244
+ f"{self.base_url}/api/cli/auth",
245
+ json={"apiKey": self.api_key},
246
+ headers={"Content-Type": "application/json"},
247
+ timeout=30
248
+ )
249
+
250
+ if response.status_code == 401:
251
+ raise PrismorAPIError("Invalid API key. Please check your PRISMOR_API_KEY.")
252
+
253
+ if response.status_code == 400:
254
+ raise PrismorAPIError("API key is required.")
255
+
256
+ if response.status_code >= 400:
257
+ error_msg = response.json().get("error", "Authentication failed")
258
+ raise PrismorAPIError(f"Authentication error: {error_msg}")
259
+
260
+ response.raise_for_status()
261
+ return response.json()
262
+
263
+ except requests.exceptions.Timeout:
264
+ raise PrismorAPIError("Authentication request timed out.")
265
+ except requests.exceptions.ConnectionError:
266
+ raise PrismorAPIError(
267
+ "Failed to connect to Prismor API. Please check your internet connection."
268
+ )
269
+ except requests.exceptions.RequestException as e:
270
+ raise PrismorAPIError(f"Authentication request failed: {str(e)}")
271
+
272
+ def normalize_repo_url(self, repo: str) -> str:
273
+ """Normalize repository input to a full GitHub URL.
274
+
275
+ Args:
276
+ repo: Repository in various formats (username/repo, GitHub URL, etc.)
277
+
278
+ Returns:
279
+ Full GitHub repository URL
280
+ """
281
+ # Use the comprehensive parser to extract user/repo_name
282
+ repo_name = parse_github_repo(repo)
283
+
284
+ # Convert to full GitHub URL
285
+ return f"https://github.com/{repo_name}"
286
+
287
+ def list_orgs(self) -> Dict[str, Any]:
288
+ """List the organizations this API key's user belongs to."""
289
+ resp = requests.post(
290
+ f"{self.base_url}/api/cli/orgs",
291
+ json={"api_key": self.api_key},
292
+ headers=self.headers,
293
+ timeout=30,
294
+ )
295
+ if resp.status_code == 401:
296
+ raise PrismorAPIError("Invalid API key. Please check your PRISMOR_API_KEY.")
297
+ resp.raise_for_status()
298
+ return resp.json()
299
+
300
+ # ── Org controls (policy-as-code, read-only listings) ──────────────────
301
+ def _org_param(self, org_id: Optional[str]) -> str:
302
+ if not org_id:
303
+ try:
304
+ from prismor import cli_config
305
+ org_id = cli_config.active_org_id()
306
+ except Exception:
307
+ org_id = None
308
+ return f"?orgId={org_id}" if org_id else ""
309
+
310
+ def _cli_admin_get(self, path: str, org_id: Optional[str]) -> Dict[str, Any]:
311
+ resp = requests.get(f"{self.base_url}{path}{self._org_param(org_id)}", headers=self.headers, timeout=30)
312
+ if not resp.ok:
313
+ try:
314
+ msg = resp.json().get("message") or resp.text
315
+ except Exception:
316
+ msg = resp.text
317
+ raise PrismorAPIError(msg or f"Request failed ({resp.status_code})")
318
+ return resp.json()
319
+
320
+ def get_org_policy(self, org_id: Optional[str] = None) -> Dict[str, Any]:
321
+ """Fetch the active org policy (YAML + version)."""
322
+ return self._cli_admin_get("/api/cli/admin/policy", org_id)
323
+
324
+ def apply_org_policy(self, yaml_text: str, org_id: Optional[str] = None, dry_run: bool = False) -> Dict[str, Any]:
325
+ """Apply (or dry-run validate) an org policy YAML. Requires an admin-scoped key."""
326
+ body: Dict[str, Any] = {"yaml": yaml_text, "dryRun": dry_run}
327
+ if not org_id:
328
+ try:
329
+ from prismor import cli_config
330
+ org_id = cli_config.active_org_id()
331
+ except Exception:
332
+ org_id = None
333
+ if org_id:
334
+ body["orgId"] = org_id
335
+ resp = requests.post(
336
+ f"{self.base_url}/api/cli/admin/policy",
337
+ json=body,
338
+ headers={**self.headers, "Content-Type": "application/json"},
339
+ timeout=30,
340
+ )
341
+ if not resp.ok:
342
+ try:
343
+ msg = resp.json().get("message") or resp.text
344
+ except Exception:
345
+ msg = resp.text
346
+ raise PrismorAPIError(msg or f"Apply failed ({resp.status_code})")
347
+ return resp.json()
348
+
349
+ def list_devices(self, org_id: Optional[str] = None) -> Dict[str, Any]:
350
+ return self._cli_admin_get("/api/cli/admin/devices", org_id)
351
+
352
+ def list_members(self, org_id: Optional[str] = None) -> Dict[str, Any]:
353
+ return self._cli_admin_get("/api/cli/admin/members", org_id)
354
+
355
+ def scan(
356
+ self,
357
+ repo: str,
358
+ scan: bool = False,
359
+ sbom: bool = False,
360
+ detect_secret: bool = False,
361
+ fullscan: bool = False,
362
+ branch: Optional[str] = None,
363
+ action_id: Optional[str] = None
364
+ ) -> Dict[str, Any]:
365
+ """Perform security scan on a GitHub repository.
366
+
367
+ Args:
368
+ repo: Repository URL or username/repo format
369
+ scan: Enable vulnerability scanning
370
+ sbom: Enable SBOM generation
371
+ detect_secret: Enable secret detection
372
+ fullscan: Enable all scan types
373
+ branch: Specific branch to scan (defaults to main/master)
374
+
375
+ Returns:
376
+ Dictionary containing scan results
377
+ """
378
+ repo_url = self.normalize_repo_url(repo)
379
+
380
+ # First authenticate to get user info
381
+ auth_response = self.authenticate()
382
+ user_info = auth_response.get("user", {})
383
+
384
+ # Prepare request payload for CLI scan
385
+ payload = {
386
+ "repo_url": repo_url,
387
+ "api_key": self.api_key,
388
+ "scan": scan or fullscan,
389
+ "sbom": sbom or fullscan,
390
+ "detect_secret": detect_secret or fullscan,
391
+ "fullscan": fullscan,
392
+ "branch": branch,
393
+ "action_id": action_id,
394
+ "github_token": os.environ.get("GITHUB_TOKEN"),
395
+ "org_id": _active_org_id(), # attribute to the active org (prismor org switch)
396
+ }
397
+
398
+ try:
399
+ # Note: Vulnerability scans now run asynchronously and can take up to 10 minutes
400
+ # The web API handles polling internally, so we just need a longer timeout
401
+ # Use retry logic for network resilience
402
+ def make_request():
403
+ return requests.post(
404
+ f"{self.base_url}/api/cli/scan",
405
+ json=payload,
406
+ headers={"Content-Type": "application/json"},
407
+ timeout=600 # 10 minute timeout to accommodate async vulnerability scans
408
+ )
409
+
410
+ response = retry_request(make_request, max_retries=3)
411
+
412
+ if response.status_code == 401:
413
+ error_data = response.json()
414
+ if error_data.get("action") == "integrate_github":
415
+ raise PrismorAPIError(
416
+ f"{error_data.get('message', 'GitHub integration required')}\n"
417
+ f"Please visit: {error_data.get('integration_url', 'https://prismor.dev/dashboard')}"
418
+ )
419
+ raise PrismorAPIError("Invalid API key. Please check your PRISMOR_API_KEY.")
420
+
421
+ if response.status_code == 404:
422
+ raise PrismorAPIError("CLI scan endpoint not found. Please check if CLI endpoints are available.")
423
+
424
+ if response.status_code >= 400:
425
+ error_msg = response.json().get("error", "Unknown error")
426
+ raise PrismorAPIError(f"API error: {error_msg}")
427
+
428
+ response.raise_for_status()
429
+ result = response.json()
430
+
431
+ # Handle the new response format from CLI endpoint
432
+ if result.get("ok") and result.get("status") == "accepted" and "job_id" in result:
433
+ job_id = result["job_id"]
434
+ poll_interval = DEFAULT_SCAN_POLL_INTERVAL_SECONDS
435
+ max_wait_seconds = DEFAULT_SCAN_MAX_WAIT_SECONDS
436
+ status_retry_limit = DEFAULT_SCAN_STATUS_RETRY_LIMIT
437
+ started_at = time.time()
438
+ transient_failures = 0
439
+
440
+ # Poll for completion with a hard timeout to avoid infinite hangs in CI.
441
+ while True:
442
+ elapsed = time.time() - started_at
443
+ if elapsed > max_wait_seconds:
444
+ raise PrismorAPIError(
445
+ f"Scan timed out after {max_wait_seconds} seconds. "
446
+ f"Job ID: {job_id}. Check status with: prismor scan-status {job_id}"
447
+ )
448
+
449
+ time.sleep(poll_interval)
450
+
451
+ try:
452
+ status_data = self.check_scan_status(job_id)
453
+ transient_failures = 0
454
+ status = status_data.get("status")
455
+
456
+ if status in {"completed", "success"}:
457
+ # Return the results
458
+ if "results" in status_data:
459
+ return status_data["results"]
460
+ return status_data
461
+
462
+ if status in {"failed", "error", "cancelled"}:
463
+ error_msg = status_data.get("error") or status_data.get("message") or "Unknown error"
464
+ raise PrismorAPIError(f"Scan failed: {error_msg}")
465
+
466
+ except PrismorAPIError as e:
467
+ error_text = str(e).lower()
468
+ is_transient = (
469
+ "timed out" in error_text
470
+ or "failed to connect" in error_text
471
+ or "request failed" in error_text
472
+ or "502" in error_text
473
+ or "503" in error_text
474
+ or "504" in error_text
475
+ )
476
+ if is_transient and transient_failures < status_retry_limit:
477
+ transient_failures += 1
478
+ continue
479
+ raise
480
+
481
+ if result.get("ok") and "results" in result:
482
+ return result["results"]
483
+ return result
484
+
485
+ except requests.exceptions.Timeout:
486
+ raise PrismorAPIError(
487
+ "Request timed out. The repository scan is taking longer than expected. "
488
+ "Large repositories may require more time. Please try again or check the dashboard for results."
489
+ )
490
+ except requests.exceptions.ConnectionError:
491
+ raise PrismorAPIError(
492
+ "Failed to connect to Prismor API. Please check your internet connection."
493
+ )
494
+ except requests.exceptions.RequestException as e:
495
+ raise PrismorAPIError(f"Request failed: {str(e)}")
496
+
497
+ def start_vulnerability_scan(
498
+ self,
499
+ repo: str,
500
+ branch: Optional[str] = None,
501
+ github_token: Optional[str] = None
502
+ ) -> Dict[str, Any]:
503
+ """Start a vulnerability scan and return immediately with a job_id.
504
+
505
+ This method directly calls the backend API to start an async scan.
506
+ Use check_scan_status() to poll for completion.
507
+
508
+ Args:
509
+ repo: Repository URL or username/repo format
510
+ branch: Specific branch to scan (defaults to main)
511
+ github_token: Optional GitHub token. If not provided, will try to get from env var GITHUB_TOKEN
512
+
513
+ Returns:
514
+ Dictionary containing job_id and status information
515
+
516
+ Raises:
517
+ PrismorAPIError: If request fails
518
+ """
519
+ # Directly call the backend API
520
+ backend_url = os.environ.get(
521
+ "PRISMOR_BACKEND_URL",
522
+ "https://2dlxuia6i5.execute-api.us-east-1.amazonaws.com/prod"
523
+ )
524
+
525
+ repo_url = self.normalize_repo_url(repo)
526
+
527
+ # Get GitHub token from parameter, env var, or raise error
528
+ gh_token = github_token or os.environ.get("GITHUB_TOKEN")
529
+
530
+ if not gh_token:
531
+ raise PrismorAPIError(
532
+ "GitHub token required. Provide it as --token, set the GITHUB_TOKEN environment variable, "
533
+ "or use 'prismor --repo <repo> --scan' which handles GitHub authentication automatically."
534
+ )
535
+
536
+ try:
537
+ response = requests.post(
538
+ f"{backend_url}/scan",
539
+ json={
540
+ "repo_url": repo_url,
541
+ "token": gh_token,
542
+ "branch": branch or "main"
543
+ },
544
+ headers={"Content-Type": "application/json"},
545
+ timeout=30
546
+ )
547
+
548
+ if response.status_code >= 400:
549
+ error_msg = response.json().get("error", "Unknown error")
550
+ raise PrismorAPIError(f"API error: {error_msg}")
551
+
552
+ response.raise_for_status()
553
+ return response.json()
554
+
555
+ except requests.exceptions.Timeout:
556
+ raise PrismorAPIError("Request timed out.")
557
+ except requests.exceptions.ConnectionError:
558
+ raise PrismorAPIError(
559
+ "Failed to connect to Prismor API. Please check your internet connection."
560
+ )
561
+ except requests.exceptions.RequestException as e:
562
+ raise PrismorAPIError(f"Request failed: {str(e)}")
563
+
564
+ def check_scan_status(self, job_id: str) -> Dict[str, Any]:
565
+ """Check the status of a vulnerability scan job.
566
+
567
+ This method directly calls the backend API to check scan status.
568
+ Use this when you have a job_id from starting an async scan.
569
+
570
+ Args:
571
+ job_id: The job ID returned from starting a scan
572
+
573
+ Returns:
574
+ Dictionary containing scan status and results if completed
575
+
576
+ Raises:
577
+ PrismorAPIError: If request fails
578
+ """
579
+
580
+ # Call the Web API to check status (which checks the DB populate by callbacks)
581
+ # We do NOT call the backend directly because the async worker relies on callbacks to update state
582
+
583
+ try:
584
+ response = requests.get(
585
+ f"{self.base_url}/api/cli/scan/status/{job_id}",
586
+ headers={
587
+ "Content-Type": "application/json",
588
+ # Authenticate so the control plane returns the (org-scoped)
589
+ # scan results, not just the bare status. The key also
590
+ # scopes the job to the caller's org.
591
+ "Authorization": f"Bearer {self.api_key}",
592
+ },
593
+ params={"api_key": self.api_key},
594
+ timeout=30
595
+ )
596
+
597
+ if response.status_code == 404:
598
+ raise PrismorAPIError(f"Scan job '{job_id}' not found.")
599
+
600
+ if response.status_code >= 400:
601
+ error_msg = response.json().get("error", "Unknown error")
602
+ raise PrismorAPIError(f"API error: {error_msg}")
603
+
604
+ response.raise_for_status()
605
+ return response.json()
606
+
607
+ except requests.exceptions.Timeout:
608
+ raise PrismorAPIError("Request timed out.")
609
+ except requests.exceptions.ConnectionError:
610
+ raise PrismorAPIError(
611
+ "Failed to connect to Prismor API. Please check your internet connection."
612
+ )
613
+ except requests.exceptions.RequestException as e:
614
+ raise PrismorAPIError(f"Request failed: {str(e)}")
615
+
616
+ def trigger_autofix(
617
+ self,
618
+ repo: str,
619
+ branch: Optional[str] = None,
620
+ instruction: Optional[str] = None
621
+ ) -> Dict[str, Any]:
622
+ """Trigger AI auto-fix for a repository and return a job_id immediately.
623
+
624
+ Args:
625
+ repo: Repository URL or username/repo format
626
+ branch: Base branch to apply fixes on (defaults to main)
627
+ instruction: Custom fix instruction; if omitted a sensible default is used
628
+
629
+ Returns:
630
+ Dictionary containing job_id and status
631
+ """
632
+ repo_url = self.normalize_repo_url(repo)
633
+
634
+ payload: Dict[str, Any] = {
635
+ "api_key": self.api_key,
636
+ "repo_url": repo_url,
637
+ "branch": branch,
638
+ "org_id": _active_org_id(), # attribute to the active org (prismor org switch)
639
+ }
640
+ if instruction:
641
+ payload["instruction"] = instruction
642
+
643
+ try:
644
+ response = requests.post(
645
+ f"{self.base_url}/api/cli/fix",
646
+ json=payload,
647
+ headers={"Content-Type": "application/json"},
648
+ timeout=30,
649
+ )
650
+
651
+ if response.status_code == 401:
652
+ error_data = response.json()
653
+ raise PrismorAPIError(error_data.get("error", "Unauthorized"))
654
+
655
+ if response.status_code >= 400:
656
+ error_msg = response.json().get("error", "Unknown error")
657
+ raise PrismorAPIError(f"API error: {error_msg}")
658
+
659
+ response.raise_for_status()
660
+ return response.json()
661
+
662
+ except requests.exceptions.Timeout:
663
+ raise PrismorAPIError("Request timed out.")
664
+ except requests.exceptions.ConnectionError:
665
+ raise PrismorAPIError(
666
+ "Failed to connect to Prismor API. Please check your internet connection."
667
+ )
668
+ except requests.exceptions.RequestException as e:
669
+ raise PrismorAPIError(f"Request failed: {str(e)}")
670
+
671
+ def check_fix_status(self, job_id: str) -> Dict[str, Any]:
672
+ """Check the status of an auto-fix job.
673
+
674
+ Args:
675
+ job_id: The job ID returned by trigger_autofix()
676
+
677
+ Returns:
678
+ Dictionary containing job status, pr_url, etc.
679
+ """
680
+ try:
681
+ response = requests.get(
682
+ f"{self.base_url}/api/cli/fix/status/{job_id}",
683
+ params={"api_key": self.api_key},
684
+ headers={"Content-Type": "application/json"},
685
+ timeout=30,
686
+ )
687
+
688
+ if response.status_code == 404:
689
+ raise PrismorAPIError(f"Fix job '{job_id}' not found.")
690
+
691
+ if response.status_code >= 400:
692
+ error_msg = response.json().get("error", "Unknown error")
693
+ raise PrismorAPIError(f"API error: {error_msg}")
694
+
695
+ response.raise_for_status()
696
+ return response.json()
697
+
698
+ except requests.exceptions.Timeout:
699
+ raise PrismorAPIError("Request timed out.")
700
+ except requests.exceptions.ConnectionError:
701
+ raise PrismorAPIError(
702
+ "Failed to connect to Prismor API. Please check your internet connection."
703
+ )
704
+ except requests.exceptions.RequestException as e:
705
+ raise PrismorAPIError(f"Request failed: {str(e)}")
706
+
707
+ def get_repositories(self) -> Dict[str, Any]:
708
+ """Get user's repositories.
709
+
710
+ Returns:
711
+ Dictionary containing user repositories
712
+
713
+ Raises:
714
+ PrismorAPIError: If request fails
715
+ """
716
+ auth_response = self.authenticate()
717
+ user_info = auth_response.get("user", {})
718
+ return {
719
+ "repositories": user_info.get("repositories", []),
720
+ "user": {
721
+ "id": user_info.get("id"),
722
+ "email": user_info.get("email"),
723
+ "name": user_info.get("name")
724
+ }
725
+ }
726
+
727
+ def get_repository_by_name(self, repo_name: str) -> Dict[str, Any]:
728
+ """Get repository ID by repository name.
729
+
730
+ Args:
731
+ repo_name: Repository name (e.g., "username/repo")
732
+
733
+ Returns:
734
+ Dictionary containing repository information including ID
735
+
736
+ Raises:
737
+ PrismorAPIError: If request fails
738
+ """
739
+ try:
740
+ response = requests.post(
741
+ f"{self.base_url}/api/repositories/by-name",
742
+ json={
743
+ "apiKey": self.api_key,
744
+ "repoName": repo_name
745
+ },
746
+ headers={"Content-Type": "application/json"},
747
+ timeout=30
748
+ )
749
+
750
+ if response.status_code == 401:
751
+ raise PrismorAPIError("Invalid API key. Please check your PRISMOR_API_KEY.")
752
+
753
+ if response.status_code == 404:
754
+ raise PrismorAPIError(f"Repository '{repo_name}' not found.")
755
+
756
+ if response.status_code >= 400:
757
+ error_msg = response.json().get("error", "Unknown error")
758
+ raise PrismorAPIError(f"API error: {error_msg}")
759
+
760
+ response.raise_for_status()
761
+ return response.json()
762
+
763
+ except requests.exceptions.Timeout:
764
+ raise PrismorAPIError("Request timed out.")
765
+ except requests.exceptions.ConnectionError:
766
+ raise PrismorAPIError(
767
+ "Failed to connect to Prismor API. Please check your internet connection."
768
+ )
769
+ except requests.exceptions.RequestException as e:
770
+ raise PrismorAPIError(f"Request failed: {str(e)}")