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/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)