monkeyscode 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.
monkeyscode/ci.py ADDED
@@ -0,0 +1,288 @@
1
+ """
2
+ MonkeysCode SDK — CI/CD Helpers.
3
+
4
+ Utilities for running MonkeysCode in CI pipelines:
5
+ - Headless runs with structured output
6
+ - SARIF report generation for GitHub Code Scanning
7
+ - GitHub Actions annotations
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from monkeyscode.agent import MonkeysCode
18
+ from monkeyscode.events import AgentEvent, AgentResult, CompleteEvent, ErrorEvent, TextEvent
19
+
20
+
21
+ @dataclass
22
+ class CiFinding:
23
+ """A single finding/issue reported by the agent."""
24
+
25
+ severity: str # 'error', 'warning', 'note', 'high', 'medium', 'low'
26
+ message: str
27
+ file: str | None = None
28
+ line: int | None = None
29
+ end_line: int | None = None
30
+ column: int | None = None
31
+ rule_id: str | None = None
32
+ category: str | None = None
33
+
34
+
35
+ @dataclass
36
+ class CiResult:
37
+ """Result from a CI run."""
38
+
39
+ success: bool
40
+ exit_code: int
41
+ result: AgentResult
42
+ findings: list[CiFinding] = field(default_factory=list)
43
+ events: list[AgentEvent] = field(default_factory=list)
44
+ duration_ms: int = 0
45
+
46
+
47
+ # Exit codes
48
+ CI_EXIT_SUCCESS = 0
49
+ CI_EXIT_ERROR = 1
50
+ CI_EXIT_FINDINGS = 10
51
+ CI_EXIT_HIGH_SEVERITY = 11
52
+
53
+
54
+ async def ci_run(
55
+ *,
56
+ prompt: str,
57
+ api_key: str | None = None,
58
+ model: str = "auto",
59
+ working_directory: str | None = None,
60
+ diff: str | None = None,
61
+ timeout: int = 600_000,
62
+ on_event: Any = None,
63
+ ) -> CiResult:
64
+ """
65
+ Run the agent in headless CI mode.
66
+
67
+ Args:
68
+ prompt: The instruction for the agent.
69
+ api_key: API key.
70
+ model: Model to use.
71
+ working_directory: Working directory.
72
+ diff: Git diff content to provide as context.
73
+ timeout: Timeout in ms.
74
+ on_event: Optional callback for streaming events.
75
+
76
+ Returns:
77
+ CiResult with findings, exit code, and events.
78
+ """
79
+ start_time = time.monotonic()
80
+ events: list[AgentEvent] = []
81
+
82
+ agent = MonkeysCode(
83
+ api_key=api_key,
84
+ model=model,
85
+ working_directory=working_directory,
86
+ timeout=timeout,
87
+ permissions_mode="autoApprove",
88
+ )
89
+
90
+ # Build prompt with diff context
91
+ full_prompt = prompt
92
+ if diff:
93
+ full_prompt = f"{prompt}\n\nHere is the diff to review:\n```diff\n{diff}\n```"
94
+
95
+ async for event in agent.stream(full_prompt):
96
+ events.append(event)
97
+ if on_event:
98
+ on_event(event)
99
+
100
+ # Build result
101
+ result = AgentResult(success=False, summary="Agent failed", model=model)
102
+ for event in events:
103
+ if isinstance(event, CompleteEvent):
104
+ result = AgentResult(
105
+ success=True,
106
+ summary=event.summary,
107
+ files_changed=event.files_changed,
108
+ tokens=event.tokens,
109
+ cost=event.cost,
110
+ duration_ms=event.duration_ms,
111
+ model=model,
112
+ )
113
+ break
114
+ if isinstance(event, ErrorEvent):
115
+ result = AgentResult(
116
+ success=False,
117
+ summary=event.message,
118
+ exit_code=1,
119
+ model=model,
120
+ )
121
+
122
+ # Extract findings
123
+ findings = _extract_findings(events)
124
+
125
+ # Determine exit code
126
+ exit_code = CI_EXIT_SUCCESS
127
+ if not result.success:
128
+ exit_code = CI_EXIT_ERROR
129
+ elif any(f.severity in ("high", "error") for f in findings):
130
+ exit_code = CI_EXIT_HIGH_SEVERITY
131
+ elif findings:
132
+ exit_code = CI_EXIT_FINDINGS
133
+
134
+ duration_ms = int((time.monotonic() - start_time) * 1000)
135
+
136
+ return CiResult(
137
+ success=result.success,
138
+ exit_code=exit_code,
139
+ result=result,
140
+ findings=findings,
141
+ events=events,
142
+ duration_ms=duration_ms,
143
+ )
144
+
145
+
146
+ def to_sarif(result: CiResult) -> dict[str, Any]:
147
+ """Convert a CI result to SARIF 2.1.0 format."""
148
+ rules: dict[str, dict[str, Any]] = {}
149
+ sarif_results: list[dict[str, Any]] = []
150
+
151
+ for finding in result.findings:
152
+ rule_id = finding.rule_id or finding.category or "monkeyscode-review"
153
+
154
+ if rule_id not in rules:
155
+ rules[rule_id] = {
156
+ "id": rule_id,
157
+ "shortDescription": {"text": finding.category or rule_id},
158
+ "defaultConfiguration": {"level": _map_severity(finding.severity)},
159
+ }
160
+
161
+ sarif_result: dict[str, Any] = {
162
+ "ruleId": rule_id,
163
+ "message": {"text": finding.message},
164
+ "level": _map_severity(finding.severity),
165
+ "locations": [],
166
+ }
167
+
168
+ if finding.file:
169
+ location: dict[str, Any] = {
170
+ "physicalLocation": {
171
+ "artifactLocation": {"uri": finding.file},
172
+ },
173
+ }
174
+ if finding.line:
175
+ location["physicalLocation"]["region"] = {
176
+ "startLine": finding.line,
177
+ }
178
+ if finding.end_line:
179
+ location["physicalLocation"]["region"]["endLine"] = finding.end_line
180
+ sarif_result["locations"].append(location)
181
+
182
+ sarif_results.append(sarif_result)
183
+
184
+ return {
185
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
186
+ "version": "2.1.0",
187
+ "runs": [
188
+ {
189
+ "tool": {
190
+ "driver": {
191
+ "name": "MonkeysCode",
192
+ "version": "2.0.0",
193
+ "informationUri": "https://monkeyscode.com",
194
+ "rules": list(rules.values()),
195
+ },
196
+ },
197
+ "results": sarif_results,
198
+ },
199
+ ],
200
+ }
201
+
202
+
203
+ def print_github_annotations(findings: list[CiFinding]) -> None:
204
+ """Print GitHub Actions annotations for inline PR display."""
205
+ for finding in findings:
206
+ level = "error" if finding.severity in ("high", "error") else "warning"
207
+ file_part = f"file={finding.file}," if finding.file else ""
208
+ line_part = f"line={finding.line}," if finding.line else ""
209
+ title = finding.rule_id or "MonkeysCode Review"
210
+ print(f"::{level} {file_part}{line_part}title={title}::{finding.message}")
211
+
212
+
213
+ def print_ci_summary(result: CiResult) -> None:
214
+ """Print a human-readable CI summary."""
215
+ findings = result.findings
216
+ errors = sum(1 for f in findings if f.severity in ("high", "error"))
217
+ warnings = sum(1 for f in findings if f.severity in ("medium", "warning"))
218
+ notes = sum(1 for f in findings if f.severity in ("low", "note"))
219
+
220
+ print()
221
+ print("MonkeysCode CI Review")
222
+ print(f" Duration: {result.duration_ms / 1000:.1f}s")
223
+ print(f" Findings: {len(findings)} total")
224
+ if errors:
225
+ print(f" Errors: {errors}")
226
+ if warnings:
227
+ print(f" Warnings: {warnings}")
228
+ if notes:
229
+ print(f" Notes: {notes}")
230
+ print(f" Exit code: {result.exit_code}")
231
+ print()
232
+
233
+
234
+ # ── Private ──────────────────────────────────────────────────────────
235
+
236
+
237
+ def _extract_findings(events: list[AgentEvent]) -> list[CiFinding]:
238
+ """Extract findings from agent events."""
239
+ findings: list[CiFinding] = []
240
+
241
+ for event in events:
242
+ if not isinstance(event, TextEvent):
243
+ continue
244
+
245
+ for line in event.content.split("\n"):
246
+ # BUG/ERROR/VULNERABILITY patterns
247
+ bug_match = re.match(
248
+ r"(?:BUG|ERROR|CRITICAL|VULNERABILITY):\s*(.+)", line, re.IGNORECASE,
249
+ )
250
+ if bug_match:
251
+ findings.append(
252
+ CiFinding(severity="high", message=bug_match.group(1).strip(), category="bug"),
253
+ )
254
+
255
+ # WARNING patterns
256
+ warn_match = re.match(r"(?:WARNING|WARN|ISSUE):\s*(.+)", line, re.IGNORECASE)
257
+ if warn_match:
258
+ findings.append(
259
+ CiFinding(
260
+ severity="warning", message=warn_match.group(1).strip(), category="warning",
261
+ ),
262
+ )
263
+
264
+ # File:line patterns
265
+ file_match = re.match(r"([^\s:]+\.[a-z]+):(\d+):\s*(.+)", line, re.IGNORECASE)
266
+ if file_match:
267
+ findings.append(
268
+ CiFinding(
269
+ severity="warning",
270
+ file=file_match.group(1),
271
+ line=int(file_match.group(2)),
272
+ message=file_match.group(3).strip(),
273
+ ),
274
+ )
275
+
276
+ return findings
277
+
278
+
279
+ def _map_severity(severity: str) -> str:
280
+ """Map severity to SARIF level."""
281
+ return {
282
+ "error": "error",
283
+ "high": "error",
284
+ "warning": "warning",
285
+ "medium": "warning",
286
+ "note": "note",
287
+ "low": "note",
288
+ }.get(severity, "warning")