kaizen-loop 0.1.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.
- kaizen/__init__.py +1 -0
- kaizen/__main__.py +4 -0
- kaizen/agent.py +349 -0
- kaizen/cli.py +92 -0
- kaizen/config.py +31 -0
- kaizen/findings.py +53 -0
- kaizen/git.py +208 -0
- kaizen/loop.py +248 -0
- kaizen/orchestrator.py +159 -0
- kaizen/review_prompt.py +66 -0
- kaizen/run.py +93 -0
- kaizen/steps/__init__.py +11 -0
- kaizen/steps/pr.py +96 -0
- kaizen/steps/push.py +23 -0
- kaizen/steps/review.py +55 -0
- kaizen/work_prompt.py +43 -0
- kaizen_loop-0.1.0.dist-info/METADATA +10 -0
- kaizen_loop-0.1.0.dist-info/RECORD +21 -0
- kaizen_loop-0.1.0.dist-info/WHEEL +4 -0
- kaizen_loop-0.1.0.dist-info/entry_points.txt +2 -0
- kaizen_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
kaizen/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
kaizen/__main__.py
ADDED
kaizen/agent.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import http.client
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from urllib.parse import quote, urlparse
|
|
11
|
+
|
|
12
|
+
_RETRYABLE_STATUS = frozenset({502, 503, 504})
|
|
13
|
+
_MAX_RETRIES = 2
|
|
14
|
+
_RETRY_BASE_DELAY = 0.5
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _validate_server_url(url: str | None) -> None:
|
|
18
|
+
if url is None:
|
|
19
|
+
return
|
|
20
|
+
parsed = urlparse(url)
|
|
21
|
+
if parsed.scheme not in ("http", "https"):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Invalid server URL: {url!r}. Must start with http:// or https://"
|
|
24
|
+
)
|
|
25
|
+
if not parsed.hostname:
|
|
26
|
+
raise ValueError(f"Invalid server URL: {url!r}. No hostname found.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AgentResult:
|
|
31
|
+
output: dict
|
|
32
|
+
text: str = ""
|
|
33
|
+
input_tokens: int = 0
|
|
34
|
+
output_tokens: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Session:
|
|
38
|
+
def __init__(self, agent: "OpenCodeAgent", session_id: str):
|
|
39
|
+
self._agent = agent
|
|
40
|
+
self._id = session_id
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def id(self) -> str:
|
|
44
|
+
return self._id
|
|
45
|
+
|
|
46
|
+
def send(self, prompt: str, schema: dict | None = None) -> AgentResult:
|
|
47
|
+
try:
|
|
48
|
+
return self._agent._send_message(self._id, prompt, schema)
|
|
49
|
+
except Exception:
|
|
50
|
+
self._agent._abort_session(self._id)
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OpenCodeAgent:
|
|
55
|
+
def __init__(self, bin_path: str = "opencode", server_url: str | None = None):
|
|
56
|
+
_validate_server_url(server_url)
|
|
57
|
+
self.bin_path = bin_path
|
|
58
|
+
self._external_server = server_url
|
|
59
|
+
self._process: subprocess.Popen | None = None
|
|
60
|
+
self._base_url: str | None = server_url
|
|
61
|
+
self._port: int | None = None
|
|
62
|
+
self._conn: http.client.HTTPConnection | None = None
|
|
63
|
+
|
|
64
|
+
def _get_conn(self) -> http.client.HTTPConnection:
|
|
65
|
+
if self._conn is not None:
|
|
66
|
+
return self._conn
|
|
67
|
+
url = self._base_url
|
|
68
|
+
if not url:
|
|
69
|
+
raise RuntimeError("No server URL configured")
|
|
70
|
+
parsed = urlparse(url)
|
|
71
|
+
self._conn = http.client.HTTPConnection(
|
|
72
|
+
parsed.hostname or "127.0.0.1",
|
|
73
|
+
parsed.port or 80,
|
|
74
|
+
timeout=300,
|
|
75
|
+
)
|
|
76
|
+
return self._conn
|
|
77
|
+
|
|
78
|
+
def _reconnect(self) -> http.client.HTTPConnection:
|
|
79
|
+
self._close_conn()
|
|
80
|
+
return self._get_conn()
|
|
81
|
+
|
|
82
|
+
def _close_conn(self) -> None:
|
|
83
|
+
if self._conn:
|
|
84
|
+
try:
|
|
85
|
+
self._conn.close()
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
self._conn = None
|
|
89
|
+
|
|
90
|
+
def _http_request(
|
|
91
|
+
self,
|
|
92
|
+
method: str,
|
|
93
|
+
path: str,
|
|
94
|
+
body: dict | None = None,
|
|
95
|
+
timeout: float | None = None,
|
|
96
|
+
max_retries: int = _MAX_RETRIES,
|
|
97
|
+
) -> dict:
|
|
98
|
+
conn = self._get_conn()
|
|
99
|
+
if timeout is not None:
|
|
100
|
+
conn.timeout = timeout
|
|
101
|
+
headers = {"Accept": "application/json"}
|
|
102
|
+
data = None
|
|
103
|
+
if body is not None:
|
|
104
|
+
data = json.dumps(body).encode()
|
|
105
|
+
headers["Content-Type"] = "application/json"
|
|
106
|
+
|
|
107
|
+
last_err: Exception | None = None
|
|
108
|
+
for attempt in range(max_retries + 1):
|
|
109
|
+
try:
|
|
110
|
+
conn.request(method, path, body=data, headers=headers)
|
|
111
|
+
resp = conn.getresponse()
|
|
112
|
+
resp_data = resp.read()
|
|
113
|
+
if 200 <= resp.status < 300:
|
|
114
|
+
return json.loads(resp_data)
|
|
115
|
+
if resp.status in _RETRYABLE_STATUS and attempt < max_retries:
|
|
116
|
+
last_err = RuntimeError(f"HTTP {resp.status}")
|
|
117
|
+
time.sleep(_RETRY_BASE_DELAY * (2**attempt))
|
|
118
|
+
conn = self._reconnect()
|
|
119
|
+
continue
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"HTTP {resp.status}: {resp_data.decode(errors='replace')}"
|
|
122
|
+
)
|
|
123
|
+
except (
|
|
124
|
+
ConnectionError,
|
|
125
|
+
OSError,
|
|
126
|
+
http.client.HTTPException,
|
|
127
|
+
) as e:
|
|
128
|
+
if attempt < max_retries:
|
|
129
|
+
last_err = e
|
|
130
|
+
time.sleep(_RETRY_BASE_DELAY * (2**attempt))
|
|
131
|
+
conn = self._reconnect()
|
|
132
|
+
continue
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
f"Request failed after {max_retries} retries: {e}"
|
|
135
|
+
) from e
|
|
136
|
+
raise last_err # type: ignore[misc]
|
|
137
|
+
|
|
138
|
+
def _request(
|
|
139
|
+
self,
|
|
140
|
+
path: str,
|
|
141
|
+
method: str = "GET",
|
|
142
|
+
body: dict | None = None,
|
|
143
|
+
timeout: float = 300,
|
|
144
|
+
) -> dict:
|
|
145
|
+
return self._http_request(method, path, body=body, timeout=timeout)
|
|
146
|
+
|
|
147
|
+
def _ensure_server(self, server_cwd: str) -> None:
|
|
148
|
+
if self._base_url:
|
|
149
|
+
self._check_external_server()
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
self._port = _get_free_port()
|
|
153
|
+
env = {**os.environ}
|
|
154
|
+
env.pop("OPENCODE_SERVER_USERNAME", None)
|
|
155
|
+
env.pop("OPENCODE_SERVER_PASSWORD", None)
|
|
156
|
+
|
|
157
|
+
self._process = subprocess.Popen(
|
|
158
|
+
[
|
|
159
|
+
self.bin_path,
|
|
160
|
+
"serve",
|
|
161
|
+
"--hostname",
|
|
162
|
+
"127.0.0.1",
|
|
163
|
+
"--port",
|
|
164
|
+
str(self._port),
|
|
165
|
+
"--print-logs",
|
|
166
|
+
],
|
|
167
|
+
cwd=server_cwd,
|
|
168
|
+
stdin=subprocess.DEVNULL,
|
|
169
|
+
stdout=subprocess.PIPE,
|
|
170
|
+
stderr=subprocess.PIPE,
|
|
171
|
+
env=env,
|
|
172
|
+
start_new_session=True,
|
|
173
|
+
)
|
|
174
|
+
self._base_url = f"http://127.0.0.1:{self._port}"
|
|
175
|
+
self._wait_healthy(timeout=30)
|
|
176
|
+
|
|
177
|
+
def _check_external_server(self) -> None:
|
|
178
|
+
url = self._base_url
|
|
179
|
+
if not url:
|
|
180
|
+
raise RuntimeError("No server URL configured")
|
|
181
|
+
try:
|
|
182
|
+
self._http_request(
|
|
183
|
+
"GET", "/global/health", timeout=5, max_retries=1
|
|
184
|
+
)
|
|
185
|
+
except (
|
|
186
|
+
RuntimeError,
|
|
187
|
+
ConnectionError,
|
|
188
|
+
OSError,
|
|
189
|
+
http.client.HTTPException,
|
|
190
|
+
) as e:
|
|
191
|
+
port = url.split(":")[-1].rstrip("/")
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
f"Shared server at {url} is not reachable. "
|
|
194
|
+
f"Start it with: opencode serve --hostname 127.0.0.1 --port {port} | Error: {e}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _wait_healthy(self, timeout: float = 30) -> None:
|
|
198
|
+
deadline = time.monotonic() + timeout
|
|
199
|
+
while time.monotonic() < deadline:
|
|
200
|
+
if self._process and self._process.poll() is not None:
|
|
201
|
+
raise RuntimeError("opencode server exited during startup")
|
|
202
|
+
try:
|
|
203
|
+
conn = self._reconnect()
|
|
204
|
+
conn.timeout = 2
|
|
205
|
+
conn.request("GET", "/global/health")
|
|
206
|
+
resp = conn.getresponse()
|
|
207
|
+
resp.read()
|
|
208
|
+
if resp.status == 200:
|
|
209
|
+
return
|
|
210
|
+
except (ConnectionError, OSError, http.client.HTTPException):
|
|
211
|
+
pass
|
|
212
|
+
time.sleep(0.25)
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
f"opencode server did not become healthy on port {self._port}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _create_session(self, session_dir: str) -> str:
|
|
218
|
+
resp = self._request(
|
|
219
|
+
f"/session?directory={quote(session_dir, safe='')}",
|
|
220
|
+
method="POST",
|
|
221
|
+
body={},
|
|
222
|
+
timeout=10,
|
|
223
|
+
)
|
|
224
|
+
return resp.get("id", "")
|
|
225
|
+
|
|
226
|
+
@contextmanager
|
|
227
|
+
def session(self, work_dir: str, repo_dir: str | None = None):
|
|
228
|
+
server_cwd = repo_dir or work_dir
|
|
229
|
+
self._ensure_server(server_cwd)
|
|
230
|
+
session_id = self._create_session(work_dir)
|
|
231
|
+
try:
|
|
232
|
+
yield _Session(self, session_id)
|
|
233
|
+
finally:
|
|
234
|
+
self._delete_session(session_id)
|
|
235
|
+
|
|
236
|
+
def run(
|
|
237
|
+
self,
|
|
238
|
+
prompt: str,
|
|
239
|
+
work_dir: str,
|
|
240
|
+
schema: dict | None = None,
|
|
241
|
+
repo_dir: str | None = None,
|
|
242
|
+
) -> AgentResult:
|
|
243
|
+
server_cwd = repo_dir or work_dir
|
|
244
|
+
self._ensure_server(server_cwd)
|
|
245
|
+
session_id = self._create_session(work_dir)
|
|
246
|
+
try:
|
|
247
|
+
return self._send_message(session_id, prompt, schema)
|
|
248
|
+
except Exception:
|
|
249
|
+
self._abort_session(session_id)
|
|
250
|
+
raise
|
|
251
|
+
finally:
|
|
252
|
+
self._delete_session(session_id)
|
|
253
|
+
|
|
254
|
+
def _send_message(
|
|
255
|
+
self, session_id: str, prompt: str, schema: dict | None = None
|
|
256
|
+
) -> AgentResult:
|
|
257
|
+
body: dict = {
|
|
258
|
+
"role": "user",
|
|
259
|
+
"parts": [{"type": "text", "text": prompt}],
|
|
260
|
+
}
|
|
261
|
+
if schema:
|
|
262
|
+
body["format"] = {
|
|
263
|
+
"type": "json_schema",
|
|
264
|
+
"schema": schema,
|
|
265
|
+
"retryCount": 1,
|
|
266
|
+
}
|
|
267
|
+
result = self._request(
|
|
268
|
+
f"/session/{session_id}/message",
|
|
269
|
+
method="POST",
|
|
270
|
+
body=body,
|
|
271
|
+
timeout=600,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
info = result.get("info", {})
|
|
275
|
+
structured = info.get("structured")
|
|
276
|
+
tokens = info.get("tokens", {})
|
|
277
|
+
|
|
278
|
+
if structured:
|
|
279
|
+
return AgentResult(
|
|
280
|
+
output=structured,
|
|
281
|
+
input_tokens=tokens.get("input", 0),
|
|
282
|
+
output_tokens=tokens.get("output", 0),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
text = ""
|
|
286
|
+
for part in result.get("parts", []):
|
|
287
|
+
if part.get("type") == "text" and part.get("text"):
|
|
288
|
+
text = part["text"]
|
|
289
|
+
|
|
290
|
+
if not text:
|
|
291
|
+
raise RuntimeError("No structured output or text in agent response")
|
|
292
|
+
|
|
293
|
+
raise RuntimeError(f"Agent returned unstructured text: {text[:200]}")
|
|
294
|
+
|
|
295
|
+
def _abort_session(self, session_id: str) -> None:
|
|
296
|
+
try:
|
|
297
|
+
self._request(
|
|
298
|
+
f"/session/{session_id}/abort", method="POST", timeout=3
|
|
299
|
+
)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
def _delete_session(self, session_id: str) -> None:
|
|
304
|
+
try:
|
|
305
|
+
self._request(
|
|
306
|
+
f"/session/{session_id}", method="DELETE", timeout=3
|
|
307
|
+
)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
if self._external_server:
|
|
313
|
+
self._close_conn()
|
|
314
|
+
return
|
|
315
|
+
if self._base_url:
|
|
316
|
+
try:
|
|
317
|
+
self._http_request(
|
|
318
|
+
"POST", "/instance/dispose", timeout=5, max_retries=0
|
|
319
|
+
)
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
self._close_conn()
|
|
323
|
+
if self._process and self._process.poll() is None:
|
|
324
|
+
try:
|
|
325
|
+
self._process.wait(timeout=5)
|
|
326
|
+
except subprocess.TimeoutExpired:
|
|
327
|
+
try:
|
|
328
|
+
os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
|
|
329
|
+
except OSError:
|
|
330
|
+
self._process.terminate()
|
|
331
|
+
try:
|
|
332
|
+
self._process.wait(timeout=5)
|
|
333
|
+
except subprocess.TimeoutExpired:
|
|
334
|
+
try:
|
|
335
|
+
os.killpg(
|
|
336
|
+
os.getpgid(self._process.pid), signal.SIGKILL
|
|
337
|
+
)
|
|
338
|
+
except OSError:
|
|
339
|
+
self._process.kill()
|
|
340
|
+
self._process.wait(timeout=2)
|
|
341
|
+
self._process = None
|
|
342
|
+
self._base_url = None
|
|
343
|
+
self._port = None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _get_free_port() -> int:
|
|
347
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
348
|
+
s.bind(("127.0.0.1", 0))
|
|
349
|
+
return s.getsockname()[1]
|
kaizen/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from kaizen import __version__
|
|
8
|
+
from kaizen.agent import OpenCodeAgent
|
|
9
|
+
from kaizen.config import load_config
|
|
10
|
+
from kaizen.git import is_git_repo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cmd_loop(args: argparse.Namespace) -> None:
|
|
14
|
+
cwd = args.directory or args.cwd
|
|
15
|
+
if not is_git_repo(cwd):
|
|
16
|
+
print("Error: not a git repo", file=sys.stderr)
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
if not args.prompt:
|
|
20
|
+
print("Error: prompt required", file=sys.stderr)
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
config = load_config()
|
|
24
|
+
opencode_bin = args.opencode_bin or config.get("opencode_bin", "opencode")
|
|
25
|
+
use_worktree = not args.no_worktree
|
|
26
|
+
server_url = args.server_url or config.get("server_url")
|
|
27
|
+
|
|
28
|
+
agent = OpenCodeAgent(bin_path=opencode_bin, server_url=server_url)
|
|
29
|
+
|
|
30
|
+
force_exit = False
|
|
31
|
+
|
|
32
|
+
def handle_sigint(sig, frame):
|
|
33
|
+
nonlocal force_exit
|
|
34
|
+
if force_exit:
|
|
35
|
+
print("\n force stop")
|
|
36
|
+
agent.close()
|
|
37
|
+
sys.exit(130)
|
|
38
|
+
force_exit = True
|
|
39
|
+
print("\n stopping... (Ctrl+C again to force)")
|
|
40
|
+
|
|
41
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
|
42
|
+
signal.signal(signal.SIGTERM, lambda s, f: sys.exit(0))
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from kaizen.loop import run_loop
|
|
46
|
+
|
|
47
|
+
result = run_loop(
|
|
48
|
+
prompt=args.prompt,
|
|
49
|
+
cwd=cwd,
|
|
50
|
+
agent=agent,
|
|
51
|
+
max_work_iterations=args.max_iterations,
|
|
52
|
+
max_review_rounds=args.max_review_rounds or config.get("max_review_rounds", 3),
|
|
53
|
+
use_worktree=use_worktree,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if result == "passed":
|
|
57
|
+
print("\n kaizen loop passed")
|
|
58
|
+
elif result == "cancelled":
|
|
59
|
+
print("\n kaizen loop cancelled")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
else:
|
|
62
|
+
print("\n kaizen loop failed")
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print(f"\n fatal: {e}", file=sys.stderr)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
finally:
|
|
68
|
+
agent.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
parser = argparse.ArgumentParser(
|
|
73
|
+
prog="kaizen",
|
|
74
|
+
description="Continuous code improvement: work → review → fix → ship",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument("--version", action="version", version=f"kaizen {__version__}")
|
|
77
|
+
parser.add_argument("--directory", "-C", help="Path to git repo (default: current dir)")
|
|
78
|
+
|
|
79
|
+
parser.add_argument("prompt", nargs="?", help="What the agent should do")
|
|
80
|
+
parser.add_argument("--max-iterations", type=int, help="Max work iterations")
|
|
81
|
+
parser.add_argument("--max-review-rounds", type=int, help="Max review rounds")
|
|
82
|
+
parser.add_argument("--no-worktree", action="store_true", help="Work in current tree instead of worktree")
|
|
83
|
+
parser.add_argument("--opencode-bin", help="Path to opencode binary")
|
|
84
|
+
parser.add_argument("--server-url", help="URL of an existing opencode serve instance (e.g. http://127.0.0.1:4096)")
|
|
85
|
+
|
|
86
|
+
args = parser.parse_args()
|
|
87
|
+
args.cwd = args.directory or ""
|
|
88
|
+
|
|
89
|
+
if args.prompt:
|
|
90
|
+
cmd_loop(args)
|
|
91
|
+
else:
|
|
92
|
+
parser.print_help()
|
kaizen/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DEFAULT_CONFIG = {
|
|
7
|
+
"max_work_iterations": None,
|
|
8
|
+
"max_review_rounds": 3,
|
|
9
|
+
"max_consecutive_failures": 3,
|
|
10
|
+
"opencode_bin": "opencode",
|
|
11
|
+
"use_worktree": True,
|
|
12
|
+
"server_url": None,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_path() -> str:
|
|
17
|
+
return os.path.expanduser("~/.kaizen/config.json")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config() -> dict:
|
|
21
|
+
path = config_path()
|
|
22
|
+
if os.path.exists(path):
|
|
23
|
+
try:
|
|
24
|
+
with open(path) as f:
|
|
25
|
+
cfg = json.load(f)
|
|
26
|
+
return {**DEFAULT_CONFIG, **cfg}
|
|
27
|
+
except (json.JSONDecodeError, OSError):
|
|
28
|
+
pass
|
|
29
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
30
|
+
Path(path).write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n")
|
|
31
|
+
return {**DEFAULT_CONFIG}
|
kaizen/findings.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
ACTION_NOOP = "no-op"
|
|
4
|
+
ACTION_AUTO_FIX = "auto-fix"
|
|
5
|
+
|
|
6
|
+
SEVERITY_INFO = "info"
|
|
7
|
+
SEVERITY_WARNING = "warning"
|
|
8
|
+
SEVERITY_ERROR = "error"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Finding:
|
|
13
|
+
id: str
|
|
14
|
+
severity: str
|
|
15
|
+
file: str = ""
|
|
16
|
+
line: int = 0
|
|
17
|
+
description: str = ""
|
|
18
|
+
action: str = ACTION_NOOP
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FindingsResult:
|
|
23
|
+
items: list[Finding] = field(default_factory=list)
|
|
24
|
+
summary: str = ""
|
|
25
|
+
risk_level: str = "low"
|
|
26
|
+
risk_rationale: str = ""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def has_auto_fix(self) -> bool:
|
|
30
|
+
return any(f.action == ACTION_AUTO_FIX for f in self.items)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def auto_fix_items(self) -> list[Finding]:
|
|
34
|
+
return [f for f in self.items if f.action == ACTION_AUTO_FIX]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_findings(data: dict) -> FindingsResult:
|
|
38
|
+
items = []
|
|
39
|
+
for i, f in enumerate(data.get("findings", [])):
|
|
40
|
+
items.append(Finding(
|
|
41
|
+
id=f.get("id", f"f{i + 1}"),
|
|
42
|
+
severity=f.get("severity", SEVERITY_INFO),
|
|
43
|
+
file=f.get("file", ""),
|
|
44
|
+
line=f.get("line", 0),
|
|
45
|
+
description=f.get("description", ""),
|
|
46
|
+
action=f.get("action", ACTION_NOOP),
|
|
47
|
+
))
|
|
48
|
+
return FindingsResult(
|
|
49
|
+
items=items,
|
|
50
|
+
summary=data.get("summary", ""),
|
|
51
|
+
risk_level=data.get("risk_level", "low"),
|
|
52
|
+
risk_rationale=data.get("risk_rationale", ""),
|
|
53
|
+
)
|