pr-analyzer-cli 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.
- pr_analyzer/__init__.py +0 -0
- pr_analyzer/analyzer/__init__.py +0 -0
- pr_analyzer/analyzer/ast_analyzer.py +467 -0
- pr_analyzer/analyzer/line_lexer.py +249 -0
- pr_analyzer/analyzer/scorer.py +132 -0
- pr_analyzer/cache.py +373 -0
- pr_analyzer/cli.py +171 -0
- pr_analyzer/config.py +50 -0
- pr_analyzer/diff.py +74 -0
- pr_analyzer/exceptions.py +41 -0
- pr_analyzer/github/__init__.py +0 -0
- pr_analyzer/github/client.py +398 -0
- pr_analyzer/github/markdown.py +199 -0
- pr_analyzer/github/ratelimit.py +277 -0
- pr_analyzer/llm/__init__.py +0 -0
- pr_analyzer/llm/chunking.py +149 -0
- pr_analyzer/llm/engine.py +336 -0
- pr_analyzer/llm/prompts.py +109 -0
- pr_analyzer/llm/review.py +141 -0
- pr_analyzer/logging_config.py +21 -0
- pr_analyzer/models.py +65 -0
- pr_analyzer/pipeline.py +273 -0
- pr_analyzer/project_config.py +266 -0
- pr_analyzer/reporting/__init__.py +0 -0
- pr_analyzer/reporting/rich_reporter.py +244 -0
- pr_analyzer/security/__init__.py +0 -0
- pr_analyzer/security/dep_auditor.py +179 -0
- pr_analyzer/security/scanner.py +272 -0
- pr_analyzer/security/semgrep_runner.py +190 -0
- pr_analyzer/security/tools.py +22 -0
- pr_analyzer/suppression.py +52 -0
- pr_analyzer_cli-0.1.0.dist-info/METADATA +598 -0
- pr_analyzer_cli-0.1.0.dist-info/RECORD +36 -0
- pr_analyzer_cli-0.1.0.dist-info/WHEEL +4 -0
- pr_analyzer_cli-0.1.0.dist-info/entry_points.txt +3 -0
- pr_analyzer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
pr_analyzer/cache.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""On-disk cache for what doesn't change once fetched or computed.
|
|
2
|
+
|
|
3
|
+
Each entry is a text value under a kind and a key made of the parts that identify it:
|
|
4
|
+
|
|
5
|
+
contents (repo, commit sha, path) a file's text; fixed at a commit
|
|
6
|
+
llm (provider, model, ..., system prompt, prompt) an LLM reply to that exact prompt
|
|
7
|
+
|
|
8
|
+
Entries live at <dir>/v1/<kind>/<2 hex digits>/<sha256 of the key>, as zlib-compressed JSON. They
|
|
9
|
+
are written to a temporary file and renamed into place, so a reader sees a whole entry or none;
|
|
10
|
+
an entry that can't be read or decoded counts as a miss. Entries unused for CACHE_MAX_AGE_DAYS
|
|
11
|
+
expire, and once the cache outgrows CACHE_MAX_SIZE_MB the least recently used go first.
|
|
12
|
+
|
|
13
|
+
The cache may hold code from private repositories, and anyone else who could write to it could
|
|
14
|
+
plant entries, or make prune() delete files elsewhere through a symbolic link. So <dir> and
|
|
15
|
+
<dir>/v1 are created private to the user (PRIVATE_MODE), and before either is used it must be a
|
|
16
|
+
real directory (not a symbolic link) that belongs to the user: one of the user's that others can
|
|
17
|
+
use too is made private, and anything else turns the cache off for the run, with a warning.
|
|
18
|
+
Entries are read only from the user's own regular files, never through a symbolic link, and
|
|
19
|
+
prune() deletes nothing but entry and temporary files in <kind>/<2 hex digits>/, following no
|
|
20
|
+
symbolic link.
|
|
21
|
+
|
|
22
|
+
Callers never cache errors. A value isn't written if it contains one of our own keys (`secrets`:
|
|
23
|
+
open_cache passes GITHUB_TOKEN, OPENAI_API_KEY and ANTHROPIC_API_KEY; values under 8 characters
|
|
24
|
+
don't count) or matches CREDENTIAL: a private key block (PEM, OpenSSH or PGP), a PuTTY key file,
|
|
25
|
+
or a GitHub, OpenAI or Anthropic (sk-), AWS access key ID (AKIA, ASIA), Slack (xox?-), Stripe live
|
|
26
|
+
(sk_live_, rk_live_), Google API (AIza), GitLab (glpat-) or Hugging Face (hf_) token. Other
|
|
27
|
+
secrets, such as an AWS secret access key on its own or a password in a database URL, aren't
|
|
28
|
+
recognised.
|
|
29
|
+
"""
|
|
30
|
+
import contextlib
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import stat
|
|
36
|
+
import tempfile
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
import zlib
|
|
40
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
import structlog
|
|
44
|
+
|
|
45
|
+
from pr_analyzer.config import settings
|
|
46
|
+
|
|
47
|
+
log = structlog.get_logger(__name__)
|
|
48
|
+
|
|
49
|
+
# bump when the entry format or a key's meaning changes; old entries then simply stop matching
|
|
50
|
+
FORMAT_VERSION = "v1"
|
|
51
|
+
MEGABYTE = 1024 * 1024
|
|
52
|
+
DAY_SECONDS = 24 * 60 * 60
|
|
53
|
+
DEFAULT_MAX_SIZE_MB = 500
|
|
54
|
+
DEFAULT_MAX_AGE_DAYS = 30
|
|
55
|
+
# checking the size and age limits walks every entry, so it runs at most this often
|
|
56
|
+
PRUNE_INTERVAL_SECONDS = 60 * 60
|
|
57
|
+
PRUNE_STAMP = ".last-prune"
|
|
58
|
+
TEMP_PREFIX = ".tmp-"
|
|
59
|
+
# shorter values can't be real keys, and matching them would keep almost anything out
|
|
60
|
+
MIN_SECRET_LENGTH = 8
|
|
61
|
+
PRIVATE_MODE = 0o700
|
|
62
|
+
# the names prune() may delete in <kind>/<2 hex digits>/: entries, and the temporary files
|
|
63
|
+
# tempfile.mkstemp names
|
|
64
|
+
BUCKET_NAME = re.compile(r"[0-9a-f]{2}")
|
|
65
|
+
ENTRY_NAME = re.compile(r"[0-9a-f]{64}")
|
|
66
|
+
TEMP_NAME = re.compile(re.escape(TEMP_PREFIX) + r"[a-z0-9_]+")
|
|
67
|
+
# an entry is never opened through a symbolic link, nor waits on a FIFO put in its place
|
|
68
|
+
READ_FLAGS = (
|
|
69
|
+
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
70
|
+
| getattr(os, "O_BINARY", 0)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# tokens and keys a file or an LLM reply might contain; such values never reach the disk
|
|
74
|
+
GITHUB_TOKEN_FORMAT = r"gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}"
|
|
75
|
+
OPENAI_ANTHROPIC_KEY_FORMAT = r"sk-[A-Za-z0-9_-]{20,}"
|
|
76
|
+
AWS_KEY_ID_FORMAT = r"(?:AKIA|ASIA)[0-9A-Z]{16}" # long-term and temporary (STS) key IDs
|
|
77
|
+
SLACK_TOKEN_FORMAT = r"xox[a-z]-[0-9A-Za-z-]{10,}"
|
|
78
|
+
STRIPE_LIVE_KEY_FORMAT = r"[rs]k_live_[0-9A-Za-z]{20,}"
|
|
79
|
+
GOOGLE_API_KEY_FORMAT = r"AIza[0-9A-Za-z_-]{35}"
|
|
80
|
+
GITLAB_TOKEN_FORMAT = r"glpat-[0-9A-Za-z_-]{20,}"
|
|
81
|
+
HUGGING_FACE_TOKEN_FORMAT = r"hf_[A-Za-z0-9]{34}"
|
|
82
|
+
# the first line of a PEM or OpenSSH private key ("BEGIN RSA PRIVATE KEY" between dashes), or of
|
|
83
|
+
# a PGP one ("BEGIN PGP PRIVATE KEY BLOCK")
|
|
84
|
+
PRIVATE_KEY_HEADER = r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----"
|
|
85
|
+
PUTTY_KEY_HEADER = r"PuTTY-User-Key-File-[0-9]+:"
|
|
86
|
+
CREDENTIAL = re.compile("|".join((
|
|
87
|
+
GITHUB_TOKEN_FORMAT, OPENAI_ANTHROPIC_KEY_FORMAT, AWS_KEY_ID_FORMAT, SLACK_TOKEN_FORMAT,
|
|
88
|
+
STRIPE_LIVE_KEY_FORMAT, GOOGLE_API_KEY_FORMAT, GITLAB_TOKEN_FORMAT, HUGGING_FACE_TOKEN_FORMAT,
|
|
89
|
+
PRIVATE_KEY_HEADER, PUTTY_KEY_HEADER,
|
|
90
|
+
)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def default_cache_dir() -> Path:
|
|
94
|
+
# the XDG base directory spec says to ignore a relative XDG_CACHE_HOME
|
|
95
|
+
base = os.environ.get("XDG_CACHE_HOME", "")
|
|
96
|
+
if not os.path.isabs(base):
|
|
97
|
+
base = os.path.join(os.path.expanduser("~"), ".cache")
|
|
98
|
+
return Path(base) / "pr-analyzer"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cache_key(kind: str, parts: Iterable[str]) -> str:
|
|
102
|
+
# a JSON list keeps ("a/b", "c") and ("a", "b/c") apart
|
|
103
|
+
encoded = json.dumps([FORMAT_VERSION, kind, *parts])
|
|
104
|
+
return hashlib.sha256(encoded.encode("ascii")).hexdigest()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def current_uid() -> int | None:
|
|
108
|
+
# None where files have no POSIX owner and mode (Windows): only links are checked there
|
|
109
|
+
getuid = getattr(os, "getuid", None)
|
|
110
|
+
return getuid() if getuid is not None else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def directory_problem(path: Path, uid: int | None) -> str | None:
|
|
114
|
+
"""Why the cache mustn't use the directory at `path`, or None if only the user can use it.
|
|
115
|
+
A directory of the user's that others can use too is made private first. Raises OSError if
|
|
116
|
+
there's nothing at `path`, or not a directory."""
|
|
117
|
+
info = os.lstat(path)
|
|
118
|
+
if stat.S_ISLNK(info.st_mode):
|
|
119
|
+
return "it is a symbolic link"
|
|
120
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
121
|
+
raise NotADirectoryError(f"Not a directory: {str(path)!r}")
|
|
122
|
+
if uid is None:
|
|
123
|
+
return None
|
|
124
|
+
if info.st_uid != uid:
|
|
125
|
+
return "it belongs to another user"
|
|
126
|
+
if info.st_mode & 0o077:
|
|
127
|
+
with contextlib.suppress(OSError):
|
|
128
|
+
os.chmod(path, PRIVATE_MODE) # pr-analyzer: ignore - makes the folder private (0700)
|
|
129
|
+
if os.lstat(path).st_mode & 0o077:
|
|
130
|
+
return "other users can open it, and it can't be made private"
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def own_file(info: os.stat_result, uid: int | None) -> bool:
|
|
135
|
+
return stat.S_ISREG(info.st_mode) and (uid is None or info.st_uid == uid)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def subdirectories(path: Path) -> list[Path]:
|
|
139
|
+
"""The directories in `path`, leaving out symbolic links to one; none if it can't be read."""
|
|
140
|
+
try:
|
|
141
|
+
with os.scandir(path) as items:
|
|
142
|
+
return [Path(item.path) for item in items if item.is_dir(follow_symlinks=False)]
|
|
143
|
+
except OSError:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def regular_files(path: Path) -> list[tuple[Path, os.stat_result]]:
|
|
148
|
+
"""The regular files in `path` with their lstat, leaving out symbolic links; none if it
|
|
149
|
+
can't be read."""
|
|
150
|
+
files = []
|
|
151
|
+
with contextlib.suppress(OSError), os.scandir(path) as items:
|
|
152
|
+
for item in items:
|
|
153
|
+
with contextlib.suppress(OSError):
|
|
154
|
+
info = item.stat(follow_symlinks=False)
|
|
155
|
+
if stat.S_ISREG(info.st_mode):
|
|
156
|
+
files.append((Path(item.path), info))
|
|
157
|
+
return files
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class DiskCache:
|
|
161
|
+
"""Safe to share between threads: every entry is its own file, and only the counters and
|
|
162
|
+
the result of the directory check are shared state."""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
root: Path | None,
|
|
167
|
+
max_bytes: int = DEFAULT_MAX_SIZE_MB * MEGABYTE,
|
|
168
|
+
max_age_seconds: float = DEFAULT_MAX_AGE_DAYS * DAY_SECONDS,
|
|
169
|
+
secrets: Iterable[str] = (),
|
|
170
|
+
clock: Callable[[], float] = time.time,
|
|
171
|
+
):
|
|
172
|
+
self.root = root / FORMAT_VERSION if root is not None else None
|
|
173
|
+
self.max_bytes = max_bytes
|
|
174
|
+
self.max_age_seconds = max_age_seconds
|
|
175
|
+
self.secrets = tuple(s for s in secrets if len(s) >= MIN_SECRET_LENGTH)
|
|
176
|
+
self.clock = clock
|
|
177
|
+
self._lock = threading.Lock()
|
|
178
|
+
self.hits = self.misses = self.writes = 0
|
|
179
|
+
self.writable = True # until a write fails: then the run carries on without writing
|
|
180
|
+
self._checked = False # <dir> and <dir>/v1 passed directory_problem
|
|
181
|
+
self._unsafe = False # one of them didn't: the cache is off for the run
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def disabled(cls) -> "DiskCache":
|
|
185
|
+
return cls(None)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def enabled(self) -> bool:
|
|
189
|
+
return self.root is not None and not self._unsafe
|
|
190
|
+
|
|
191
|
+
def get(self, kind: str, *parts: str) -> str | None:
|
|
192
|
+
if not self.enabled:
|
|
193
|
+
return None
|
|
194
|
+
value = self._read(self._path(kind, parts)) if self._usable(create=False) else None
|
|
195
|
+
with self._lock:
|
|
196
|
+
if value is None:
|
|
197
|
+
self.misses += 1
|
|
198
|
+
else:
|
|
199
|
+
self.hits += 1
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
def put(self, kind: str, *parts: str, value: str) -> None:
|
|
203
|
+
if not self.enabled or not self.writable or not self.storable(value):
|
|
204
|
+
return
|
|
205
|
+
path = self._path(kind, parts)
|
|
206
|
+
data = zlib.compress(json.dumps({"value": value}).encode("ascii"))
|
|
207
|
+
try:
|
|
208
|
+
if not self._usable(create=True):
|
|
209
|
+
return
|
|
210
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
write_atomically(path, data, mtime=self.clock())
|
|
212
|
+
except OSError as e:
|
|
213
|
+
# a cache that can't be written must never fail the analysis, nor log every file
|
|
214
|
+
with self._lock:
|
|
215
|
+
first, self.writable = self.writable, False
|
|
216
|
+
if first:
|
|
217
|
+
log.warning("cache_write_failed", error=str(e), hint="caching off for this run")
|
|
218
|
+
return
|
|
219
|
+
with self._lock:
|
|
220
|
+
self.writes += 1
|
|
221
|
+
|
|
222
|
+
def storable(self, value: str) -> bool:
|
|
223
|
+
return not CREDENTIAL.search(value) and not any(s in value for s in self.secrets)
|
|
224
|
+
|
|
225
|
+
def close(self) -> None:
|
|
226
|
+
if not self.enabled:
|
|
227
|
+
return
|
|
228
|
+
log.info("cache_used", hits=self.hits, misses=self.misses, writes=self.writes)
|
|
229
|
+
if self.writes and self._prune_due():
|
|
230
|
+
self.prune()
|
|
231
|
+
|
|
232
|
+
def prune(self) -> None:
|
|
233
|
+
"""Drop expired entries, then the least recently used until the cache fits its size."""
|
|
234
|
+
if not self.enabled or not self._usable(create=False):
|
|
235
|
+
return
|
|
236
|
+
assert self.root is not None
|
|
237
|
+
now = self.clock()
|
|
238
|
+
entries = []
|
|
239
|
+
for path, info in self._entries():
|
|
240
|
+
age = now - info.st_mtime
|
|
241
|
+
if age > self.max_age_seconds:
|
|
242
|
+
discard(path)
|
|
243
|
+
elif not path.name.startswith(TEMP_PREFIX):
|
|
244
|
+
entries.append((info.st_mtime, info.st_size, path))
|
|
245
|
+
elif age > PRUNE_INTERVAL_SECONDS:
|
|
246
|
+
discard(path) # left behind by a writer that crashed
|
|
247
|
+
|
|
248
|
+
total = sum(size for _, size, _ in entries)
|
|
249
|
+
for _, size, path in sorted(entries):
|
|
250
|
+
if total <= self.max_bytes:
|
|
251
|
+
break
|
|
252
|
+
discard(path)
|
|
253
|
+
total -= size
|
|
254
|
+
with contextlib.suppress(OSError):
|
|
255
|
+
stamp = self.root / PRUNE_STAMP
|
|
256
|
+
stamp.touch()
|
|
257
|
+
os.utime(stamp, (now, now))
|
|
258
|
+
|
|
259
|
+
def _path(self, kind: str, parts: tuple[str, ...]) -> Path:
|
|
260
|
+
assert self.root is not None
|
|
261
|
+
key = cache_key(kind, parts)
|
|
262
|
+
return self.root / kind / key[:2] / key
|
|
263
|
+
|
|
264
|
+
def _read(self, path: Path) -> str | None:
|
|
265
|
+
now = self.clock()
|
|
266
|
+
try:
|
|
267
|
+
with open(os.open(path, READ_FLAGS), "rb") as handle:
|
|
268
|
+
info = os.fstat(handle.fileno())
|
|
269
|
+
if not own_file(info, current_uid()):
|
|
270
|
+
return None # nobody else's file is ever served as an entry
|
|
271
|
+
expired = now - info.st_mtime > self.max_age_seconds
|
|
272
|
+
data = b"" if expired else handle.read()
|
|
273
|
+
except OSError:
|
|
274
|
+
return None # not cached yet, unreadable, or a symbolic link
|
|
275
|
+
if expired:
|
|
276
|
+
return None # prune() deletes it
|
|
277
|
+
try:
|
|
278
|
+
value = json.loads(zlib.decompress(data))["value"]
|
|
279
|
+
except (zlib.error, ValueError, TypeError, KeyError):
|
|
280
|
+
value = None
|
|
281
|
+
if not isinstance(value, str):
|
|
282
|
+
log.warning("cache_entry_corrupt", path=str(path))
|
|
283
|
+
discard(path)
|
|
284
|
+
return None
|
|
285
|
+
# a hit counts as a use: past the size limit, the least recently used entries go first
|
|
286
|
+
with contextlib.suppress(OSError):
|
|
287
|
+
os.utime(path, (now, now))
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
def _usable(self, create: bool) -> bool:
|
|
291
|
+
"""Whether <dir> and <dir>/v1 are there and safe to use. They're checked until they pass,
|
|
292
|
+
after being created if `create`, and one that isn't safe turns the cache off for the run.
|
|
293
|
+
Raises OSError if `create` and they can't be made."""
|
|
294
|
+
if self._checked:
|
|
295
|
+
return True
|
|
296
|
+
with self._lock:
|
|
297
|
+
if not (self._checked or self._unsafe):
|
|
298
|
+
try:
|
|
299
|
+
self._check_directories(create)
|
|
300
|
+
except OSError:
|
|
301
|
+
if create:
|
|
302
|
+
raise
|
|
303
|
+
# not made yet, so there's nothing to read
|
|
304
|
+
return self._checked
|
|
305
|
+
|
|
306
|
+
def _check_directories(self, create: bool) -> None:
|
|
307
|
+
assert self.root is not None
|
|
308
|
+
for path in (self.root.parent, self.root):
|
|
309
|
+
if create:
|
|
310
|
+
# private: the cache holds file contents, which may come from private repositories
|
|
311
|
+
path.mkdir(mode=PRIVATE_MODE, parents=True, exist_ok=True)
|
|
312
|
+
problem = directory_problem(path, current_uid())
|
|
313
|
+
if problem is not None:
|
|
314
|
+
self._unsafe = True
|
|
315
|
+
log.warning("cache_dir_unsafe", path=str(path), problem=problem,
|
|
316
|
+
hint="caching off for this run: use a CACHE_DIR of your own")
|
|
317
|
+
return
|
|
318
|
+
self._checked = True
|
|
319
|
+
|
|
320
|
+
def _prune_due(self) -> bool:
|
|
321
|
+
assert self.root is not None
|
|
322
|
+
try:
|
|
323
|
+
last = (self.root / PRUNE_STAMP).stat().st_mtime
|
|
324
|
+
except OSError:
|
|
325
|
+
return True
|
|
326
|
+
return self.clock() - last > PRUNE_INTERVAL_SECONDS
|
|
327
|
+
|
|
328
|
+
def _entries(self) -> Iterator[tuple[Path, os.stat_result]]:
|
|
329
|
+
"""The entry and temporary files in <kind>/<2 hex digits>/, with their lstat: the only
|
|
330
|
+
files prune() may delete. No symbolic link is followed."""
|
|
331
|
+
assert self.root is not None
|
|
332
|
+
for kind in subdirectories(self.root):
|
|
333
|
+
for bucket in subdirectories(kind):
|
|
334
|
+
if not BUCKET_NAME.fullmatch(bucket.name):
|
|
335
|
+
continue
|
|
336
|
+
for path, info in regular_files(bucket):
|
|
337
|
+
name = path.name
|
|
338
|
+
if TEMP_NAME.fullmatch(name) or (
|
|
339
|
+
ENTRY_NAME.fullmatch(name) and name.startswith(bucket.name)
|
|
340
|
+
):
|
|
341
|
+
yield path, info
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def write_atomically(path: Path, data: bytes, mtime: float) -> None:
|
|
345
|
+
# write a temporary file next to the entry and rename it over the entry: readers never see
|
|
346
|
+
# half an entry, and a crash leaves only a temporary file, which prune() removes later
|
|
347
|
+
fd, temp = tempfile.mkstemp(dir=path.parent, prefix=TEMP_PREFIX)
|
|
348
|
+
try:
|
|
349
|
+
with os.fdopen(fd, "wb") as handle:
|
|
350
|
+
handle.write(data)
|
|
351
|
+
os.utime(temp, (mtime, mtime))
|
|
352
|
+
os.replace(temp, path)
|
|
353
|
+
except BaseException:
|
|
354
|
+
discard(Path(temp))
|
|
355
|
+
raise
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def discard(path: Path) -> None:
|
|
359
|
+
with contextlib.suppress(OSError):
|
|
360
|
+
path.unlink()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def open_cache(enabled: bool = True) -> DiskCache:
|
|
364
|
+
"""The cache the settings describe, or a disabled one (CACHE_ENABLED=false or --no-cache)."""
|
|
365
|
+
if not (enabled and settings.cache_enabled):
|
|
366
|
+
return DiskCache.disabled()
|
|
367
|
+
root = Path(settings.cache_dir).expanduser() if settings.cache_dir else default_cache_dir()
|
|
368
|
+
return DiskCache(
|
|
369
|
+
root,
|
|
370
|
+
max_bytes=settings.cache_max_size_mb * MEGABYTE,
|
|
371
|
+
max_age_seconds=settings.cache_max_age_days * DAY_SECONDS,
|
|
372
|
+
secrets=(settings.github_token, settings.openai_api_key, settings.anthropic_api_key),
|
|
373
|
+
)
|
pr_analyzer/cli.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import traceback
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, NoReturn
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from pr_analyzer.config import settings
|
|
10
|
+
from pr_analyzer.exceptions import (
|
|
11
|
+
ConfigError,
|
|
12
|
+
InvalidPRURLError,
|
|
13
|
+
InvalidTokenError,
|
|
14
|
+
PrivateRepoError,
|
|
15
|
+
PRNotFoundError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
RepositoryNotFoundError,
|
|
18
|
+
PRAnalyzerError,
|
|
19
|
+
)
|
|
20
|
+
from pr_analyzer.logging_config import setup_logging
|
|
21
|
+
from pr_analyzer.models import AnalysisResult
|
|
22
|
+
from pr_analyzer.pipeline import AnalysisPipeline
|
|
23
|
+
from pr_analyzer.project_config import load_project_config
|
|
24
|
+
from pr_analyzer.reporting.rich_reporter import RichReporter
|
|
25
|
+
|
|
26
|
+
# exit codes: 0 = passed, 1 = blocked (with --fail-on-block), 2 = the PR could not be analysed
|
|
27
|
+
EXIT_BLOCKED = 1
|
|
28
|
+
EXIT_ERROR = 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OutputFormat(str, Enum):
|
|
32
|
+
rich = "rich"
|
|
33
|
+
json = "json"
|
|
34
|
+
markdown = "markdown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# creates the Typer app — this is what powers the CLI
|
|
38
|
+
# add_completion=False removes the shell autocomplete install prompt
|
|
39
|
+
app = typer.Typer(add_completion=False)
|
|
40
|
+
reporter = RichReporter()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fail(*messages: str) -> NoReturn:
|
|
44
|
+
for message in messages:
|
|
45
|
+
typer.echo(message, err=True)
|
|
46
|
+
raise typer.Exit(code=EXIT_ERROR)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# the name on PyPI; the command and the import package are still called pr-analyzer / pr_analyzer
|
|
50
|
+
DISTRIBUTION = "pr-analyzer-cli"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def package_version() -> str:
|
|
54
|
+
try:
|
|
55
|
+
return importlib.metadata.version(DISTRIBUTION)
|
|
56
|
+
except importlib.metadata.PackageNotFoundError:
|
|
57
|
+
return "unknown"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def error_messages(e: PRAnalyzerError) -> tuple[str, ...]:
|
|
61
|
+
"""What to tell the user about an error the pipeline is expected to raise."""
|
|
62
|
+
if isinstance(e, InvalidPRURLError):
|
|
63
|
+
# bad URL format — user typed the wrong kind of URL
|
|
64
|
+
return (f"Invalid URL: {e}",)
|
|
65
|
+
if isinstance(e, ConfigError):
|
|
66
|
+
# the --config file can't be read or is invalid; e lists every problem. An invalid
|
|
67
|
+
# .pr-analyzer.toml on the base branch only gets a warning (see pipeline.py)
|
|
68
|
+
return (f"Configuration error: {e}",)
|
|
69
|
+
if isinstance(e, InvalidTokenError):
|
|
70
|
+
# token is wrong, expired or missing
|
|
71
|
+
return (f"Authentication failed: {e}", "Check GITHUB_TOKEN in your .env or environment")
|
|
72
|
+
if isinstance(e, PrivateRepoError):
|
|
73
|
+
# token works but does not have permission for this repo
|
|
74
|
+
return (
|
|
75
|
+
f"Access denied: {e}",
|
|
76
|
+
"Classic tokens need the 'repo' scope for private repositories; fine-grained "
|
|
77
|
+
"tokens need read access to Contents and Pull requests",
|
|
78
|
+
)
|
|
79
|
+
if isinstance(e, PRNotFoundError):
|
|
80
|
+
# repo is fine, token is fine, but the PR number does not exist
|
|
81
|
+
return (f"PR not found: {e}",)
|
|
82
|
+
if isinstance(e, RepositoryNotFoundError):
|
|
83
|
+
# the repo does not exist (typo in owner or repo name) or the token can't see it
|
|
84
|
+
return (f"Repository not found: {e}",)
|
|
85
|
+
if isinstance(e, RateLimitError):
|
|
86
|
+
# too many GitHub API calls — e.reset_time tells user when to retry
|
|
87
|
+
return (f"GitHub rate limit hit. Try again after {e.reset_time.strftime('%H:%M:%S')}",)
|
|
88
|
+
# catch-all for any other known error from the pipeline
|
|
89
|
+
return (f"Analysis failed: {e}",)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_report(result: AnalysisResult, output: OutputFormat) -> None:
|
|
93
|
+
# rich is the default, json and markdown are for CI and sharing
|
|
94
|
+
if output is OutputFormat.json:
|
|
95
|
+
reporter.print_json_report(result)
|
|
96
|
+
elif output is OutputFormat.markdown:
|
|
97
|
+
reporter.print_markdown_report(result)
|
|
98
|
+
else:
|
|
99
|
+
reporter.print_rich_report(result)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def analyze(
|
|
104
|
+
pr_url: Annotated[str, typer.Argument(help="Full GitHub PR URL")],
|
|
105
|
+
skip_llm: Annotated[bool, typer.Option("--skip-llm", help="Skip the LLM review, run static and security checks only")] = False,
|
|
106
|
+
post_review: Annotated[bool, typer.Option("--post-review", help="Post results as a GitHub review with inline comments")] = False,
|
|
107
|
+
fail_on_block: Annotated[bool, typer.Option("--fail-on-block/--no-fail-on-block", help="Exit with code 1 if blocking issues are found")] = True,
|
|
108
|
+
output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Report format")] = OutputFormat.rich,
|
|
109
|
+
config: Annotated[Path | None, typer.Option(
|
|
110
|
+
"--config", help="Settings file to use instead of .pr-analyzer.toml on the PR's base branch",
|
|
111
|
+
)] = None,
|
|
112
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show progress logs (on stderr)")] = False,
|
|
113
|
+
no_cache: Annotated[bool, typer.Option("--no-cache", help="Don't read or write the on-disk cache")] = False,
|
|
114
|
+
):
|
|
115
|
+
"""
|
|
116
|
+
Analyze a GitHub pull request for code quality issues.
|
|
117
|
+
|
|
118
|
+
Usage:
|
|
119
|
+
pr-analyzer analyze https://github.com/microsoft/vscode/pull/336766
|
|
120
|
+
pr-analyzer analyze https://github.com/microsoft/vscode/pull/336766 --skip-llm
|
|
121
|
+
pr-analyzer analyze https://github.com/microsoft/vscode/pull/336766 --output json
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
# start logging before anything else runs; logs go to stderr so stdout holds only the report
|
|
125
|
+
setup_logging("INFO" if verbose else settings.log_level)
|
|
126
|
+
|
|
127
|
+
# run the pipeline — this is the only business logic cli.py triggers
|
|
128
|
+
# everything else is just error handling and output routing
|
|
129
|
+
pipeline = None
|
|
130
|
+
try:
|
|
131
|
+
# a bad --config file stops the run here, before any GitHub request. Without --config the
|
|
132
|
+
# pipeline reads .pr-analyzer.toml from the PR's base branch, never from the current
|
|
133
|
+
# directory: in CI that is the PR's own checkout, which could loosen its own checks.
|
|
134
|
+
project_config = load_project_config(config) if config is not None else None
|
|
135
|
+
pipeline = AnalysisPipeline(skip_llm=skip_llm, post_review=post_review, use_cache=not no_cache)
|
|
136
|
+
if project_config is not None:
|
|
137
|
+
pipeline.project_config = project_config
|
|
138
|
+
result = pipeline.run(pr_url)
|
|
139
|
+
|
|
140
|
+
except PRAnalyzerError as e:
|
|
141
|
+
fail(*error_messages(e))
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
# a bug, not a verdict: exit 2 so CI can tell it apart from a blocked PR (exit 1)
|
|
145
|
+
if verbose:
|
|
146
|
+
typer.echo(traceback.format_exc(), err=True)
|
|
147
|
+
fail(f"Unexpected error: {e!r}", "Re-run with --verbose to see the full traceback")
|
|
148
|
+
|
|
149
|
+
finally:
|
|
150
|
+
if pipeline is not None:
|
|
151
|
+
pipeline.close()
|
|
152
|
+
|
|
153
|
+
# hand off to the correct reporter — cli.py never prints anything itself
|
|
154
|
+
print_report(result, output)
|
|
155
|
+
|
|
156
|
+
# exit code is the only thing cli.py "returns" to the terminal
|
|
157
|
+
# code 1 means blocked — CI systems use this to fail the pipeline
|
|
158
|
+
# code 0 means passed — everything is fine
|
|
159
|
+
if fail_on_block and not result.passed:
|
|
160
|
+
raise typer.Exit(code=EXIT_BLOCKED)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def version():
|
|
165
|
+
"""Show the current version of pr-analyzer."""
|
|
166
|
+
typer.echo(f"pr-analyzer {package_version()}")
|
|
167
|
+
typer.echo("Static analysis · Security scanning · LLM suggestions")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
app()
|
pr_analyzer/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Settings(BaseSettings):
|
|
6
|
+
model_config = SettingsConfigDict(
|
|
7
|
+
env_file=".env",
|
|
8
|
+
env_file_encoding="utf-8",
|
|
9
|
+
env_ignore_empty=True,
|
|
10
|
+
# other tools often keep their keys in the same .env; don't refuse to start because of them
|
|
11
|
+
extra="ignore",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# GitHub: the token is optional for public repos (unauthenticated = 60 requests/hour)
|
|
15
|
+
github_token: str = ""
|
|
16
|
+
github_timeout_seconds: int = Field(default=30, gt=0)
|
|
17
|
+
github_max_workers: int = Field(default=8, ge=1) # file contents fetched at the same time
|
|
18
|
+
# longest wait for a rate limit to reset before giving up; 0 = never wait
|
|
19
|
+
github_rate_limit_max_wait_seconds: float = Field(default=60, ge=0)
|
|
20
|
+
|
|
21
|
+
# LLM review
|
|
22
|
+
llm_provider: str = "ollama" # "ollama" | "openai" | "anthropic"
|
|
23
|
+
llm_model: str = "" # empty = the provider's default model
|
|
24
|
+
llm_confidence_threshold: float = Field(default=0.5, ge=0, le=1)
|
|
25
|
+
llm_timeout_seconds: float = Field(default=120, gt=0)
|
|
26
|
+
llm_max_tokens: int = Field(default=4096, gt=0)
|
|
27
|
+
max_diff_lines: int = Field(default=3000, gt=0) # diff lines per LLM request
|
|
28
|
+
# estimated prompt tokens (characters / 4) one run may send; 0 = no limit
|
|
29
|
+
llm_token_budget: int = Field(default=200_000, ge=0)
|
|
30
|
+
llm_max_concurrency: int = Field(default=1, ge=1) # LLM requests in flight at once
|
|
31
|
+
ollama_url: str = "http://localhost:11434"
|
|
32
|
+
openai_api_key: str = ""
|
|
33
|
+
anthropic_api_key: str = ""
|
|
34
|
+
|
|
35
|
+
# external scanners (skipped with a warning when the tool isn't installed)
|
|
36
|
+
semgrep_config: str = "p/owasp-top-ten"
|
|
37
|
+
semgrep_timeout_seconds: int = Field(default=300, gt=0)
|
|
38
|
+
pip_audit_timeout_seconds: int = Field(default=300, gt=0)
|
|
39
|
+
|
|
40
|
+
# on-disk cache of file contents and LLM replies; empty dir = $XDG_CACHE_HOME/pr-analyzer
|
|
41
|
+
# or ~/.cache/pr-analyzer
|
|
42
|
+
cache_enabled: bool = True
|
|
43
|
+
cache_dir: str = ""
|
|
44
|
+
cache_max_size_mb: int = Field(default=500, gt=0)
|
|
45
|
+
cache_max_age_days: int = Field(default=30, gt=0)
|
|
46
|
+
|
|
47
|
+
log_level: str = "WARNING"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
settings = Settings()
|
pr_analyzer/diff.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Helpers for GitHub's per-file `patch` text.
|
|
2
|
+
|
|
3
|
+
GitHub's patch for one file is a list of unified-diff hunks with no `---`/`+++` file headers:
|
|
4
|
+
|
|
5
|
+
@@ -10,7 +10,8 @@ def foo(): <- hunk header: the new file's hunk starts at line 10
|
|
6
|
+
unchanged line <- context line (leading space)
|
|
7
|
+
-removed line
|
|
8
|
+
+added line
|
|
9
|
+
"""
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def iter_new_lines(patch: str) -> Iterator[tuple[int, str, bool]]:
|
|
17
|
+
"""Yield (line number in the new file, text without the +/space prefix, is_added)
|
|
18
|
+
for every line of the patch that exists in the new version of the file."""
|
|
19
|
+
current: int | None = None
|
|
20
|
+
for raw in patch.splitlines():
|
|
21
|
+
hunk = HUNK_HEADER.match(raw)
|
|
22
|
+
if hunk:
|
|
23
|
+
current = int(hunk.group(1))
|
|
24
|
+
continue
|
|
25
|
+
if current is None or raw.startswith(("-", "\\")):
|
|
26
|
+
# text before the first hunk, a removed line, or ""
|
|
27
|
+
continue
|
|
28
|
+
text = raw[1:] if raw[:1] in ("+", " ") else raw
|
|
29
|
+
yield current, text, raw.startswith("+")
|
|
30
|
+
current += 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def iter_added_lines(patch: str) -> Iterator[tuple[int, str]]:
|
|
34
|
+
"""Yield (line number in the new file, text) for each line the patch adds."""
|
|
35
|
+
for line_no, text, is_added in iter_new_lines(patch):
|
|
36
|
+
if is_added:
|
|
37
|
+
yield line_no, text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def count_changes(patch: str) -> tuple[int, int]:
|
|
41
|
+
"""(lines added, lines removed) by the patch."""
|
|
42
|
+
added = removed = 0
|
|
43
|
+
in_hunk = False
|
|
44
|
+
for raw in patch.splitlines():
|
|
45
|
+
if HUNK_HEADER.match(raw):
|
|
46
|
+
in_hunk = True
|
|
47
|
+
elif in_hunk and raw.startswith("+"):
|
|
48
|
+
added += 1
|
|
49
|
+
elif in_hunk and raw.startswith("-"):
|
|
50
|
+
removed += 1
|
|
51
|
+
return added, removed
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def commentable_lines(patch: str) -> set[int]:
|
|
55
|
+
# GitHub accepts inline review comments only on lines that appear in the diff
|
|
56
|
+
return {line_no for line_no, _, _ in iter_new_lines(patch)}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def annotate_patch(patch: str) -> str:
|
|
60
|
+
"""Prefix every line that exists in the new file with its line number, so an LLM reading
|
|
61
|
+
the diff can cite exact lines. Removed lines keep their '-' and get no number."""
|
|
62
|
+
out = []
|
|
63
|
+
current: int | None = None
|
|
64
|
+
for raw in patch.splitlines():
|
|
65
|
+
hunk = HUNK_HEADER.match(raw)
|
|
66
|
+
if hunk:
|
|
67
|
+
current = int(hunk.group(1))
|
|
68
|
+
out.append(raw)
|
|
69
|
+
elif current is None or raw.startswith(("-", "\\")):
|
|
70
|
+
out.append(f"{'':>6}{raw}")
|
|
71
|
+
else:
|
|
72
|
+
out.append(f"{current:>5} {raw}")
|
|
73
|
+
current += 1
|
|
74
|
+
return "\n".join(out)
|