pr-analyzer-cli 0.1.0__tar.gz

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.
Files changed (35) hide show
  1. pr_analyzer_cli-0.1.0/LICENSE +21 -0
  2. pr_analyzer_cli-0.1.0/PKG-INFO +598 -0
  3. pr_analyzer_cli-0.1.0/README.md +555 -0
  4. pr_analyzer_cli-0.1.0/pyproject.toml +84 -0
  5. pr_analyzer_cli-0.1.0/src/pr_analyzer/__init__.py +0 -0
  6. pr_analyzer_cli-0.1.0/src/pr_analyzer/analyzer/__init__.py +0 -0
  7. pr_analyzer_cli-0.1.0/src/pr_analyzer/analyzer/ast_analyzer.py +467 -0
  8. pr_analyzer_cli-0.1.0/src/pr_analyzer/analyzer/line_lexer.py +249 -0
  9. pr_analyzer_cli-0.1.0/src/pr_analyzer/analyzer/scorer.py +132 -0
  10. pr_analyzer_cli-0.1.0/src/pr_analyzer/cache.py +373 -0
  11. pr_analyzer_cli-0.1.0/src/pr_analyzer/cli.py +171 -0
  12. pr_analyzer_cli-0.1.0/src/pr_analyzer/config.py +50 -0
  13. pr_analyzer_cli-0.1.0/src/pr_analyzer/diff.py +74 -0
  14. pr_analyzer_cli-0.1.0/src/pr_analyzer/exceptions.py +41 -0
  15. pr_analyzer_cli-0.1.0/src/pr_analyzer/github/__init__.py +0 -0
  16. pr_analyzer_cli-0.1.0/src/pr_analyzer/github/client.py +398 -0
  17. pr_analyzer_cli-0.1.0/src/pr_analyzer/github/markdown.py +199 -0
  18. pr_analyzer_cli-0.1.0/src/pr_analyzer/github/ratelimit.py +277 -0
  19. pr_analyzer_cli-0.1.0/src/pr_analyzer/llm/__init__.py +0 -0
  20. pr_analyzer_cli-0.1.0/src/pr_analyzer/llm/chunking.py +149 -0
  21. pr_analyzer_cli-0.1.0/src/pr_analyzer/llm/engine.py +336 -0
  22. pr_analyzer_cli-0.1.0/src/pr_analyzer/llm/prompts.py +109 -0
  23. pr_analyzer_cli-0.1.0/src/pr_analyzer/llm/review.py +141 -0
  24. pr_analyzer_cli-0.1.0/src/pr_analyzer/logging_config.py +21 -0
  25. pr_analyzer_cli-0.1.0/src/pr_analyzer/models.py +65 -0
  26. pr_analyzer_cli-0.1.0/src/pr_analyzer/pipeline.py +273 -0
  27. pr_analyzer_cli-0.1.0/src/pr_analyzer/project_config.py +266 -0
  28. pr_analyzer_cli-0.1.0/src/pr_analyzer/reporting/__init__.py +0 -0
  29. pr_analyzer_cli-0.1.0/src/pr_analyzer/reporting/rich_reporter.py +244 -0
  30. pr_analyzer_cli-0.1.0/src/pr_analyzer/security/__init__.py +0 -0
  31. pr_analyzer_cli-0.1.0/src/pr_analyzer/security/dep_auditor.py +179 -0
  32. pr_analyzer_cli-0.1.0/src/pr_analyzer/security/scanner.py +272 -0
  33. pr_analyzer_cli-0.1.0/src/pr_analyzer/security/semgrep_runner.py +190 -0
  34. pr_analyzer_cli-0.1.0/src/pr_analyzer/security/tools.py +22 -0
  35. pr_analyzer_cli-0.1.0/src/pr_analyzer/suppression.py +52 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Neil Mathew
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,598 @@
1
+ Metadata-Version: 2.4
2
+ Name: pr-analyzer-cli
3
+ Version: 0.1.0
4
+ Summary: Score GitHub pull requests with static analysis, security scanning and an optional LLM review
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: github,pull-request,code-review,static-analysis,security,semgrep,llm,ci
8
+ Author: Neil Mathew
9
+ Requires-Python: >=3.11
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Provides-Extra: anthropic
21
+ Provides-Extra: openai
22
+ Provides-Extra: security
23
+ Requires-Dist: anthropic (>=0.28,<3) ; extra == "anthropic"
24
+ Requires-Dist: httpx (>=0.27,<0.28)
25
+ Requires-Dist: openai (>=1.30,<4) ; extra == "openai"
26
+ Requires-Dist: pip-audit (>=2.10,<3) ; extra == "security"
27
+ Requires-Dist: pydantic-settings (>=2.3,<3)
28
+ Requires-Dist: pygithub (>=2.3,<3)
29
+ Requires-Dist: python-dotenv (>=1.0,<2)
30
+ Requires-Dist: rich (>=13.8,<14)
31
+ Requires-Dist: semgrep (>=1.181,<2) ; extra == "security"
32
+ Requires-Dist: structlog (>=24.2,<25)
33
+ Requires-Dist: tree-sitter (>=0.25,<0.26)
34
+ Requires-Dist: tree-sitter-javascript (>=0.25,<0.26)
35
+ Requires-Dist: tree-sitter-python (>=0.25,<0.26)
36
+ Requires-Dist: tree-sitter-typescript (>=0.23,<0.24)
37
+ Requires-Dist: typer (>=0.27,<0.28)
38
+ Project-URL: Homepage, https://github.com/Quantumstringfloops/PRanalyzer
39
+ Project-URL: Issues, https://github.com/Quantumstringfloops/PRanalyzer/issues
40
+ Project-URL: Repository, https://github.com/Quantumstringfloops/PRanalyzer
41
+ Description-Content-Type: text/markdown
42
+
43
+ # PR Analyzer
44
+
45
+ [![CI](https://github.com/Quantumstringfloops/PRanalyzer/actions/workflows/ci.yml/badge.svg)](https://github.com/Quantumstringfloops/PRanalyzer/actions/workflows/ci.yml)
46
+
47
+ An AI-powered pull request analysis tool that combines static analysis, security scanning, and LLM review into a single CLI command. Point it at any GitHub PR URL and it returns a quality score, a letter grade, line-level suggestions, and an optional GitHub review with inline comments.
48
+
49
+ ---
50
+
51
+ ## The Problem
52
+
53
+ Code review is slow, inconsistent, and easy to skip under deadline pressure. Junior developers miss security patterns. Senior developers miss them too when they are tired. PR Analyzer runs automatically on every pull request, catches hardcoded secrets, unsafe dependencies, high-complexity functions, and common OWASP vulnerabilities before a human ever opens the diff.
54
+
55
+ ---
56
+
57
+ ## Architecture
58
+
59
+ ```
60
+ GitHub PR URL
61
+ │
62
+ ▼
63
+ ┌─────────────────┐
64
+ │ GitHub Client │ fetches PR metadata, each file's diff, and its full content
65
+ └────────┬────────┘ (8 files at a time, from the on-disk cache where it can)
66
+ │
67
+ ▼
68
+ ┌─────────────────┐
69
+ │ Static Analyzer │ regex rules on added lines + tree-sitter complexity per function
70
+ └────────┬────────┘
71
+ │
72
+ ▼
73
+ ┌─────────────────┐
74
+ │Security Scanner │ secrets and risky code on added lines, Semgrep OWASP rules,
75
+ └────────┬────────┘ pip-audit on newly pinned requirements
76
+ │
77
+ ▼
78
+ ┌─────────────────┐
79
+ │ LLM Engine │ sends the line-numbered diff to Ollama / OpenAI / Anthropic
80
+ └────────┬────────┘ (a big one in parts); receives JSON suggestions back
81
+ │
82
+ ▼
83
+ ┌─────────────────┐
84
+ │ Scorer │ groups by dimension, applies severity penalties,
85
+ └────────┬────────┘ computes weighted score, maps to letter grade
86
+ │
87
+ ▼
88
+ ┌─────────────────┐
89
+ │ Reporter │ Rich terminal table / JSON / Markdown on stdout,
90
+ └─────────────────┘ plus a GitHub review with inline comments (--post-review)
91
+ ```
92
+
93
+ Every check reports only lines the PR adds, so a PR is never blamed for code it didn't touch: complexity is reported for the functions the PR changes, and file length only when the PR takes a file past the limit. The optional stages degrade instead of failing: if Semgrep or pip-audit isn't installed, or the LLM can't be reached, the run carries on and the report lists a warning.
94
+
95
+ ---
96
+
97
+ ## Technology Choices
98
+
99
+ | Library | Why |
100
+ |---------|-----|
101
+ | PyGithub | Official GitHub REST API wrapper — handles auth, pagination, and rate limits |
102
+ | tree-sitter | Multi-language parser — counts decision points per function for cyclomatic complexity without executing code |
103
+ | Semgrep, pip-audit | OWASP rule matching, and known-vulnerability lookups for newly pinned dependencies (the optional `security` extra) |
104
+ | Rich | Terminal output with colour, tables, and panels |
105
+ | Typer | CLI framework — automatic `--help`, type validation, and exit codes |
106
+ | Pydantic Settings | Loads `.env` and environment variables into typed config |
107
+ | structlog | Structured logging to stderr — every log line is key/value data |
108
+
109
+ ---
110
+
111
+ ## Getting Started
112
+
113
+ You need Python 3.11 or newer. The package is called `pr-analyzer-cli` (`pr-analyzer` on PyPI is an unrelated project), and the command it installs is `pr-analyzer`.
114
+
115
+ ### 1. Install
116
+
117
+ ```bash
118
+ pip install "pr-analyzer-cli[security]"
119
+ ```
120
+
121
+ Or straight from GitHub, for the latest `main`:
122
+
123
+ ```bash
124
+ pip install "pr-analyzer-cli[security] @ git+https://github.com/Quantumstringfloops/PRanalyzer.git#subdirectory=pr-analyzer"
125
+ ```
126
+
127
+ > Until the first release is on PyPI, use the GitHub line.
128
+
129
+ `[security]` adds Semgrep and pip-audit. Leave it out for a smaller install, or add `openai` or `anthropic` for those LLM providers, for example `[security,openai]`. `pipx install` works too.
130
+
131
+ ### 2. Analyse a pull request
132
+
133
+ ```bash
134
+ pr-analyzer analyze https://github.com/pallets/flask/pull/6176 --skip-llm
135
+ ```
136
+
137
+ This runs the static and security checks, then prints a score, a grade and every finding.
138
+
139
+ Public repositories work without a token, though GitHub allows only 60 requests an hour without one. For private repositories, for posting reviews, or for big PRs, set a token first:
140
+
141
+ ```bash
142
+ export GITHUB_TOKEN=ghp_... # classic: the repo scope; fine-grained: read access to Contents and Pull requests
143
+ ```
144
+
145
+ ### 3. Add the AI review (optional)
146
+
147
+ Pick a provider and drop `--skip-llm`:
148
+
149
+ ```bash
150
+ # a local model through Ollama (the default provider; the Ollama app or `ollama serve` must be running)
151
+ ollama pull llama3.2
152
+ pr-analyzer analyze https://github.com/owner/repo/pull/123
153
+
154
+ # OpenAI
155
+ pip install "pr-analyzer-cli[security,openai]"
156
+ export LLM_PROVIDER=openai OPENAI_API_KEY=sk-...
157
+ pr-analyzer analyze https://github.com/owner/repo/pull/123
158
+
159
+ # Anthropic
160
+ pip install "pr-analyzer-cli[security,anthropic]"
161
+ export LLM_PROVIDER=anthropic ANTHROPIC_API_KEY=sk-ant-...
162
+ pr-analyzer analyze https://github.com/owner/repo/pull/123
163
+ ```
164
+
165
+ If the provider can't be reached, the run still finishes without LLM suggestions and says so.
166
+
167
+ ### 4. Use it in CI, or on every pull request
168
+
169
+ - `--output json` and `--output markdown` print a report other tools can read. The exit code is `0` when the PR passed, `1` when it's blocked, and `2` when it couldn't be analysed.
170
+ - `--post-review` posts the result on the PR as a review with inline comments.
171
+ - To review every pull request automatically, add the GitHub Action; see [Use it in GitHub Actions](#use-it-in-github-actions).
172
+
173
+ Settings can also come from environment variables, or from a `.env` file in the directory you run it from. `.env.example` lists them all.
174
+
175
+ ### From source (contributors)
176
+
177
+ ```bash
178
+ git clone https://github.com/Quantumstringfloops/PRanalyzer.git
179
+ cd PRanalyzer/pr-analyzer
180
+ pip install "poetry>=2.2"
181
+ poetry install --all-extras
182
+ poetry run pr-analyzer analyze https://github.com/owner/repo/pull/123 --skip-llm
183
+ poetry run pytest
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Usage
189
+
190
+ ```
191
+ pr-analyzer analyze <PR URL> [OPTIONS]
192
+ pr-analyzer version
193
+ ```
194
+
195
+ | Option | Default | Description |
196
+ |--------|---------|-------------|
197
+ | `--skip-llm` | off | Run the static and security checks only |
198
+ | `--post-review` | off | Post the result as a GitHub review: inline comments for MEDIUM and above (configurable), LOW findings counted in the summary. Later runs on the same PR update that summary and comment only on new findings (needs a token that can write pull requests) |
199
+ | `--fail-on-block` / `--no-fail-on-block` | on | Exit with code 1 when the PR is blocked |
200
+ | `--output`, `-o` | `rich` | `rich`, `json` or `markdown`. Reports go to stdout, logs to stderr |
201
+ | `--config PATH` | — | Project settings to use instead of the `.pr-analyzer.toml` on the PR's base branch (see [Project configuration](#project-configuration-pr-analyzertoml)) |
202
+ | `--verbose`, `-v` | off | Show progress logs |
203
+ | `--no-cache` | off | Don't read or write the [on-disk cache](#caching) for this run |
204
+
205
+ URLs copied from a PR's *Files changed*, *Commits* or *Checks* tab work too.
206
+
207
+ **Exit codes:** `0` passed · `1` blocked (with `--fail-on-block`) · `2` the PR could not be analysed (bad URL, authentication, rate limit, invalid configuration, or an unexpected error)
208
+
209
+ ### Ignoring a finding
210
+
211
+ Put `pr-analyzer: ignore` in a comment on the line, ideally with the reason:
212
+
213
+ ```python
214
+ subprocess.run(cmd, shell=True) # pr-analyzer: ignore - cmd is a fixed string
215
+ ```
216
+
217
+ Every finding on that line is dropped and doesn't count towards the score, whichever check reported it: the regex rules, Semgrep, the dependency audit or the LLM. The report's warnings name each ignored line, so a reviewer can see what a PR silenced.
218
+
219
+ ### Text the bot posts
220
+
221
+ Finding titles and descriptions can come from the LLM, which reads the PR, from Semgrep messages that quote the PR's code, and from file names the PR chose. Before posting a review the bot neutralises that text with an invisible zero-width space, so it reads the same but can't @mention anyone, add HTML (such as an `<!-- -->` comment that hides the rest of the review or fakes the bot's hidden tag), show links or images, or cross-reference issues (`#123`, `GH-123`), not even through character references such as `&#x40;` or `&commat;`, which are shown as written. Inline code and fenced code blocks are left as written, since GitHub acts on nothing inside them, so a suggested change can be committed and code copied without picking up invisible characters (inline code only keeps a break between `]` and `:`, where GitHub could still read a link definition). Whatever GitHub might not show as code is still neutralised: blocks it draws instead, such as maths and Mermaid diagrams, code that a `$` could turn into maths, and code whose place it can't be sure of, such as a fenced block indented into a list. A `<` or `]` that can't start a tag or a link is left alone too, which keeps the bot's own hints, such as `pip install 'pranalyzer[security]'`, working when copied. In the list of findings, file names are shown as code; in the notes they are neutralised like the rest.
222
+
223
+ The LLM prompt treats the PR the same way: its title, file names and diff sit between `<untrusted_pr_content>` tags that the model is told hold code to review and never instructions, and any copy of the tags inside the PR is escaped. The scanner findings the model must not repeat sit in a block of their own, one JSON object per finding, so a file name stays a quoted value that can't pose as another finding, and the model is told those names were chosen by the PR. Prompt injection can't be ruled out entirely, so the LLM's reply is still checked strictly: only JSON suggestions about files in the PR, with known severities and categories, are kept.
224
+
225
+ ### What the line rules skip
226
+
227
+ The pattern rules read one added line at a time and tell code, string literals and comments apart in Python, JavaScript and TypeScript, and in C-family languages such as Java, Go or Rust (`//`, `/* */`, `"..."` and character literals such as `'"'`). In Python a line holding only a docstring is read like a comment, and in C, C++ and C# so is a line starting with `#`, a preprocessor directive such as `#define`, `#pragma` or `#region`. PHP and other languages are matched as before, anywhere in the line, and a line starting with `#`, `//`, `/*` or `*` counts as a comment:
228
+
229
+ - Rules about calls and settings (`eval(`, `pickle.load`, `yaml.load`, `subprocess … shell=True`, `debug=True`, `print(`, `console.log`) ignore text inside strings and comments. The code in an f-string's `{...}` or a template literal's `${...}` is checked like any other code, though a format spec such as `:>8` is text. The SQL-injection rule still sees a string being formatted into a query.
230
+ - `todo-comment` counts `TODO` and `FIXME` anywhere but inside a string literal: in comments and docstrings, and on the middle lines of a docstring or a `/* */` comment, but not in `status = "TODO"`.
231
+ - The secret rules read the whole line, strings and comments included, since that is where secrets end up.
232
+ - `magic-number` skips numbers that are already named or explain themselves: a keyword argument or default value (`timeout=30`), an assignment (`retries = 5`), a dict key or object property (`"port": 8080`, `{ port: 8080 }`), a constant definition (`MAX_SIZE = 10`), a slice or index (`line[:72]`), a line about a version (`sys.version_info >= (3, 11)`), and well-known values: 10, 100 and 1000; 24, 60, 3600 and 86400; powers of two from 16 to 65536, 255 and 65535; ports 80 and 443; and the standard HTTP status codes. Test files are skipped.
233
+
234
+ Because each line is read on its own, the middle lines of a multi-line string (a docstring, a template literal) or of a `/* */` comment are read as code unless they start like a comment (`*`, `#`, `//`), and JavaScript regex literals aren't recognised.
235
+
236
+ ### Project configuration (`.pr-analyzer.toml`)
237
+
238
+ A repository can tune the checks with a `.pr-analyzer.toml` file in its root. Every key is optional:
239
+
240
+ ```toml
241
+ # rule ids and whole stages to switch off (see the table below)
242
+ disable = ["magic-number", "missing-return-type"]
243
+
244
+ # files to leave out: no findings are reported for them and they are not sent to the LLM
245
+ ignore_paths = ["docs/**", "*.min.js", "tests/fixtures/"]
246
+
247
+ # severities that block the PR (exit code 1 with --fail-on-block); [] never blocks
248
+ block_on = ["CRITICAL", "HIGH"]
249
+
250
+ # the lowest severity that gets an inline review comment; lower ones are counted in the summary
251
+ inline_min_severity = "MEDIUM"
252
+
253
+ [thresholds]
254
+ complexity_medium = 15 # a function's cyclomatic complexity above this is MEDIUM...
255
+ complexity_high = 30 # ...and above this, HIGH
256
+ max_file_lines = 500 # a PR that takes a file past this gets a file-length finding
257
+ max_line_length = 120 # longer added lines get a long-line finding
258
+ ```
259
+
260
+ The values shown are the defaults. Severities can be written in any case.
261
+
262
+ **Where it is read from.** When a PR is analysed, `.pr-analyzer.toml` is read from the PR's **base branch** through the GitHub API, never from the PR itself, so a PR can't loosen its own checks; a change to the file takes effect once it is merged, and until then the PR's copy is only checked (see below). A file in the current directory is never picked up (in CI that is the PR's own checkout). `--config PATH` uses a local file instead. No file means the defaults.
263
+
264
+ **Validation.** Unknown keys, unknown rule ids, bad severities, unsupported `ignore_paths` patterns and thresholds that aren't positive whole numbers make the file invalid. Every problem is named, with a suggestion for likely typos, and what happens next depends on where the file came from:
265
+
266
+ - **A PR that adds or changes `.pr-analyzer.toml`** has its copy checked, though not applied: each problem is a HIGH finding on the file, with the rule id `config`, so with the default `block_on` the PR is blocked until the file is valid. The check runs whatever `ignore_paths` says; `disable = ["config"]` on the base branch turns it off.
267
+ - **An invalid file on the base branch** doesn't stop the run. The file is ignored, the PR is analysed with the default settings, and a warning names the problems in the report, in the review's notes and on stderr:
268
+
269
+ ```
270
+ .pr-analyzer.toml on main is invalid, so the default settings were used: disable: unknown rule id 'magic-numbers' (did you mean 'magic-number'?)
271
+ ```
272
+
273
+ To recover, fix the file in a PR to that branch. That PR is analysed with the defaults like any other, and its copy of the file is checked; once it is merged, the project's settings apply again.
274
+ - **An invalid `--config` file** stops the run with exit code 2 before anything is fetched:
275
+
276
+ ```
277
+ Configuration error: settings.toml is invalid:
278
+ - unknown key 'disabel' (did you mean 'disable'?)
279
+ - disable: unknown rule id 'magic-numbers' (did you mean 'magic-number'?)
280
+ ```
281
+
282
+ **`ignore_paths` patterns** use a subset of `.gitignore` syntax, matched the way git matches it:
283
+
284
+ - `*` matches any run of characters within one path segment, and `?` any one character except `/`.
285
+ - `**` as a whole path segment crosses directories: `**/` at the start or `/**/` in the middle stands for any number of directories, including none, and `/**` at the end for everything inside. Within a name, `**` is the same as `*`, so `**.min.js` means `*.min.js`.
286
+ - A pattern with no `/` except at the end (like `*.min.js` or `vendor/`) matches in every directory; one with a leading or inner `/` (like `/setup.py` or `docs/**`) only from the repository root.
287
+ - A trailing `/` matches directories only, and a pattern that matches a directory covers every file in it.
288
+ - Every other character matches itself, case-sensitively, `#` and spaces included (a `.gitignore` would read a leading `#` as a comment and drop trailing spaces).
289
+
290
+ Negation (a leading `!`), `[` and `]` (character classes), backslashes (escapes) and `**` inside a name before a `/` (like `foo**/bar`, which git itself reads inconsistently) make the file invalid, rather than silently matching something other than what a `.gitignore` would. The report notes which files were skipped.
291
+
292
+ **Rule ids.** Each finding's id is in the JSON report (`"rule"`), and the review summary shows it next to each counted rule.
293
+
294
+ | Id | Flags | Severity | Applies to |
295
+ |----|-------|----------|------------|
296
+ | `todo-comment` | `TODO` / `FIXME` in a comment or docstring | LOW | source files |
297
+ | `console-log` | `console.log(` | LOW | JavaScript / TypeScript |
298
+ | `print-call` | `print(` | LOW | Python |
299
+ | `bare-except` | `except:` | MEDIUM | Python |
300
+ | `long-line` | added line longer than `max_line_length` | LOW | source files |
301
+ | `magic-number` | unexplained number literal | LOW | source files, not tests |
302
+ | `missing-return-type` | `def` without `->` | LOW | Python, not tests |
303
+ | `complexity` | function above `complexity_medium` / `complexity_high` | MEDIUM / HIGH | Python, JavaScript, TypeScript |
304
+ | `file-length` | a file the PR takes past `max_file_lines` | LOW | Python, JavaScript, TypeScript |
305
+ | `aws-access-key` | AWS access key id | CRITICAL | every file |
306
+ | `hardcoded-api-key` | `api_key = "..."` and similar | CRITICAL | every file |
307
+ | `private-key` | PEM private key block | CRITICAL | every file |
308
+ | `github-token` | GitHub token | CRITICAL | every file |
309
+ | `jwt-token` | JSON Web Token | HIGH | every file |
310
+ | `hardcoded-password` | `password = "..."` and similar | CRITICAL | every file |
311
+ | `db-connection-string` | database URL with a password | CRITICAL | every file |
312
+ | `sql-injection` | SQL built with an f-string, `%`, `.format()` or `+` | HIGH | source files |
313
+ | `eval` | `eval(` | HIGH | source files |
314
+ | `pickle-load` | `pickle.load(` / `pickle.loads(` | HIGH | source files |
315
+ | `yaml-load` | `yaml.load(` without `SafeLoader` | HIGH | source files |
316
+ | `subprocess-shell` | `subprocess.*(..., shell=True)` | HIGH | source files |
317
+ | `insecure-random` | `random` used for tokens, passwords or keys | MEDIUM | source files |
318
+ | `debug-mode` | `debug=True` | MEDIUM | source files |
319
+ | `semgrep` | the whole Semgrep scan | per Semgrep rule | changed files |
320
+ | `dependency-audit` | the whole pip-audit check | HIGH | `requirements*.txt` |
321
+ | `llm` | the whole LLM review | per suggestion | the diff |
322
+ | `config` | a problem in the copy of `.pr-analyzer.toml` a PR adds or changes | HIGH | `.pr-analyzer.toml` |
323
+
324
+ To tune individual Semgrep rules, point `SEMGREP_CONFIG` at your own ruleset.
325
+
326
+ ---
327
+
328
+ ## Use it in GitHub Actions
329
+
330
+ Add a workflow to the repository you want reviewed:
331
+
332
+ ```yaml
333
+ # .github/workflows/pr-analyzer.yml
334
+ name: PR Analyzer
335
+
336
+ on:
337
+ pull_request:
338
+ types: [opened, synchronize, reopened]
339
+
340
+ permissions: {}
341
+
342
+ jobs:
343
+ analyze:
344
+ runs-on: ubuntu-latest
345
+ permissions:
346
+ contents: read # read the PR's files and the base branch's .pr-analyzer.toml
347
+ pull-requests: write # post the review
348
+ steps:
349
+ - id: pr
350
+ uses: Quantumstringfloops/PRanalyzer@main # for stability, pin a release tag or a commit SHA
351
+ with:
352
+ llm-provider: openai # optional: leave out to skip the LLM
353
+ openai-api-key: ${{ secrets.OPENAI_API_KEY }}
354
+ extras: security,openai
355
+ ```
356
+
357
+ No checkout step is needed: the action reads the PR through the GitHub API and installs pr-analyzer from its own copy of this repository, so the action and the code always match.
358
+
359
+ | Input | Default | Description |
360
+ |-------|---------|-------------|
361
+ | `github-token` | `github.token` | Reads the PR and posts the review |
362
+ | `pr-url` | the PR that triggered the run | The pull request to analyse |
363
+ | `llm-provider` | empty | `openai`, `anthropic` or `ollama`. Empty skips the LLM review; a provider without its key is skipped with a note in the review |
364
+ | `llm-model` | the provider's default | Model to use |
365
+ | `openai-api-key`, `anthropic-api-key` | empty | Pass a secret here |
366
+ | `post-review` | `true` | Post the result as a review |
367
+ | `fail-on-block` | `true` | Fail the step when the PR is blocked |
368
+ | `config` | empty | A settings file in the workspace, used instead of the base branch's `.pr-analyzer.toml` (see below) |
369
+ | `extras` | `security` | Comma-separated optional parts: `security`, `openai`, `anthropic` |
370
+ | `python-version` | `3.12` | Python to run pr-analyzer with |
371
+
372
+ **Outputs:** `score` (0–100), `grade` (`A+` to `F`) and `passed` (`true`/`false`), read from the JSON report, e.g. `${{ steps.pr.outputs.grade }}`.
373
+
374
+ - The action runs on Linux and macOS runners.
375
+ - On `pull_request`, PRs from forks get a read-only token and no secrets, so the review can't be posted; skip them with `if: github.event.pull_request.head.repo.full_name == github.repository` on the job, as this repository's own workflow does.
376
+ - With `llm-provider: ollama`, set `OLLAMA_URL` in the step's `env:`.
377
+ - `config` reads a file from the workspace. If the job checks out the PR, that is the PR's own code, which could loosen its own checks, so prefer the base branch's `.pr-analyzer.toml` unless the file comes from somewhere the PR can't change.
378
+ - pr-analyzer is installed and run outside the workspace, with Python isolated (`-I`), so files in a checked-out PR can't stand in for pip or any other module, a `.env` file in the PR can't change its settings, and the log can't be fed workflow commands by text from the PR.
379
+
380
+ ---
381
+
382
+ ## Sample Output
383
+
384
+ ```
385
+ ╭─ PR #4821 — Fix authentication bypass ──────────────────────╮
386
+ │ Author: neil │
387
+ │ Repo: acme/api │
388
+ │ Branch: fix → main │
389
+ │ Draft: No │
390
+ │ Files: 6 +142 -38 │
391
+ ╰─────────────────────────────────────────────────────────────╯
392
+ ╭─ Quality Score ─────────────────────────────────────────────╮
393
+ │ Score: 71.5 Grade: C- ✗ BLOCKED │
394
+ │ 2 critical issue(s) found — review required │
395
+ ╰─────────────────────────────────────────────────────────────╯
396
+ Dimension Scores
397
+ ┌───────────────┬───────┬──────────────────────┐
398
+ │ Dimension │ Score │ Bar │
399
+ ├───────────────┼───────┼──────────────────────┤
400
+ │ security │ 20.0 │ ████░░░░░░░░░░░░░░░░ │
401
+ │ style │ 97.0 │ ███████████████████░ │
402
+ │ complexity │ 100.0 │ ████████████████████ │
403
+ │ documentation │ 100.0 │ ████████████████████ │
404
+ │ testing │ 100.0 │ ████████████████████ │
405
+ └───────────────┴───────┴──────────────────────┘
406
+
407
+ ✗ BLOCKED 3 suggestion(s) duration 8.31s
408
+ ```
409
+
410
+ ---
411
+
412
+ ## Configuration Reference
413
+
414
+ Settings come from environment variables or a `.env` file in the directory you run the command from (see `.env.example`). Unrelated keys in `.env` are ignored.
415
+
416
+ | Variable | Required | Default | Description |
417
+ |----------|----------|---------|-------------|
418
+ | `GITHUB_TOKEN` | ✴️ | — | Optional for public repos (unauthenticated: 60 requests/hour). Required for private repos and `--post-review` |
419
+ | `LLM_PROVIDER` | ❌ | `ollama` | `ollama` / `openai` / `anthropic` |
420
+ | `LLM_MODEL` | ❌ | per provider | `llama3.2` / `gpt-4o-mini` / `claude-haiku-5-5` |
421
+ | `OLLAMA_URL` | ❌ | `http://localhost:11434` | Where Ollama is listening |
422
+ | `OPENAI_API_KEY` | ✴️ | — | Required if `LLM_PROVIDER=openai` |
423
+ | `ANTHROPIC_API_KEY` | ✴️ | — | Required if `LLM_PROVIDER=anthropic` |
424
+ | `LLM_CONFIDENCE_THRESHOLD` | ❌ | `0.5` | Drop LLM suggestions below this confidence |
425
+ | `LLM_TIMEOUT_SECONDS` | ❌ | `120` | Time limit for one LLM request |
426
+ | `LLM_MAX_TOKENS` | ❌ | `4096` | Response length limit (Anthropic) |
427
+ | `MAX_DIFF_LINES` | ❌ | `3000` | Diff lines per LLM request; a bigger diff is [reviewed in parts](#big-pull-requests) |
428
+ | `LLM_TOKEN_BUDGET` | ❌ | `200000` | Estimated prompt tokens (characters ÷ 4) one run may send; files past it aren't AI-reviewed, and the report says how many. `0` = no limit |
429
+ | `LLM_MAX_CONCURRENCY` | ❌ | `1` | LLM requests in flight at once when a PR is reviewed in parts |
430
+ | `SEMGREP_CONFIG` | ❌ | `p/owasp-top-ten` | Semgrep ruleset: registry name, URL or local path |
431
+ | `SEMGREP_TIMEOUT_SECONDS` | ❌ | `300` | Time limit for the Semgrep run |
432
+ | `PIP_AUDIT_TIMEOUT_SECONDS` | ❌ | `300` | Time limit for pip-audit |
433
+ | `GITHUB_TIMEOUT_SECONDS` | ❌ | `30` | Time limit for each GitHub API request |
434
+ | `GITHUB_MAX_WORKERS` | ❌ | `8` | Files whose content is fetched at the same time |
435
+ | `GITHUB_RATE_LIMIT_MAX_WAIT_SECONDS` | ❌ | `60` | Longest wait for a GitHub rate limit to reset. A longer one fails the run while the PR is fetched (exit code 2); when the review is posted, it leaves the review out with a warning. `0` = never wait |
436
+ | `CACHE_ENABLED` | ❌ | `true` | `false` turns the [on-disk cache](#caching) off |
437
+ | `CACHE_DIR` | ❌ | `$XDG_CACHE_HOME/pr-analyzer`, else `~/.cache/pr-analyzer` | Where the cache lives |
438
+ | `CACHE_MAX_SIZE_MB` | ❌ | `500` | Beyond this, the least recently used entries are removed |
439
+ | `CACHE_MAX_AGE_DAYS` | ❌ | `30` | Entries unused for this long are removed |
440
+ | `LOG_LEVEL` | ❌ | `WARNING` | `DEBUG` / `INFO` / `WARNING` / `ERROR` (`--verbose` sets `INFO`) |
441
+
442
+ ---
443
+
444
+ ## Big Pull Requests
445
+
446
+ **Fetching.** The full content of `GITHUB_MAX_WORKERS` files is fetched at a time, each worker thread with a GitHub client of its own. With 100 ms per request, a 50-file PR took 19.2 s before and takes 3.1 s now (`benchmarks/fetch_benchmark.py`).
447
+
448
+ **Rate limits.** When GitHub answers with a rate limit (403 or 429), the request is sent again once the limit allows: after `Retry-After` (seconds, or a date), at `X-RateLimit-Reset` for the hourly limit, or after a minute when GitHub doesn't say. Each wait is logged as a warning, and the other requests hold back meanwhile; Ctrl-C ends the waits at once. A wait longer than `GITHUB_RATE_LIMIT_MAX_WAIT_SECONDS` isn't sat out. While the PR is fetched, the run then stops with exit code 2 and says when to try again. When the review is posted (`--post-review`, the last step), the report still stands: the review is left out, a warning says when to try again, and the exit code follows the verdict.
449
+
450
+ **LLM review in parts.** A diff of up to `MAX_DIFF_LINES` lines is reviewed in one request, as before. A bigger one is split into parts of whole files (a file too big for one part is cut between hunks, or between lines, keeping its line numbers), each reviewed in its own request, and the suggestions are merged. `LLM_TOKEN_BUDGET` caps what one run sends: once the next part would go over it, the remaining parts are skipped and a warning says how many files weren't AI-reviewed. If a request fails, the parts already reviewed are kept and the warning says what was left out. Parts are sent one at a time by default, because Ollama may queue parallel requests (see its `OLLAMA_NUM_PARALLEL`) and time spent in its queue counts towards `LLM_TIMEOUT_SECONDS`; for OpenAI and Anthropic, `LLM_MAX_CONCURRENCY=2` to `4` reviews a big PR faster.
451
+
452
+ ### Caching
453
+
454
+ Results that can't change are kept on disk, in `CACHE_DIR`:
455
+
456
+ - **file contents**, by git blob SHA: a file that didn't change keeps its blob SHA from push to push, so a new push fetches only the files it changed;
457
+ - **LLM replies**, by provider, model and the exact prompt: re-running the same diff costs no tokens and doesn't count towards `LLM_TOKEN_BUDGET`.
458
+
459
+ Failed requests are never cached, and neither is a file or reply that contains your `GITHUB_TOKEN`, `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`, or any of these: a private key block (PEM, OpenSSH or PGP), a PuTTY key file, or a GitHub, OpenAI or Anthropic (`sk-`), AWS access key ID (`AKIA`, `ASIA`), Slack (`xox?-`), Stripe live (`sk_live_`, `rk_live_`), Google API (`AIza`), GitLab (`glpat-`) or Hugging Face (`hf_`) token. Other secrets, such as an AWS secret access key on its own or a password in a database URL, aren't recognised and can be cached. A damaged entry counts as a miss, entries are written atomically, and the size and age limits are applied at most once an hour. `--no-cache` skips the cache for one run (for a fresh LLM review, say); `CACHE_ENABLED=false` turns it off.
460
+
461
+ Since the cache may hold code from private repositories, pr-analyzer creates its directory private to your user (mode 700). It uses an existing `CACHE_DIR`, and the `v1` folder in it, only if each is a real directory (not a symbolic link) that belongs to you: one that other users can open too is made private, and anything else turns the cache off for the run, with a warning. Entries are read only from your own files, never through a symbolic link, and pruning deletes nothing but the cache's own entry and temporary files. On Windows, which has no POSIX owners or modes, only symbolic links are refused.
462
+
463
+ To keep the cache between GitHub Actions runs, restore and save it with `actions/cache`. A saved cache can't be updated, so each run saves a new one and the next run restores the newest:
464
+
465
+ ```yaml
466
+ - uses: actions/cache@v4
467
+ with:
468
+ path: ~/.cache/pr-analyzer
469
+ key: pr-analyzer-${{ runner.os }}-${{ github.event.pull_request.number }}-${{ github.run_id }}
470
+ restore-keys: |
471
+ pr-analyzer-${{ runner.os }}-${{ github.event.pull_request.number }}-
472
+ pr-analyzer-${{ runner.os }}-
473
+ ```
474
+
475
+ GitHub scopes caches by branch, so a PR's runs restore what earlier runs of the same PR saved (or the default branch's), never another PR's.
476
+
477
+ ---
478
+
479
+ ## Suggestion Schema
480
+
481
+ | Field | Type | Allowed Values |
482
+ |-------|------|----------------|
483
+ | `filename` | `str` | repo-relative path |
484
+ | `line` | `int \| None` | line number in the new file, or `None` |
485
+ | `severity` | `str` | `CRITICAL` `HIGH` `MEDIUM` `LOW` |
486
+ | `category` | `str` | `security` `complexity` `style` `documentation` `testing` |
487
+ | `title` | `str` | short label |
488
+ | `description` | `str` | full explanation |
489
+ | `source` | `str` | `static` `security` `llm` |
490
+ | `confidence` | `float` | `0.0` – `1.0` |
491
+
492
+ ---
493
+
494
+ ## Scoring Formula
495
+
496
+ Each dimension starts at 100. Every suggestion subtracts:
497
+
498
+ ```
499
+ penalty = SEVERITY_PENALTIES[severity] × confidence
500
+
501
+ CRITICAL = 40 HIGH = 20 MEDIUM = 8 LOW = 3
502
+ ```
503
+
504
+ Dimension score is clamped to a minimum of 0. Confidence is clamped to 0–1, and a suggestion whose category isn't one of the five dimensions counts as `style`.
505
+
506
+ Overall score is a weighted sum, rounded to one decimal before grading (so the score shown always matches the grade):
507
+
508
+ ```
509
+ score = security×0.35 + complexity×0.25 + style×0.15 + documentation×0.15 + testing×0.10
510
+ ```
511
+
512
+ ### Grade Boundaries
513
+
514
+ | Score | Grade |
515
+ |-------|-------|
516
+ | 97–100 | A+ |
517
+ | 93–96 | A |
518
+ | 90–92 | A- |
519
+ | 87–89 | B+ |
520
+ | 83–86 | B |
521
+ | 80–82 | B- |
522
+ | 77–79 | C+ |
523
+ | 73–76 | C |
524
+ | 70–72 | C- |
525
+ | 65–69 | D |
526
+ | 0–64 | F |
527
+
528
+ A PR is **blocked** if any suggestion has severity `CRITICAL` or `HIGH`, regardless of overall score. A project can choose other severities with `block_on` in `.pr-analyzer.toml`.
529
+
530
+ ---
531
+
532
+ ## Troubleshooting
533
+
534
+ **`zsh: command not found: pr-analyzer`**
535
+ Activate the virtualenv you installed `pr-analyzer-cli` into, or use `pipx install pr-analyzer-cli`. From a source checkout, run it through Poetry: `poetry run pr-analyzer`.
536
+
537
+ **`ModuleNotFoundError: No module named 'pr_analyzer'`**
538
+ Install the package (`pip install pr-analyzer-cli`), or from a source checkout run `poetry install` in the `pr-analyzer/` directory (the one with `pyproject.toml`).
539
+
540
+ **`Authentication failed` or `GitHub rate limit hit`**
541
+ Set `GITHUB_TOKEN`. The `.env` file must be in the directory you run the command from, or export the variable in your shell. A rate limit that resets within `GITHUB_RATE_LIMIT_MAX_WAIT_SECONDS` is waited out; the message means it resets later than that.
542
+
543
+ **Warning: `… file(s) were not AI-reviewed, or only in part`**
544
+ The PR was reviewed in parts and some were left out: either `LLM_TOKEN_BUDGET` ran out (raise it, or set `0` for no limit) or an LLM request failed (the warning names the error). The other checks covered every file.
545
+
546
+ **Warning: `Semgrep scan skipped` / `Dependency audit skipped`**
547
+ The scanners are optional. Install them with `pip install "pr-analyzer-cli[security]"` (from source: `poetry install --extras "security"`).
548
+
549
+ **Warning: `LLM review skipped: Could not reach Ollama`**
550
+ Start Ollama and pull the model (`ollama pull llama3.2`), point `LLM_PROVIDER` at OpenAI or Anthropic, or pass `--skip-llm`.
551
+
552
+ **Bot posts no inline comments**
553
+ Only MEDIUM, HIGH and CRITICAL findings get inline comments (or the severities from `inline_min_severity` up), and only on lines that are part of the diff. Other findings at those levels are listed in the review body, and LOW findings are counted there by rule. A finding that already has a comment isn't commented on again when the PR is re-analysed. If GitHub rejects the inline comments, the summary is still posted. PRs from forks can't be reviewed from Actions (read-only token, no secrets).
554
+
555
+ ---
556
+
557
+ ## Known Limitations
558
+
559
+ - **Only scans changed lines** — security issues in unchanged code are not reported. A vulnerability introduced in a previous commit is invisible unless that line is touched again.
560
+ - **LLM suggestions are probabilistic** — the confidence filter reduces noise but does not eliminate it. Always verify HIGH findings manually.
561
+ - **No support for monorepos** — if a PR touches files across multiple packages the scorer treats them as one codebase.
562
+ - **Complexity covers Python, JavaScript and TypeScript only** — other languages get the regex checks but no complexity score.
563
+ - **Dependency audit checks exact pins only** — `name==version` lines added to `requirements*.txt`; ranges, `pyproject.toml` and lock files are not audited.
564
+ - **Semgrep's default ruleset is downloaded** from the Semgrep registry on every run, so it needs network access (point `SEMGREP_CONFIG` at a local file to avoid that).
565
+ - **Big PRs are reviewed in parts** — the LLM sees one part of the diff at a time, so it can miss a problem that only shows across files in different parts.
566
+ - **Fork PRs cannot post reviews** — GitHub blocks write tokens on PRs from forks by default.
567
+ - **Ignore markers are the PR author's call** — anyone can add `pr-analyzer: ignore` to a line. The report names every ignored line, so check them during review.
568
+ - **Line rules read one line at a time** — text in the middle of a multi-line docstring or comment can still trigger a code rule (see [What the line rules skip](#what-the-line-rules-skip)).
569
+ - **Secret rules don't know test fixtures** — a placeholder password in a test (`password="pass"`) is reported like a real one; mark it with `pr-analyzer: ignore` or leave the fixtures out with `ignore_paths`.
570
+
571
+ ---
572
+
573
+ ## Planned Improvements
574
+
575
+ - **Auto-refactoring suggestions** — generate a ready-to-apply patch for style and complexity findings
576
+ - **Base branch delta scoring** — compare score against the base branch to show whether the PR improved or degraded quality
577
+ - **Dependency audit for `pyproject.toml` and lock files**
578
+ - **Web dashboard** — timeline of PR scores per repository
579
+
580
+ ---
581
+
582
+ ## Releasing
583
+
584
+ Maintainers publish to PyPI by pushing a version tag; `.github/workflows/release.yml` does the rest:
585
+
586
+ 1. Set the new `version` in `pr-analyzer/pyproject.toml` and merge it to `main`.
587
+ 2. Tag that commit and push the tag: `git tag v0.2.0 && git push origin v0.2.0`.
588
+
589
+ The workflow stops if the tag doesn't match the version, then runs the tests, builds the wheel and sdist, checks them with `twine check --strict`, installs the wheel and runs it, and publishes with PyPI Trusted Publishing, so no API token is stored anywhere. Running the workflow by hand (Actions → Release → Run workflow) publishes the build to TestPyPI instead, as a rehearsal; TestPyPI refuses a version it already has.
590
+
591
+ One-time setup, before the first tag: on PyPI, add a pending trusted publisher (your account's *Publishing* page) for the project `pr-analyzer-cli` with owner `Quantumstringfloops`, repository `PRanalyzer`, workflow `release.yml` and environment `pypi`; do the same on TestPyPI with environment `testpypi`. PyPI checks the project name there, the same way it does on the first upload, so a name it won't accept is caught before anything is tagged. Then create both environments under the repository's *Settings → Environments*. Required reviewers on `pypi` make every release wait for an approval.
592
+
593
+ ---
594
+
595
+ ## License
596
+
597
+ MIT. See [LICENSE](https://github.com/Quantumstringfloops/PRanalyzer/blob/main/LICENSE).
598
+