@prism-d1/cli 1.8.0 → 1.9.1
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.
- package/README.md +48 -0
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +77 -38
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +55 -29
- package/dist/assets/kirocrew-app/README.md +234 -0
- package/dist/assets/kirocrew-app/app.json +96 -0
- package/dist/assets/kirocrew-app/assets/icon.png +0 -0
- package/dist/assets/kirocrew-app/assets/screenshots/page-dark.png +0 -0
- package/dist/assets/kirocrew-app/assets/screenshots/page-light.png +0 -0
- package/dist/assets/kirocrew-app/assets/screenshots/report-dark.png +0 -0
- package/dist/assets/kirocrew-app/assets/screenshots/report-light.png +0 -0
- package/dist/assets/kirocrew-app/scripts/digest.py +263 -0
- package/dist/assets/kirocrew-app/scripts/page_model.py +298 -0
- package/dist/assets/kirocrew-app/scripts/page_routes.py +241 -0
- package/dist/assets/kirocrew-app/scripts/prism_cli_path.py +131 -0
- package/dist/assets/kirocrew-app/scripts/prism_mcp.py +28 -0
- package/dist/assets/kirocrew-app/skills/prism-fix-findings/SKILL.md +62 -0
- package/dist/assets/kirocrew-app/ui/index.mjs +269 -0
- package/dist/src/commands/_top/version.d.ts +9 -0
- package/dist/src/commands/_top/version.d.ts.map +1 -1
- package/dist/src/commands/_top/version.js +22 -1
- package/dist/src/commands/_top/version.js.map +1 -1
- package/dist/src/commands/enable/kirocrew.d.ts +82 -0
- package/dist/src/commands/enable/kirocrew.d.ts.map +1 -0
- package/dist/src/commands/enable/kirocrew.js +376 -0
- package/dist/src/commands/enable/kirocrew.js.map +1 -0
- package/dist/src/dashboard/setup.d.ts +6 -1
- package/dist/src/dashboard/setup.d.ts.map +1 -1
- package/dist/src/dashboard/setup.js +17 -0
- package/dist/src/dashboard/setup.js.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/utils/kirocrew.d.ts +35 -0
- package/dist/src/utils/kirocrew.d.ts.map +1 -0
- package/dist/src/utils/kirocrew.js +65 -0
- package/dist/src/utils/kirocrew.js.map +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Daily PRISM digest: a script cron (no model call).
|
|
2
|
+
|
|
3
|
+
Runs `prism-cli dashboard --json`, compares it with the state the previous
|
|
4
|
+
run saved in the app's data folder, and notifies only when something changed:
|
|
5
|
+
|
|
6
|
+
- a new high finding in a repo's latest codewatch review,
|
|
7
|
+
- today's AI spend above `spendThreshold` (data/config.json; off by default),
|
|
8
|
+
at most once per day,
|
|
9
|
+
- a setup item that went from ok to warn or missing.
|
|
10
|
+
|
|
11
|
+
First run, or a state file that cannot be read: save a baseline, no message.
|
|
12
|
+
Finding text is written by a reviewer model: it is shown shortened on one
|
|
13
|
+
line and never acted on.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import datetime as _dt
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from prism_cli_path import app_data_dir, cli_env, load_config, resolve_cli
|
|
29
|
+
|
|
30
|
+
STATE_FILE = "digest-state.json"
|
|
31
|
+
STATE_SCHEMA = 1
|
|
32
|
+
DASHBOARD_TIMEOUT_S = 80 # until the document is printed; plus REFRESH_GRACE_S stays under the cron's 120 s timeout
|
|
33
|
+
REFRESH_GRACE_S = 20 # time left for the CLI's codeburn refresh after it printed
|
|
34
|
+
FINDING_CHARS = 120
|
|
35
|
+
NAME_CHARS = 60
|
|
36
|
+
SHORT_SHA = 7
|
|
37
|
+
CURRENCY_CHARS = 8
|
|
38
|
+
MAX_REPOS_LISTED = 10
|
|
39
|
+
BAD_SETUP = ("warn", "missing")
|
|
40
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def clean(text: Any, limit: int) -> str:
|
|
44
|
+
"""One plain line: control and bidi characters dropped, whitespace collapsed, cut to `limit`."""
|
|
45
|
+
s = " ".join(_CONTROL.sub(" ", str(text or "")).split())
|
|
46
|
+
return s if len(s) <= limit else s[: limit - 1].rstrip() + "…"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fingerprint(f: Dict[str, Any]) -> str:
|
|
50
|
+
# No commit or line: the same issue re-reviewed on a later push is not new.
|
|
51
|
+
return "|".join(str(f.get(k) or "") for k in ("branch", "file", "rule"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _repo_name(repo: Dict[str, Any]) -> str:
|
|
55
|
+
# The dashboard's label is already unique: it adds a short parent path when two repos share a name.
|
|
56
|
+
return str(repo.get("label") or repo.get("name") or "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def snapshot(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
60
|
+
"""The parts of the dashboard payload the digest compares."""
|
|
61
|
+
highs: Dict[str, List[str]] = {}
|
|
62
|
+
setup: Dict[str, str] = {}
|
|
63
|
+
for item in (payload.get("machine") or {}).get("setup") or []:
|
|
64
|
+
if item.get("id"):
|
|
65
|
+
setup["machine:" + str(item["id"])] = str(item.get("status") or "")
|
|
66
|
+
for repo in payload.get("repos") or []:
|
|
67
|
+
name = _repo_name(repo)
|
|
68
|
+
if not name:
|
|
69
|
+
continue
|
|
70
|
+
fps = sorted({_fingerprint(f) for f in (repo.get("codewatch") or {}).get("findings") or [] if f.get("severity") == "high"})
|
|
71
|
+
if fps:
|
|
72
|
+
highs[name] = fps
|
|
73
|
+
status = (repo.get("setup") or {}).get("status")
|
|
74
|
+
if status:
|
|
75
|
+
setup["repo:" + name] = str(status)
|
|
76
|
+
return {"highs": highs, "setup": setup}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _setup_label(key: str, payload: Dict[str, Any]) -> str:
|
|
80
|
+
kind, _, ident = key.partition(":")
|
|
81
|
+
if kind == "machine":
|
|
82
|
+
for item in (payload.get("machine") or {}).get("setup") or []:
|
|
83
|
+
if str(item.get("id")) == ident:
|
|
84
|
+
return clean(item.get("label") or ident, NAME_CHARS)
|
|
85
|
+
return clean(ident, NAME_CHARS)
|
|
86
|
+
return "codewatch in " + clean(ident, NAME_CHARS)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _new_high_lines(prev: Dict[str, List[str]], payload: Dict[str, Any]) -> List[str]:
|
|
90
|
+
lines: List[str] = []
|
|
91
|
+
for repo in payload.get("repos") or []:
|
|
92
|
+
name = _repo_name(repo)
|
|
93
|
+
if not name:
|
|
94
|
+
continue # never stored in state, so it would look new on every run
|
|
95
|
+
seen = set(prev.get(name) or [])
|
|
96
|
+
# One line per branch: each branch's latest review has its own commit.
|
|
97
|
+
by_branch: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
98
|
+
for f in (repo.get("codewatch") or {}).get("findings") or []:
|
|
99
|
+
if f.get("severity") == "high" and _fingerprint(f) not in seen:
|
|
100
|
+
by_branch.setdefault(str(f.get("branch") or "?"), {}).setdefault(_fingerprint(f), f)
|
|
101
|
+
for branch, new in sorted(by_branch.items()):
|
|
102
|
+
first = next(iter(new.values()))
|
|
103
|
+
commit = clean(first.get("commit") or "?", NAME_CHARS)[:SHORT_SHA]
|
|
104
|
+
n = len(new)
|
|
105
|
+
lines.append(
|
|
106
|
+
f"- {clean(name, NAME_CHARS)}: {n} new high finding{'s' if n != 1 else ''} "
|
|
107
|
+
f"(review of {commit} on {clean(branch, NAME_CHARS)}). First: {clean(first.get('message') or first.get('rule'), FINDING_CHARS)}"
|
|
108
|
+
)
|
|
109
|
+
if len(lines) > MAX_REPOS_LISTED:
|
|
110
|
+
lines = lines[:MAX_REPOS_LISTED] + [f"- and {len(lines) - MAX_REPOS_LISTED} more entries"]
|
|
111
|
+
return lines
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def evaluate(
|
|
115
|
+
prev: Optional[Dict[str, Any]],
|
|
116
|
+
payload: Dict[str, Any],
|
|
117
|
+
threshold: Optional[float],
|
|
118
|
+
today: str,
|
|
119
|
+
) -> Tuple[Optional[str], Dict[str, Any]]:
|
|
120
|
+
"""(message or None, state to save). `prev` None means first run or unreadable state: baseline only."""
|
|
121
|
+
snap = snapshot(payload)
|
|
122
|
+
state: Dict[str, Any] = {"schema": STATE_SCHEMA, **snap, "spendAlertDate": None,
|
|
123
|
+
"lastNotifiedAt": None, "lastMessage": None}
|
|
124
|
+
if prev is None:
|
|
125
|
+
return None, state
|
|
126
|
+
state["spendAlertDate"] = prev.get("spendAlertDate")
|
|
127
|
+
# Shown on the app's page ("Last digest").
|
|
128
|
+
state["lastNotifiedAt"] = prev.get("lastNotifiedAt")
|
|
129
|
+
state["lastMessage"] = prev.get("lastMessage")
|
|
130
|
+
|
|
131
|
+
lines = _new_high_lines(prev.get("highs") or {}, payload)
|
|
132
|
+
|
|
133
|
+
usage = payload.get("usage") or {}
|
|
134
|
+
cost = (usage.get("today") or {}).get("cost") if usage.get("status") == "ok" else None
|
|
135
|
+
if threshold and isinstance(cost, (int, float)) and cost > threshold and prev.get("spendAlertDate") != today:
|
|
136
|
+
cur = clean(usage.get("currency") or "USD", CURRENCY_CHARS)
|
|
137
|
+
lines.append(f"- AI spend today is {cost:.2f} {cur}, above your threshold of {threshold:.2f} {cur}.")
|
|
138
|
+
state["spendAlertDate"] = today
|
|
139
|
+
|
|
140
|
+
prev_setup = prev.get("setup") or {}
|
|
141
|
+
for key, status in sorted(snap["setup"].items()):
|
|
142
|
+
if prev_setup.get(key) == "ok" and status in BAD_SETUP:
|
|
143
|
+
lines.append(f"- Setup: {_setup_label(key, payload)} went from ok to {status}.")
|
|
144
|
+
|
|
145
|
+
if not lines:
|
|
146
|
+
return None, state
|
|
147
|
+
return "PRISM daily digest\n" + "\n".join(lines), state
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_state(path: Path) -> Optional[Dict[str, Any]]:
|
|
151
|
+
"""The saved state, or None when it is missing or not a state this version wrote."""
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
154
|
+
except (OSError, ValueError):
|
|
155
|
+
return None
|
|
156
|
+
if not isinstance(data, dict) or data.get("schema") != STATE_SCHEMA:
|
|
157
|
+
return None
|
|
158
|
+
if not isinstance(data.get("highs"), dict) or not isinstance(data.get("setup"), dict):
|
|
159
|
+
return None
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_state(path: Path, state: Dict[str, Any]) -> None:
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
166
|
+
tmp.write_text(json.dumps(state, indent=1, sort_keys=True) + "\n", encoding="utf-8")
|
|
167
|
+
os.replace(tmp, path)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def spend_threshold(cfg: Dict[str, Any]) -> Optional[float]:
|
|
171
|
+
v = cfg.get("spendThreshold")
|
|
172
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or v <= 0:
|
|
173
|
+
return None
|
|
174
|
+
return float(v)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def run_dashboard(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|
178
|
+
setting = cfg.get("cliPath")
|
|
179
|
+
cli, reason = resolve_cli(setting if isinstance(setting, str) else None, os.environ)
|
|
180
|
+
if not cli:
|
|
181
|
+
raise RuntimeError(reason)
|
|
182
|
+
# No --root: the CLI scans the home directory, as the dashboard does.
|
|
183
|
+
# With a stale usage cache the CLI prints the document, then waits for its codeburn refresh;
|
|
184
|
+
# read only up to the closing line, give the refresh a moment, then stop the child, so that
|
|
185
|
+
# wait cannot push this run past the cron's timeout.
|
|
186
|
+
proc = subprocess.Popen([cli, "dashboard", "--json"], cwd=str(Path.home()), env=cli_env(cli, os.environ),
|
|
187
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
188
|
+
lines: List[bytes] = []
|
|
189
|
+
|
|
190
|
+
def read_doc() -> None:
|
|
191
|
+
assert proc.stdout is not None
|
|
192
|
+
for line in proc.stdout:
|
|
193
|
+
lines.append(line)
|
|
194
|
+
if line == b"}\n": # the document is pretty-printed; this closes it
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
reader = threading.Thread(target=read_doc, daemon=True)
|
|
199
|
+
reader.start()
|
|
200
|
+
reader.join(DASHBOARD_TIMEOUT_S) # a deadline of our own, under the cron's 120 s kill
|
|
201
|
+
if not reader.is_alive():
|
|
202
|
+
try:
|
|
203
|
+
proc.wait(timeout=REFRESH_GRACE_S)
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
pass
|
|
206
|
+
finally:
|
|
207
|
+
if proc.poll() is None:
|
|
208
|
+
proc.kill()
|
|
209
|
+
proc.wait()
|
|
210
|
+
if not lines or lines[-1] != b"}\n":
|
|
211
|
+
raise RuntimeError(f"prism-cli dashboard --json gave no complete document (exit {proc.returncode})")
|
|
212
|
+
payload = json.loads(b"".join(lines).decode("utf-8"))
|
|
213
|
+
if not isinstance(payload, dict) or not str(payload.get("schema", "")).startswith("prism.dashboard."):
|
|
214
|
+
raise RuntimeError("prism-cli dashboard --json returned an unexpected document")
|
|
215
|
+
return payload
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def digest_once(
|
|
219
|
+
data_dir: Path,
|
|
220
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
221
|
+
deliver: Optional[Callable[[str], Any]] = None,
|
|
222
|
+
) -> Tuple[Optional[str], bool]:
|
|
223
|
+
"""Run one digest. Returns (message or None, baseline_only).
|
|
224
|
+
|
|
225
|
+
The state is saved only after `deliver(msg)` returns, so a failed
|
|
226
|
+
delivery is retried on the next run instead of being lost.
|
|
227
|
+
"""
|
|
228
|
+
cfg = load_config(data_dir)
|
|
229
|
+
if payload is None:
|
|
230
|
+
payload = run_dashboard(cfg)
|
|
231
|
+
path = data_dir / STATE_FILE
|
|
232
|
+
prev = read_state(path)
|
|
233
|
+
msg, state = evaluate(prev, payload, spend_threshold(cfg), _dt.date.today().isoformat())
|
|
234
|
+
stamp = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
|
|
235
|
+
if msg and deliver:
|
|
236
|
+
deliver(msg)
|
|
237
|
+
state["lastRunAt"] = stamp
|
|
238
|
+
if msg:
|
|
239
|
+
state["lastNotifiedAt"], state["lastMessage"] = stamp, msg
|
|
240
|
+
write_state(path, state)
|
|
241
|
+
return msg, prev is None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def run(ctx: Any) -> None:
|
|
245
|
+
"""Cron entry point (app.json `scripts/digest.py:run`). ctx.notify raises when delivery fails."""
|
|
246
|
+
digest_once(app_data_dir(), deliver=ctx.notify)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
250
|
+
ap = argparse.ArgumentParser(description="Run the PRISM digest once and print what it would send.")
|
|
251
|
+
# Required, so a manual run never advances the cron's own state with changes it did not deliver.
|
|
252
|
+
ap.add_argument("--state-dir", required=True, help="Folder for digest-state.json and config.json; use a scratch folder, not the app's data folder")
|
|
253
|
+
ap.add_argument("--payload", help="Read the dashboard JSON from this file instead of running prism-cli")
|
|
254
|
+
a = ap.parse_args(argv)
|
|
255
|
+
data_dir = Path(a.state_dir)
|
|
256
|
+
payload = json.loads(Path(a.payload).read_text(encoding="utf-8")) if a.payload else None
|
|
257
|
+
msg, baseline = digest_once(data_dir, payload)
|
|
258
|
+
print(msg if msg else ("baseline saved, no notification" if baseline else "no change, no notification"))
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
sys.exit(main())
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""What the PRISM page shows, built from `prism-cli dashboard --json`. Stdlib only, no I/O
|
|
2
|
+
except the small file reads named below, so it is unit-tested without Kiro Crew.
|
|
3
|
+
|
|
4
|
+
Port of the editor extension's machine mode (editor-extension/src/panel-model.ts,
|
|
5
|
+
machine.ts, text.ts). The page receives only the model; paths stay in the
|
|
6
|
+
targets list, which the page addresses by index.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
COMMIT_LOOKUP_MAX = 30
|
|
19
|
+
COMMIT_LOOKUP_CONCURRENCY = 4
|
|
20
|
+
ROW_FINDINGS_MAX = 20
|
|
21
|
+
MAX_TEXT = 2000
|
|
22
|
+
REPORT_MAX_AGE_S = 24 * 3600
|
|
23
|
+
USAGE_CACHE_SCHEMA = "prism.usage-cache.v1"
|
|
24
|
+
RESULT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$")
|
|
25
|
+
REPORT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
|
26
|
+
|
|
27
|
+
# C0/C1 controls (except tab and newline) and bidi controls.
|
|
28
|
+
_UNSAFE = re.compile("[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def plain(s: Any, max_len: int = MAX_TEXT, one_line: bool = True) -> str:
|
|
32
|
+
t = "" if s is None else str(s)
|
|
33
|
+
t = _UNSAFE.sub("", t.replace("\r\n", "\n").replace("\r", "\n"))
|
|
34
|
+
if one_line:
|
|
35
|
+
t = re.sub(r"\s*\n\s*", " ", t)
|
|
36
|
+
return t if len(t) <= max_len else t[: max_len - 1] + "…"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def money(n: Any, currency: str) -> str:
|
|
40
|
+
v = f"{float(n or 0):.2f}"
|
|
41
|
+
return f"${v}" if currency == "USD" else f"{v} {plain(currency, 8)}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def count(n: int, word: str) -> str:
|
|
45
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _time(iso: Any) -> Optional[float]:
|
|
49
|
+
if not isinstance(iso, str) or not iso:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return _dt.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def base_name(s: Any) -> str:
|
|
58
|
+
parts = [p for p in re.split(r"[\\/]", str(s or "")) if p]
|
|
59
|
+
return plain(parts[-1] if parts else "", 120)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---- usage cache -----------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def usage_cached_today(home: Path, today: Optional[str] = None) -> bool:
|
|
65
|
+
"""True when ~/.prism/cache/usage.json holds today's usage, so `dashboard --json` will not run a cold codeburn export."""
|
|
66
|
+
try:
|
|
67
|
+
c = json.loads((home / ".prism" / "cache" / "usage.json").read_text(encoding="utf-8"))
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return False
|
|
70
|
+
today = today or _dt.date.today().isoformat()
|
|
71
|
+
return isinstance(c, dict) and c.get("schema") == USAGE_CACHE_SCHEMA and c.get("day") == today and isinstance(c.get("panel"), dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---- repos -----------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def commit_lookup_paths(p: Dict[str, Any], max_n: int = COMMIT_LOOKUP_MAX) -> List[str]:
|
|
77
|
+
"""Repos whose last commit is looked up: codewatch results only, newest review first, capped."""
|
|
78
|
+
repos = [r for r in p.get("repos") or [] if (r.get("codewatch") or {}).get("hasResults")]
|
|
79
|
+
repos.sort(key=lambda r: _time((r.get("codewatch") or {}).get("lastRunAt")) or 0, reverse=True)
|
|
80
|
+
return [r["path"] for r in repos[:max_n] if isinstance(r.get("path"), str)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_last_commit(out: str) -> Optional[Dict[str, str]]:
|
|
84
|
+
"""`git log -1 --format=%cI%x00%h%x00%s` output, or None."""
|
|
85
|
+
parts = out.rstrip("\n").split("\0")
|
|
86
|
+
if len(parts) < 2 or _time(parts[0]) is None or not parts[1]:
|
|
87
|
+
return None
|
|
88
|
+
return {"at": parts[0], "sha": parts[1], "subject": parts[2] if len(parts) > 2 else ""}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def last_commit_args(repo: str) -> List[str]:
|
|
92
|
+
return ["git", "-C", repo, "log", "-1", "--format=%cI%x00%h%x00%s"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def machine_repos(p: Dict[str, Any], commits: Dict[str, Optional[Dict[str, str]]]) -> List[Dict[str, Any]]:
|
|
96
|
+
"""'Repositories with codewatch', newest last commit first; no commit time: last review time; neither: last."""
|
|
97
|
+
by_path = {r.get("path"): r for r in p.get("repos") or []}
|
|
98
|
+
rows = []
|
|
99
|
+
for row in (p.get("overview") or {}).get("codewatch") or []:
|
|
100
|
+
repo = by_path.get(row.get("path"))
|
|
101
|
+
found = sorted((repo or {}).get("codewatch", {}).get("findings") or [], key=lambda f: _time(f.get("started_at")) or 0, reverse=True)
|
|
102
|
+
rows.append({"row": row, "repo": repo, "commit": commits.get(row.get("path")),
|
|
103
|
+
"findings": found[:ROW_FINDINGS_MAX], "more": max(0, len(found) - ROW_FINDINGS_MAX)})
|
|
104
|
+
|
|
105
|
+
def key(m: Dict[str, Any]) -> float:
|
|
106
|
+
t = _time((m["commit"] or {}).get("at")) or _time(m["row"].get("lastRunAt"))
|
|
107
|
+
return t if t is not None else float("-inf")
|
|
108
|
+
|
|
109
|
+
rows.sort(key=key, reverse=True)
|
|
110
|
+
return rows
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def latest_review_of(repo: Optional[Dict[str, Any]]) -> Optional[Dict[str, Optional[str]]]:
|
|
114
|
+
"""The branch and commit of the repo's latest review, for the --id fallback report."""
|
|
115
|
+
cw = (repo or {}).get("codewatch") or {}
|
|
116
|
+
rid = cw.get("lastReviewId")
|
|
117
|
+
if not rid:
|
|
118
|
+
return None
|
|
119
|
+
scan = next((s for s in cw.get("scans") or [] if s.get("id") == rid), None)
|
|
120
|
+
lb = next((b for b in cw.get("latestByBranch") or [] if scan and (b.get("review") or {}).get("started_at") == scan.get("started_at")), None)
|
|
121
|
+
return {"id": rid, "branch": (scan or {}).get("branch") or (lb or {}).get("branch"), "commit": ((lb or {}).get("review") or {}).get("commit")}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def findings_badge(row: Dict[str, Any]) -> Dict[str, str]:
|
|
125
|
+
"""The repo row's badge from its latest review; the row's left border uses the same tone."""
|
|
126
|
+
if not row.get("lastRunAt"):
|
|
127
|
+
return {"text": "no review", "tone": "none"}
|
|
128
|
+
high, med, low = (int(row.get(k) or 0) for k in ("high", "medium", "low"))
|
|
129
|
+
if high:
|
|
130
|
+
return {"text": f"{high} high", "tone": "error"}
|
|
131
|
+
if med:
|
|
132
|
+
return {"text": f"{med} med", "tone": "warn"}
|
|
133
|
+
if low:
|
|
134
|
+
return {"text": f"{low} low", "tone": "none"}
|
|
135
|
+
return {"text": "clean", "tone": "ok"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def hooks_text(status: Any) -> str:
|
|
139
|
+
"""Codewatch hook status, shown as small muted text: it says the hooks run, not that the code is clean."""
|
|
140
|
+
s = plain(status or "unknown", 20)
|
|
141
|
+
return {"ok": "hooks on", "missing": "hooks off"}.get(s, "hooks " + s)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_item(i: Dict[str, Any]) -> Dict[str, str]:
|
|
145
|
+
help_text = (i.get("help") or {}).get("text")
|
|
146
|
+
return {"key": "m:" + plain(i.get("id"), 60), "label": plain(i.get("label"), 120), "status": plain(i.get("status"), 20),
|
|
147
|
+
"detail": plain(help_text or i.get("detail"), 500), "fix": plain(i.get("fix"), 500)}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_model(p: Dict[str, Any], commits: Dict[str, Optional[Dict[str, str]]], version: str,
|
|
151
|
+
digest: Optional[Dict[str, Any]]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
152
|
+
"""(model for the page, targets kept by the backend). The model holds no path; times stay ISO."""
|
|
153
|
+
u = p.get("usage") or {}
|
|
154
|
+
summary = p.get("summary") or {}
|
|
155
|
+
cur = u.get("currency") or (summary.get("spend") or {}).get("currency") or "USD"
|
|
156
|
+
|
|
157
|
+
def totals(title: str, t: Optional[Dict[str, Any]], extra: str = "") -> Dict[str, str]:
|
|
158
|
+
if not t:
|
|
159
|
+
return {"title": title, "value": "no data", "detail": ""}
|
|
160
|
+
return {"title": title, "value": money(t.get("cost"), cur),
|
|
161
|
+
"detail": f"{count(int(t.get('calls') or 0), 'call')} · {count(int(t.get('sessions') or 0), 'session')}{extra}"}
|
|
162
|
+
|
|
163
|
+
if u.get("status") == "ok":
|
|
164
|
+
m = u.get("month") or None
|
|
165
|
+
cards = [totals("AI spend today", u.get("today")),
|
|
166
|
+
totals("AI spend this month", m, f" · {plain(m.get('from'), 10)} to {plain(m.get('to'), 10)}" if m else "")]
|
|
167
|
+
else:
|
|
168
|
+
cards = [{"title": "AI spend", "value": plain(u.get("status") or "unknown", 20), "detail": plain(u.get("message") or "", 300)}]
|
|
169
|
+
warn = summary.get("warnings") or {}
|
|
170
|
+
cards.append({"title": "Setup warnings", "value": str(int(warn.get("count") or 0)), "detail": plain(warn.get("first") or "nothing to fix", 300)})
|
|
171
|
+
cards.append({"title": "Sync mode", "value": plain(((p.get("machine") or {}).get("codeburnSync") or {}).get("mode") or "unknown", 20), "detail": "where codeburn usage goes"})
|
|
172
|
+
|
|
173
|
+
setup_of = {r.get("path"): r.get("status") for r in (p.get("overview") or {}).get("setup") or []}
|
|
174
|
+
rows: List[Dict[str, Any]] = []
|
|
175
|
+
targets: List[Dict[str, Any]] = []
|
|
176
|
+
for mr in machine_repos(p, commits):
|
|
177
|
+
r, c = mr["row"], mr["commit"]
|
|
178
|
+
name = plain(r.get("label") or r.get("name"), 120)
|
|
179
|
+
latest = latest_review_of(mr["repo"])
|
|
180
|
+
badge = findings_badge(r)
|
|
181
|
+
rows.append({
|
|
182
|
+
"name": name,
|
|
183
|
+
"review": f"{int(r.get('high') or 0)} high · {int(r.get('medium') or 0)} med · {int(r.get('low') or 0)} low" if r.get("lastRunAt") else "no review yet",
|
|
184
|
+
# Raw ISO times: the page shows them relative and re-renders them on its own clock.
|
|
185
|
+
"reviewAt": plain(r.get("lastRunAt") or "", 40),
|
|
186
|
+
"badge": badge,
|
|
187
|
+
"tone": badge["tone"],
|
|
188
|
+
"hooks": hooks_text(setup_of.get(r.get("path"))),
|
|
189
|
+
"commit": {"sha": plain(c["sha"], 12), "at": plain(c["at"], 40), "subject": plain(c["subject"], 80)} if c else None,
|
|
190
|
+
"findings": [{"severity": plain(f.get("severity"), 10),
|
|
191
|
+
"location": plain(f.get("file"), 200) + (f":{f['line']}" if isinstance(f.get("line"), int) and not isinstance(f.get("line"), bool) else ""),
|
|
192
|
+
"message": plain(f.get("message"), 300), "branch": plain(f.get("branch") or "(detached)", 80)} for f in mr["findings"]],
|
|
193
|
+
"more": f"{count(mr['more'], 'more finding')} not listed; open the report for all of them." if mr["more"] else "",
|
|
194
|
+
"hasReport": bool(latest),
|
|
195
|
+
})
|
|
196
|
+
targets.append({"path": r.get("path"), "name": name, "commit": c, "latest": latest})
|
|
197
|
+
|
|
198
|
+
counts = (p.get("overview") or {}).get("counts") or {}
|
|
199
|
+
model = {
|
|
200
|
+
"generatedAt": plain(p.get("generated_at"), 40),
|
|
201
|
+
"version": plain(version or "unknown", 40),
|
|
202
|
+
"usageUpdatedAt": plain(u.get("updatedAt") or "", 40),
|
|
203
|
+
"usageStale": bool(u.get("stale")),
|
|
204
|
+
"cards": cards,
|
|
205
|
+
"topModels": [{"name": plain(x.get("name"), 120), "value": f"{money(x.get('cost'), cur)} · {count(int(x.get('calls') or 0), 'call')}"} for x in u.get("topModels") or []],
|
|
206
|
+
"topProjects": [{"name": base_name(x.get("name")), "value": f"{money(x.get('cost'), cur)} · {count(int(x.get('sessions') or 0), 'session')}"} for x in u.get("topProjects") or []],
|
|
207
|
+
"checklist": [_check_item(i) for i in (p.get("machine") or {}).get("setup") or []],
|
|
208
|
+
"repos": rows,
|
|
209
|
+
"reposNote": f"{counts.get('codewatch', 0)} of {counts.get('repos', 0)} repos found under your home folder have codewatch. Newest commit first." if counts else "",
|
|
210
|
+
"digest": digest_view(digest),
|
|
211
|
+
}
|
|
212
|
+
return model, targets
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---- index resolution and report -------------------------------------------
|
|
216
|
+
|
|
217
|
+
def parse_index(v: Any) -> Optional[int]:
|
|
218
|
+
"""A non-negative integer from a query value, else None. No sign, no spaces, at most 6 digits."""
|
|
219
|
+
if isinstance(v, str) and re.fullmatch(r"\d{1,6}", v):
|
|
220
|
+
return int(v)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def target_for(targets: List[Dict[str, Any]], v: Any) -> Optional[Dict[str, Any]]:
|
|
225
|
+
"""The backend's own target for a page-supplied index, or None when it is not an index into the last result."""
|
|
226
|
+
i = parse_index(v)
|
|
227
|
+
return targets[i] if i is not None and i < len(targets) else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def report_plan(target: Dict[str, Any], output: str) -> Dict[str, Any]:
|
|
231
|
+
"""Primary: the review covering HEAD. Fallback: the latest review by id, with a note saying which branch and commit it covers."""
|
|
232
|
+
repo = target["path"]
|
|
233
|
+
primary = ["codewatch", "report", "--repo", repo, "--commit", "HEAD", "--format", "html", "--output", output]
|
|
234
|
+
c = target.get("commit")
|
|
235
|
+
head = f"commit {plain(c['sha'], 12)}" if c else "its last commit"
|
|
236
|
+
plan: Dict[str, Any] = {"primary": primary, "primaryNote": f"Review covering {plain(target['name'], 120)}'s last commit ({head}).", "fallback": None, "fallbackNote": ""}
|
|
237
|
+
latest = target.get("latest")
|
|
238
|
+
if latest and isinstance(latest.get("id"), str) and RESULT_ID.match(latest["id"]):
|
|
239
|
+
plan["fallback"] = ["codewatch", "report", "--repo", repo, "--id", latest["id"], "--format", "html", "--output", output]
|
|
240
|
+
commit = plain(latest.get("commit"), 40)[:12] if latest.get("commit") else "(unknown)"
|
|
241
|
+
plan["fallbackNote"] = (f"No review in {plain(target['name'], 120)} covers {head}. Showing the latest review instead: "
|
|
242
|
+
f"branch {plain(latest.get('branch') or '(unknown)', 80)}, commit {commit}.")
|
|
243
|
+
return plan
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_META_REFRESH = re.compile(r"<meta\b[^>]*http-equiv\s*=\s*[\"']?\s*refresh[^>]*>", re.I)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def with_report_csp(html: str) -> str:
|
|
250
|
+
"""The report with a CSP meta tag (its inline styles only, no script, nothing remote) and no
|
|
251
|
+
meta refresh, which a CSP does not stop from navigating the frame."""
|
|
252
|
+
html = _META_REFRESH.sub("", html)
|
|
253
|
+
meta = f'<meta http-equiv="Content-Security-Policy" content="{REPORT_CSP}">'
|
|
254
|
+
m = re.search(r"<head(?:\s[^>]*)?>", html, re.I) # not <header>
|
|
255
|
+
if m:
|
|
256
|
+
return html[: m.end()] + meta + html[m.end():]
|
|
257
|
+
return f"<!DOCTYPE html><html><head>{meta}</head><body>{html}</body></html>"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cleanup_reports(report_dir: Path, now: Optional[float] = None, max_age: int = REPORT_MAX_AGE_S) -> int:
|
|
261
|
+
"""Delete this app's report files older than max_age. Only *.html directly in report_dir; links are left alone."""
|
|
262
|
+
now = time.time() if now is None else now
|
|
263
|
+
removed = 0
|
|
264
|
+
try:
|
|
265
|
+
entries = list(os.scandir(report_dir))
|
|
266
|
+
except OSError:
|
|
267
|
+
return 0
|
|
268
|
+
for e in entries:
|
|
269
|
+
try:
|
|
270
|
+
if e.name.endswith(".html") and e.is_file(follow_symlinks=False) and now - e.stat(follow_symlinks=False).st_mtime > max_age:
|
|
271
|
+
os.unlink(e.path)
|
|
272
|
+
removed += 1
|
|
273
|
+
except OSError:
|
|
274
|
+
continue
|
|
275
|
+
return removed
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---- digest ----------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def read_digest(data_dir: Path) -> Optional[Dict[str, Any]]:
|
|
281
|
+
"""The digest cron's saved state (digest-state.json), or None when missing or unreadable."""
|
|
282
|
+
try:
|
|
283
|
+
d = json.loads((data_dir / "digest-state.json").read_text(encoding="utf-8"))
|
|
284
|
+
except (OSError, ValueError):
|
|
285
|
+
return None
|
|
286
|
+
return d if isinstance(d, dict) else None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def digest_view(d: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
290
|
+
"""Raw digest fields; the page turns them into 'Not run yet' / 'Last run 3 h ago · ...'."""
|
|
291
|
+
if not d:
|
|
292
|
+
return {"recorded": False, "lastRunAt": "", "lastNotifiedAt": "", "lastMessage": ""}
|
|
293
|
+
return {
|
|
294
|
+
"recorded": True,
|
|
295
|
+
"lastRunAt": plain(d.get("lastRunAt") or "", 40),
|
|
296
|
+
"lastNotifiedAt": plain(d.get("lastNotifiedAt") or "", 40),
|
|
297
|
+
"lastMessage": plain(d.get("lastMessage") or "", 1500, one_line=False),
|
|
298
|
+
}
|