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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PRAnalyzerError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidPRURLError(PRAnalyzerError, ValueError):
|
|
9
|
+
# also a ValueError, so code that validates URLs with `except ValueError` keeps working
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(PRAnalyzerError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidTokenError(PRAnalyzerError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PrivateRepoError(PRAnalyzerError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PRNotFoundError(PRAnalyzerError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RepositoryNotFoundError(PRAnalyzerError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RateLimitError(PRAnalyzerError):
|
|
34
|
+
def __init__(self, reset_time: datetime):
|
|
35
|
+
self.reset_time = reset_time
|
|
36
|
+
super().__init__(f"Rate limit exceeded. Resets at {reset_time.strftime('%H:%M:%S')}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LLMError(PRAnalyzerError):
|
|
40
|
+
# the LLM provider could not be reached or returned something unusable
|
|
41
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, wait
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
import structlog
|
|
8
|
+
from github import Auth, Github, GithubException, RateLimitExceededException
|
|
9
|
+
from github.Commit import Commit
|
|
10
|
+
from github.GithubObject import NotSet, Opt
|
|
11
|
+
from github.PullRequest import PullRequest, ReviewComment
|
|
12
|
+
|
|
13
|
+
from pr_analyzer.cache import DiskCache
|
|
14
|
+
from pr_analyzer.config import settings
|
|
15
|
+
from pr_analyzer.exceptions import (
|
|
16
|
+
InvalidPRURLError,
|
|
17
|
+
InvalidTokenError,
|
|
18
|
+
PRAnalyzerError,
|
|
19
|
+
PRNotFoundError,
|
|
20
|
+
PrivateRepoError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
RepositoryNotFoundError,
|
|
23
|
+
)
|
|
24
|
+
from pr_analyzer.github.markdown import neutralise, neutralise_line
|
|
25
|
+
from pr_analyzer.github.ratelimit import (
|
|
26
|
+
RateLimitExceeded,
|
|
27
|
+
RateLimitPause,
|
|
28
|
+
RateLimitRetry,
|
|
29
|
+
github_retry,
|
|
30
|
+
)
|
|
31
|
+
from pr_analyzer.models import FileChange, PRSummary, Suggestion
|
|
32
|
+
|
|
33
|
+
log = structlog.get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
GITHUB_HOSTS = {"github.com", "www.github.com"}
|
|
36
|
+
# PR pages people copy URLs from: /pull/123, /pull/123/files, /pull/123/commits, /pull/123/checks
|
|
37
|
+
PR_SUBPAGES = {"files", "commits", "checks"}
|
|
38
|
+
# hidden tag on the summary review, so a later run can find and update it
|
|
39
|
+
REVIEW_MARKER = "<!-- pr-analyzer -->"
|
|
40
|
+
# GitHub's maximum page size: a PR with 300 files is listed in 3 requests instead of 10
|
|
41
|
+
PER_PAGE = 100
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GithubClient:
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
token: str | None = None,
|
|
49
|
+
cache: DiskCache | None = None,
|
|
50
|
+
rate_limits: RateLimitPause | None = None,
|
|
51
|
+
):
|
|
52
|
+
self.token = settings.github_token if token is None else token
|
|
53
|
+
self.cache = cache if cache is not None else DiskCache.disabled()
|
|
54
|
+
self.max_workers = settings.github_max_workers
|
|
55
|
+
# retry transient 5xx errors on reads, and wait out rate limits for up to
|
|
56
|
+
# GITHUB_RATE_LIMIT_MAX_WAIT_SECONDS; a longer wait surfaces as RateLimitError
|
|
57
|
+
self.retry = github_retry(settings.github_rate_limit_max_wait_seconds, rate_limits)
|
|
58
|
+
self.auth = Auth.Token(self.token) if self.token else None
|
|
59
|
+
self.git_instance = self._new_github()
|
|
60
|
+
|
|
61
|
+
def _new_github(self, lazy: bool = False, retry: RateLimitRetry | None = None) -> Github:
|
|
62
|
+
return Github(
|
|
63
|
+
auth=self.auth,
|
|
64
|
+
timeout=settings.github_timeout_seconds,
|
|
65
|
+
retry=retry if retry is not None else self.retry,
|
|
66
|
+
per_page=PER_PAGE,
|
|
67
|
+
lazy=lazy,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self.git_instance.close()
|
|
72
|
+
|
|
73
|
+
def fetch_pr(self, url: str) -> PRSummary:
|
|
74
|
+
repo_name, pr_number = self.parse_url_and_validate(url)
|
|
75
|
+
if not self.token:
|
|
76
|
+
log.warning("github_token_missing", hint="unauthenticated requests are limited to 60/hour")
|
|
77
|
+
log.info("fetch_started", repo=repo_name, pr=pr_number)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
try:
|
|
81
|
+
repo = self.git_instance.get_repo(repo_name)
|
|
82
|
+
except GithubException as e:
|
|
83
|
+
if e.status == 404:
|
|
84
|
+
raise RepositoryNotFoundError(
|
|
85
|
+
f"{repo_name} does not exist or your token cannot see it"
|
|
86
|
+
) from e
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
pr = repo.get_pull(pr_number)
|
|
91
|
+
except GithubException as e:
|
|
92
|
+
if e.status == 404:
|
|
93
|
+
raise PRNotFoundError(f"PR #{pr_number} not found in {repo_name}") from e
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
return PRSummary(
|
|
97
|
+
number=pr.number,
|
|
98
|
+
title=pr.title,
|
|
99
|
+
author=pr.user.login if pr.user else "unknown",
|
|
100
|
+
repo=repo_name,
|
|
101
|
+
base_branch=pr.base.ref,
|
|
102
|
+
head_branch=pr.head.ref,
|
|
103
|
+
total_additions=pr.additions,
|
|
104
|
+
total_deletions=pr.deletions,
|
|
105
|
+
is_draft=bool(pr.draft),
|
|
106
|
+
files=self.build_file_changes(pr, repo),
|
|
107
|
+
head_sha=pr.head.sha,
|
|
108
|
+
)
|
|
109
|
+
except RateLimitExceededException as e:
|
|
110
|
+
raise RateLimitError(self._reset_time(e)) from e
|
|
111
|
+
except GithubException as e:
|
|
112
|
+
raise self._map_error(e, repo_name) from e
|
|
113
|
+
except requests.exceptions.RequestException as e:
|
|
114
|
+
raise PRAnalyzerError(f"Could not reach GitHub: {e}") from e
|
|
115
|
+
|
|
116
|
+
def parse_url_and_validate(self, pr_url: str) -> tuple[str, int]:
|
|
117
|
+
if not pr_url or not pr_url.strip():
|
|
118
|
+
raise InvalidPRURLError("The URL cannot be empty")
|
|
119
|
+
|
|
120
|
+
parts = urlsplit(pr_url.strip())
|
|
121
|
+
if parts.scheme != "https":
|
|
122
|
+
raise InvalidPRURLError("URL must start with https://")
|
|
123
|
+
if parts.netloc.lower() not in GITHUB_HOSTS:
|
|
124
|
+
raise InvalidPRURLError("Domain must be github.com")
|
|
125
|
+
|
|
126
|
+
path = parts.path.strip("/").split("/")
|
|
127
|
+
if len(path) == 5 and path[4] in PR_SUBPAGES:
|
|
128
|
+
path = path[:4]
|
|
129
|
+
if len(path) != 4 or path[2] != "pull" or not path[0] or not path[1]:
|
|
130
|
+
raise InvalidPRURLError("Expected https://github.com/<owner>/<repo>/pull/<number>")
|
|
131
|
+
if not (path[3].isascii() and path[3].isdigit()):
|
|
132
|
+
raise InvalidPRURLError(f"PR number must be an integer, got: {path[3]!r}")
|
|
133
|
+
|
|
134
|
+
repo_name = f"{path[0]}/{path[1]}"
|
|
135
|
+
pr_number = int(path[3])
|
|
136
|
+
log.info("url_validated", repo=repo_name, pr=pr_number)
|
|
137
|
+
return repo_name, pr_number
|
|
138
|
+
|
|
139
|
+
def build_file_changes(self, pr, repo) -> list[FileChange]:
|
|
140
|
+
files = []
|
|
141
|
+
blobs: dict[str, str | None] = {}
|
|
142
|
+
for f in pr.get_files():
|
|
143
|
+
if f.patch is None:
|
|
144
|
+
# binary files, very large diffs and pure renames come without a patch
|
|
145
|
+
log.info("file_skipped_no_patch", filename=f.filename, status=f.status)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
files.append(FileChange(
|
|
149
|
+
filename=f.filename,
|
|
150
|
+
status=f.status,
|
|
151
|
+
additions=f.additions,
|
|
152
|
+
deletions=f.deletions,
|
|
153
|
+
patch=f.patch,
|
|
154
|
+
))
|
|
155
|
+
# the file's git blob SHA: its content's identity, the same from push to push
|
|
156
|
+
# while the file doesn't change
|
|
157
|
+
sha = getattr(f, "sha", None)
|
|
158
|
+
blobs[f.filename] = sha if isinstance(sha, str) else None
|
|
159
|
+
|
|
160
|
+
present = [f for f in files if f.status != "removed"]
|
|
161
|
+
wanted = [(f.filename, blobs[f.filename]) for f in present]
|
|
162
|
+
contents = self.fetch_contents(repo, wanted, pr.head.sha)
|
|
163
|
+
for f, content in zip(present, contents, strict=True):
|
|
164
|
+
f.raw_content = content
|
|
165
|
+
|
|
166
|
+
log.info("files_fetched", repo=repo.full_name, pr=pr.number, file_count=len(files))
|
|
167
|
+
return files
|
|
168
|
+
|
|
169
|
+
def fetch_contents(
|
|
170
|
+
self, repo, files: list[tuple[str, str | None]], ref: str
|
|
171
|
+
) -> list[str | None]:
|
|
172
|
+
"""The text at `ref` of each (path, blob SHA) in `files`, in order (None where it isn't
|
|
173
|
+
available), from the cache or fetched GITHUB_MAX_WORKERS at a time.
|
|
174
|
+
|
|
175
|
+
The first failure, or Ctrl-C, ends a parallel fetch at once: the files still queued
|
|
176
|
+
aren't fetched, and the requests in flight stop waiting and aren't sent again."""
|
|
177
|
+
repo_name = repo.full_name
|
|
178
|
+
if len(files) < 2 or self.max_workers < 2:
|
|
179
|
+
# on the main thread, which Ctrl-C reaches itself
|
|
180
|
+
return [
|
|
181
|
+
self._cached_content(repo, repo_name, file, ref, self.retry.pause) for file in files
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
# PyGithub's Requester isn't thread-safe: it keeps one connection object per client, and
|
|
185
|
+
# that object holds each request's verb, URL and headers in attributes between request()
|
|
186
|
+
# and getresponse() (HTTPSRequestsConnectionClass in github/Requester.py), so two threads
|
|
187
|
+
# sharing a client can send each other's requests. Each worker thread gets a client of
|
|
188
|
+
# its own instead: its own Requester, connection and requests.Session. What the threads
|
|
189
|
+
# share (the token, the retry policy and the cache) holds no per-request state.
|
|
190
|
+
# Their rate-limit waits hold back the run's other requests as usual, but this fetch can
|
|
191
|
+
# cancel them: Ctrl-C reaches only the main thread.
|
|
192
|
+
pause = self.retry.pause.scope()
|
|
193
|
+
retry = self.retry.new(pause=pause)
|
|
194
|
+
local = threading.local()
|
|
195
|
+
clients: list[Github] = []
|
|
196
|
+
lock = threading.Lock()
|
|
197
|
+
|
|
198
|
+
def fetch(file: tuple[str, str | None]) -> str | None:
|
|
199
|
+
thread_repo = getattr(local, "repo", None)
|
|
200
|
+
if thread_repo is None:
|
|
201
|
+
with lock: # once the clients are being closed, no new one is made
|
|
202
|
+
pause.raise_if_cancelled()
|
|
203
|
+
client = self._new_github(lazy=True, retry=retry) # lazy: no request yet
|
|
204
|
+
clients.append(client)
|
|
205
|
+
thread_repo = local.repo = client.get_repo(repo_name)
|
|
206
|
+
return self._cached_content(thread_repo, repo_name, file, ref, pause)
|
|
207
|
+
|
|
208
|
+
pool = ThreadPoolExecutor(
|
|
209
|
+
max_workers=min(self.max_workers, len(files)), thread_name_prefix="github-contents"
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
futures = [pool.submit(fetch, file) for file in files]
|
|
213
|
+
# a failure is raised as soon as it happens, not once the files before it are in
|
|
214
|
+
wait(futures, return_when=FIRST_EXCEPTION)
|
|
215
|
+
errors = [e for f in futures if f.done() and (e := f.exception()) is not None]
|
|
216
|
+
if errors:
|
|
217
|
+
raise errors[0]
|
|
218
|
+
contents = [future.result() for future in futures]
|
|
219
|
+
except BaseException:
|
|
220
|
+
# Ctrl-C or a failure: the waits in flight end and nothing is sent again, the fetches
|
|
221
|
+
# still queued are dropped, and the ones in flight aren't waited for
|
|
222
|
+
pause.cancel()
|
|
223
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
224
|
+
raise
|
|
225
|
+
finally:
|
|
226
|
+
with lock:
|
|
227
|
+
for client in clients:
|
|
228
|
+
client.close()
|
|
229
|
+
pool.shutdown() # every fetch is done: the workers are idle
|
|
230
|
+
return contents
|
|
231
|
+
|
|
232
|
+
def _cached_content(
|
|
233
|
+
self, repo, repo_name: str, file: tuple[str, str | None], ref: str, pause: RateLimitPause
|
|
234
|
+
) -> str | None:
|
|
235
|
+
# the text of a blob never changes, and neither does a path's at a commit
|
|
236
|
+
path, blob = file
|
|
237
|
+
owner_repo = repo_name.lower()
|
|
238
|
+
key = ("blob", owner_repo, blob) if blob else ("commit", owner_repo, ref, path)
|
|
239
|
+
text = self.cache.get("contents", *key)
|
|
240
|
+
if text is not None:
|
|
241
|
+
return text
|
|
242
|
+
|
|
243
|
+
pause.wait_if_paused() # while a request waits out a rate limit, hold back
|
|
244
|
+
text, sha = self._fetch_content(repo, path, ref)
|
|
245
|
+
# the PR may have moved on since its files were listed; a blob's key gets only its text
|
|
246
|
+
if text is not None and (blob is None or sha == blob):
|
|
247
|
+
self.cache.put("contents", *key, value=text)
|
|
248
|
+
return text
|
|
249
|
+
|
|
250
|
+
def _fetch_content(self, repo, path: str, ref: str) -> tuple[str | None, str | None]:
|
|
251
|
+
"""The file's text at `ref` and its blob SHA, or (None, None) if it can't be had."""
|
|
252
|
+
try:
|
|
253
|
+
content = repo.get_contents(path, ref=ref)
|
|
254
|
+
except RateLimitExceededException:
|
|
255
|
+
raise
|
|
256
|
+
except GithubException as e:
|
|
257
|
+
log.warning("content_unavailable", filename=path, status=e.status)
|
|
258
|
+
return None, None
|
|
259
|
+
|
|
260
|
+
# a list means a directory or submodule; files over 1 MB come back without base64 content
|
|
261
|
+
if isinstance(content, list) or content.encoding != "base64":
|
|
262
|
+
log.warning("content_unavailable", filename=path, reason="not a file or over 1 MB")
|
|
263
|
+
return None, None
|
|
264
|
+
text = content.decoded_content.decode("utf-8", errors="replace")
|
|
265
|
+
return text, getattr(content, "sha", None)
|
|
266
|
+
|
|
267
|
+
def fetch_file(self, repo_name: str, path: str, ref: str) -> bytes | None:
|
|
268
|
+
"""The raw bytes of `path` at `ref` (a branch, tag or commit), or None if there's no such
|
|
269
|
+
file. Used for .pr-analyzer.toml on the PR's base branch."""
|
|
270
|
+
try:
|
|
271
|
+
content = self.git_instance.get_repo(repo_name).get_contents(path, ref=ref)
|
|
272
|
+
except RateLimitExceededException as e:
|
|
273
|
+
raise RateLimitError(self._reset_time(e)) from e
|
|
274
|
+
except GithubException as e:
|
|
275
|
+
if e.status == 404:
|
|
276
|
+
return None
|
|
277
|
+
# anything else must not silently fall back to defaults the project didn't choose
|
|
278
|
+
problem = self._map_error(e, repo_name)
|
|
279
|
+
raise PRAnalyzerError(f"Could not read {path} at {ref}: {problem}") from e
|
|
280
|
+
except requests.exceptions.RequestException as e:
|
|
281
|
+
raise PRAnalyzerError(f"Could not reach GitHub: {e}") from e
|
|
282
|
+
|
|
283
|
+
if isinstance(content, list):
|
|
284
|
+
return None # a directory of that name
|
|
285
|
+
if content.encoding != "base64":
|
|
286
|
+
raise PRAnalyzerError(f"{path} at {ref} is over 1 MB, too large to read")
|
|
287
|
+
return content.decoded_content
|
|
288
|
+
|
|
289
|
+
def post_review(
|
|
290
|
+
self, pr: PRSummary, body: str, comments: list[tuple[Suggestion, int]]
|
|
291
|
+
) -> None:
|
|
292
|
+
if not self.token:
|
|
293
|
+
raise InvalidTokenError("--post-review needs a GITHUB_TOKEN that can write pull requests")
|
|
294
|
+
|
|
295
|
+
# titles and descriptions can come from the LLM or quote the PR's code: neutralise them so
|
|
296
|
+
# they can't ping people, hide text in HTML or show links (the path is an API field)
|
|
297
|
+
review_comments: list[ReviewComment] = [
|
|
298
|
+
{
|
|
299
|
+
"path": suggestion.filename,
|
|
300
|
+
"line": line,
|
|
301
|
+
"side": "RIGHT",
|
|
302
|
+
"body": f"**{suggestion.severity}** — {neutralise_line(suggestion.title)}\n\n"
|
|
303
|
+
f"{neutralise(suggestion.description)}",
|
|
304
|
+
}
|
|
305
|
+
for suggestion, line in comments
|
|
306
|
+
]
|
|
307
|
+
summary = f"{body}\n\n{REVIEW_MARKER}"
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
repo = self.git_instance.get_repo(pr.repo)
|
|
311
|
+
pull = repo.get_pull(pr.number)
|
|
312
|
+
# pin the review to the analysed commit so line numbers match even if the PR moved on
|
|
313
|
+
commit = repo.get_commit(pr.head_sha) if pr.head_sha else NotSet
|
|
314
|
+
|
|
315
|
+
# every push re-runs the analysis, and each run used to add a whole new review that
|
|
316
|
+
# repeated every comment. Skip findings already commented on (GitHub clears `line`
|
|
317
|
+
# once the code under a comment changes, so those get a fresh comment) and update the
|
|
318
|
+
# earlier summary in place.
|
|
319
|
+
posted = {(c.path, c.line, c.body.strip()) for c in pull.get_review_comments()}
|
|
320
|
+
review_comments = [
|
|
321
|
+
c for c in review_comments if (c["path"], c["line"], c["body"].strip()) not in posted
|
|
322
|
+
]
|
|
323
|
+
if not self._update_summary(pull, summary):
|
|
324
|
+
review_comments = self._create_review(pull, commit, summary, review_comments)
|
|
325
|
+
elif review_comments:
|
|
326
|
+
note = (f"**pr-analyzer:** {len(review_comments)} new finding(s) since the last run. "
|
|
327
|
+
f"The summary in its earlier review is kept up to date.")
|
|
328
|
+
review_comments = self._create_review(pull, commit, note, review_comments)
|
|
329
|
+
except RateLimitExceededException as e:
|
|
330
|
+
raise RateLimitError(self._reset_time(e)) from e
|
|
331
|
+
except GithubException as e:
|
|
332
|
+
raise self._map_error(e, pr.repo) from e
|
|
333
|
+
except requests.exceptions.RequestException as e:
|
|
334
|
+
raise PRAnalyzerError(f"Could not reach GitHub: {e}") from e
|
|
335
|
+
|
|
336
|
+
log.info("review_posted", pr=pr.number, comments=len(review_comments))
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _update_summary(pull: PullRequest, summary: str) -> bool:
|
|
340
|
+
"""Edit the summary review an earlier run posted; False if there's none this token may edit."""
|
|
341
|
+
# newest first: when several accounts run pr-analyzer on a PR, only our own review is editable
|
|
342
|
+
for review in reversed(list(pull.get_reviews())):
|
|
343
|
+
if REVIEW_MARKER not in (review.body or ""):
|
|
344
|
+
continue
|
|
345
|
+
try:
|
|
346
|
+
review.edit(summary)
|
|
347
|
+
return True
|
|
348
|
+
except RateLimitExceededException:
|
|
349
|
+
# a 403 too, but not "someone else's review": posting a new one would duplicate it
|
|
350
|
+
raise
|
|
351
|
+
except GithubException as e:
|
|
352
|
+
if e.status not in (403, 404):
|
|
353
|
+
raise
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _create_review(
|
|
358
|
+
pull: PullRequest, commit: Opt[Commit], body: str, comments: list[ReviewComment]
|
|
359
|
+
) -> list[ReviewComment]:
|
|
360
|
+
"""Post a review and return the inline comments that went with it."""
|
|
361
|
+
try:
|
|
362
|
+
pull.create_review(commit=commit, body=body, event="COMMENT", comments=comments)
|
|
363
|
+
return comments
|
|
364
|
+
except GithubException as e:
|
|
365
|
+
if e.status != 422 or not comments:
|
|
366
|
+
raise
|
|
367
|
+
# GitHub rejects the whole review if any inline comment falls outside the diff;
|
|
368
|
+
# post the text alone rather than nothing
|
|
369
|
+
log.warning("inline_comments_rejected", error=str(e.data))
|
|
370
|
+
pull.create_review(
|
|
371
|
+
commit=commit,
|
|
372
|
+
body=body + "\n\n_Inline comments could not be attached to this diff._",
|
|
373
|
+
event="COMMENT",
|
|
374
|
+
)
|
|
375
|
+
return []
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def _map_error(e: GithubException, repo_name: str) -> PRAnalyzerError:
|
|
379
|
+
if e.status == 401:
|
|
380
|
+
return InvalidTokenError("GITHUB_TOKEN is invalid or expired")
|
|
381
|
+
if e.status == 403:
|
|
382
|
+
return PrivateRepoError(f"Your token is not allowed to access {repo_name}")
|
|
383
|
+
message = e.data.get("message") if isinstance(e.data, dict) else e.data
|
|
384
|
+
return PRAnalyzerError(f"GitHub error {e.status}: {message}")
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _reset_time(e: RateLimitExceededException) -> datetime:
|
|
388
|
+
if isinstance(e, RateLimitExceeded):
|
|
389
|
+
# the retry policy worked it out, from an HTTP date or GitHub's minute for a limit
|
|
390
|
+
# named only in the message too (x-ratelimit-reset is the hourly limit's)
|
|
391
|
+
return datetime.fromtimestamp(e.retry_at)
|
|
392
|
+
headers = {k.lower(): v for k, v in (e.headers or {}).items()}
|
|
393
|
+
try:
|
|
394
|
+
if "retry-after" in headers: # secondary rate limits say how long to wait
|
|
395
|
+
return datetime.now() + timedelta(seconds=int(headers["retry-after"]))
|
|
396
|
+
return datetime.fromtimestamp(int(headers["x-ratelimit-reset"]))
|
|
397
|
+
except (KeyError, TypeError, ValueError):
|
|
398
|
+
return datetime.now()
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Neutralise untrusted text before the bot posts it on GitHub.
|
|
2
|
+
|
|
3
|
+
Much of what the bot posts is shaped by the PR: titles and descriptions the LLM wrote after
|
|
4
|
+
reading it, Semgrep messages that quote its code, and file names it chose. Posted as-is, that text
|
|
5
|
+
could ping people, hide the rest of the review inside an HTML comment, fake the bot's hidden
|
|
6
|
+
review tag, or show a convincing link. A zero-width space after the character that starts each
|
|
7
|
+
of these keeps the text looking the same while GitHub no longer recognises it:
|
|
8
|
+
|
|
9
|
+
@ mentions (@user, @org/team) and e-mail autolinks
|
|
10
|
+
< + letter / ! ? raw HTML, including <!-- comments --> and <https://...> autolinks
|
|
11
|
+
] + ( [ : links and images, [text](url), [text][ref] and , and
|
|
12
|
+
reference definitions [x]: url
|
|
13
|
+
:// www. bare-URL autolinks
|
|
14
|
+
# or GH- + digit issue and PR references (#12, GH-12), which would add a cross-link to
|
|
15
|
+
the referenced issue
|
|
16
|
+
& + # or name; character references such as @ and @, which GitHub would
|
|
17
|
+
decode into the characters above
|
|
18
|
+
|
|
19
|
+
Nothing else changes. A "<" or "]" that can't start any of these is left as it is, and so is
|
|
20
|
+
code: GitHub shows inline code spans and fenced code blocks exactly as written, and an invisible
|
|
21
|
+
character in them would break copying the code or committing a suggested change. Only "]:" in
|
|
22
|
+
inline code still gets one, since a reference definition is read before code spans are. Whatever
|
|
23
|
+
GitHub might not show as code is neutralised like the rest: blocks it draws instead (maths,
|
|
24
|
+
diagrams), code that a "$" could turn into maths, and code whose place can't be told for sure.
|
|
25
|
+
|
|
26
|
+
Single-line contexts (list items, table cells) also lose their newlines, so the text can't add
|
|
27
|
+
headings or rows of its own.
|
|
28
|
+
"""
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
ZERO_WIDTH_SPACE = "\u200b"
|
|
32
|
+
|
|
33
|
+
# where a zero-width space goes, unless one is already there. GitHub decodes backslash escapes
|
|
34
|
+
# too, so GH\-12, http:\/\/ and www\. count as well.
|
|
35
|
+
_BREAK = re.compile(
|
|
36
|
+
r"(?:(?<=@)"
|
|
37
|
+
r"|(?<=<)(?=[a-z/!?])"
|
|
38
|
+
r"|(?<=\])(?=[(\[:])"
|
|
39
|
+
r"|(?<=:)(?=\\?/\\?/)"
|
|
40
|
+
r"|(?<=www)(?=\\?\.)"
|
|
41
|
+
r"|(?<=#)(?=\d)"
|
|
42
|
+
r"|(?:(?<=gh-)|(?<=gh\\-))(?=\d)"
|
|
43
|
+
r"|(?<=&)(?=#|[a-z][a-z0-9]*;)"
|
|
44
|
+
r")(?!\u200b)",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
# A reference definition, [label]: url, is read before code spans are, so that a later [label]
|
|
48
|
+
# links to url. Inline code still gets a zero-width space between "]" and ":".
|
|
49
|
+
_LABEL_END = re.compile(r"(?<=\])(?=:)")
|
|
50
|
+
|
|
51
|
+
# CommonMark's line endings; split() keeps them, so the text goes back together unchanged
|
|
52
|
+
_LINE_ENDING = re.compile(r"(\r\n|\r|\n)")
|
|
53
|
+
_BLANK = re.compile(r"[ \t]*")
|
|
54
|
+
# a line that opens a fenced code block: three or more backticks with none in the info string
|
|
55
|
+
# after them, or three or more tildes
|
|
56
|
+
_FENCE_OPEN = re.compile(r"(`{3,})([^`]*)|(~{3,})(.*)", re.DOTALL)
|
|
57
|
+
_FENCE_CLOSE = re.compile(r" {0,3}(`+|~+)[ \t]*")
|
|
58
|
+
# a fence indented by one to three spaces, which may belong to a list item
|
|
59
|
+
_INDENTED_FENCE = re.compile(r" {1,3}(?:```|~~~)")
|
|
60
|
+
# what could be a fence anywhere in a line, as in a list item or a block quote
|
|
61
|
+
_FENCE_RUN = re.compile(r"`{3,}|~{3,}")
|
|
62
|
+
# GitHub draws these blocks (maths, diagrams, maps, models) instead of showing them as code, and
|
|
63
|
+
# the drawing could hold a link; so could maths written with "$" around code. Both are text here.
|
|
64
|
+
_DRAWN = {"math", "mermaid", "geojson", "topojson", "stl"}
|
|
65
|
+
# a plain language name in an info string, such as python, c++ or objective-c
|
|
66
|
+
_LANGUAGE = re.compile(r"[\w+#.-]+")
|
|
67
|
+
# what an inline parser stops at on its way to a code span: a backslash escape or backticks
|
|
68
|
+
_ESCAPE_OR_BACKTICKS = re.compile(r"\\[!-/:-@\[-`{-~]|`+")
|
|
69
|
+
# an e-mail autolink, <name@host>, whose name holds a backtick: the autolink comes first
|
|
70
|
+
_MAIL_WITH_BACKTICK = re.compile(r"<[\w.!#$%&'*+/=?^`{|}~-]*`[\w.!#$%&'*+/=?^`{|}~-]*@")
|
|
71
|
+
# GitHub's parser (cmark-gfm) never opens a code span with a longer run of backticks
|
|
72
|
+
_MAX_BACKTICKS = 1000
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def neutralise(text: str) -> str:
|
|
76
|
+
"""Untrusted text made safe to post as GitHub Markdown, still reading the same. The text must
|
|
77
|
+
start a block of its own at the top level of the comment, as a review comment's description
|
|
78
|
+
does after a blank line; in a list item or a table cell, use neutralise_line()."""
|
|
79
|
+
parts = _LINE_ENDING.split(str(text))
|
|
80
|
+
lines = parts[::2]
|
|
81
|
+
unsure = False # whether an earlier line of the paragraph makes its code spans uncertain
|
|
82
|
+
for i, block in enumerate(_fenced_blocks(lines)):
|
|
83
|
+
if block == "code" or _BLANK.fullmatch(lines[i]):
|
|
84
|
+
unsure = False # a code block or a blank line ends the paragraph
|
|
85
|
+
continue
|
|
86
|
+
lines[i], unsettling = _neutralise_inline(lines[i], trust_code=block == "" and not unsure)
|
|
87
|
+
# so does a drawn block, whose lines are all neutralised
|
|
88
|
+
unsure = block == "" and (unsure or unsettling)
|
|
89
|
+
parts[::2] = lines
|
|
90
|
+
return "".join(parts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def neutralise_line(text: str) -> str:
|
|
94
|
+
"""neutralise() for text that must stay on one line."""
|
|
95
|
+
return _neutralise_inline(" ".join(str(text).split()))[0]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def code_span(text: str) -> str:
|
|
99
|
+
"""Untrusted text as inline code. The fence is longer than any run of backticks inside, so
|
|
100
|
+
a file name like a`b can't close the span early."""
|
|
101
|
+
text = " ".join(str(text).split())
|
|
102
|
+
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
|
|
103
|
+
fence = "`" * (longest + 1)
|
|
104
|
+
padding = " " if text.startswith("`") or text.endswith("`") else ""
|
|
105
|
+
return f"{fence}{padding}{text}{padding}{fence}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _neutralise_inline(line: str, trust_code: bool = True) -> tuple[str, bool]:
|
|
109
|
+
"""One line of Markdown neutralised outside its code spans, and whether the later lines of
|
|
110
|
+
its paragraph can no longer trust theirs: a run of backticks left open could pair with one of
|
|
111
|
+
theirs, and after a "$" their code could be maths."""
|
|
112
|
+
spans, left_open = _code_spans(line)
|
|
113
|
+
# a run left open on an earlier line, or an e-mail autolink, would pair the backticks
|
|
114
|
+
# differently, and a table splits its rows at every "|", inside code spans too: then none of
|
|
115
|
+
# the line counts as code
|
|
116
|
+
if (not trust_code or _MAIL_WITH_BACKTICK.search(line)
|
|
117
|
+
or any("|" in line[start:end] for start, end in spans)):
|
|
118
|
+
spans = []
|
|
119
|
+
# code after a "$" could be maths, $`...`$ or inside $...$ or $$...$$, which GitHub draws
|
|
120
|
+
dollar = line.find("$")
|
|
121
|
+
if dollar != -1:
|
|
122
|
+
spans = [(start, end) for start, end in spans if start < dollar]
|
|
123
|
+
pieces, pos = [], 0
|
|
124
|
+
for start, end in spans:
|
|
125
|
+
code = _LABEL_END.sub(ZERO_WIDTH_SPACE, line[start:end])
|
|
126
|
+
pieces += [_BREAK.sub(ZERO_WIDTH_SPACE, line[pos:start]), code]
|
|
127
|
+
pos = end
|
|
128
|
+
pieces.append(_BREAK.sub(ZERO_WIDTH_SPACE, line[pos:]))
|
|
129
|
+
return "".join(pieces), left_open or dollar != -1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _code_spans(line: str) -> tuple[list[tuple[int, int]], bool]:
|
|
133
|
+
"""The (start, end) of each inline code span in one line, found as CommonMark finds them, and
|
|
134
|
+
whether a run of backticks found no partner on the line.
|
|
135
|
+
|
|
136
|
+
A span opens at a run of backticks that isn't backslash-escaped and closes at the next run of
|
|
137
|
+
exactly as many; a backslash inside a span is just a backslash. A run with no partner is
|
|
138
|
+
plain text, and the search goes on after it."""
|
|
139
|
+
spans: list[tuple[int, int]] = []
|
|
140
|
+
left_open = False
|
|
141
|
+
pos = 0
|
|
142
|
+
while opening := _ESCAPE_OR_BACKTICKS.search(line, pos):
|
|
143
|
+
pos = opening.end()
|
|
144
|
+
if opening[0].startswith("\\") or len(opening[0]) > _MAX_BACKTICKS:
|
|
145
|
+
continue
|
|
146
|
+
closing = re.compile(rf"(?<!`){opening[0]}(?!`)").search(line, pos)
|
|
147
|
+
if closing:
|
|
148
|
+
spans.append((opening.start(), closing.end()))
|
|
149
|
+
pos = closing.end()
|
|
150
|
+
else:
|
|
151
|
+
left_open = True
|
|
152
|
+
return spans, left_open
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _fenced_blocks(lines: list[str]) -> list[str]:
|
|
156
|
+
"""For each line, the fenced block it belongs to, fences included: "code" for one GitHub
|
|
157
|
+
shows as code, "drawn" for one it draws instead (see _DRAWN), "" for none.
|
|
158
|
+
|
|
159
|
+
A fence at the very start of a line can't be inside a list item or a block quote, so its block
|
|
160
|
+
ends only at its closing fence (or the end of the text). One indented by a few spaces may be in
|
|
161
|
+
a list item, which a later line can end before the fence does, so from there on no fence is
|
|
162
|
+
trusted. Once a drawn block may have opened where its end can't be told, every later line
|
|
163
|
+
counts as drawn."""
|
|
164
|
+
blocks: list[str] = []
|
|
165
|
+
fence = kind = "" # the open block's fence, such as ``` or ~~~~, and its kind
|
|
166
|
+
trusted = True
|
|
167
|
+
rest = "" # what lines outside a trusted block count as
|
|
168
|
+
for line in lines:
|
|
169
|
+
if fence:
|
|
170
|
+
blocks.append(kind)
|
|
171
|
+
fence = "" if _closes(line, fence) else fence
|
|
172
|
+
continue
|
|
173
|
+
opening = _FENCE_OPEN.fullmatch(line) if trusted else None
|
|
174
|
+
if opening:
|
|
175
|
+
fence = opening[1] or opening[3]
|
|
176
|
+
kind = "code" if _shown_as_code(opening[2] if opening[1] else opening[4]) else "drawn"
|
|
177
|
+
blocks.append(kind)
|
|
178
|
+
continue
|
|
179
|
+
if any(not _shown_as_code(line[run.end():]) for run in _FENCE_RUN.finditer(line)):
|
|
180
|
+
rest = "drawn"
|
|
181
|
+
trusted = trusted and not _INDENTED_FENCE.match(line)
|
|
182
|
+
blocks.append(rest)
|
|
183
|
+
return blocks
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _closes(line: str, fence: str) -> bool:
|
|
187
|
+
"""Whether the line is a closing fence for a block opened with `fence`."""
|
|
188
|
+
closing = _FENCE_CLOSE.fullmatch(line)
|
|
189
|
+
return closing is not None and closing[1][0] == fence[0] and len(closing[1]) >= len(fence)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _shown_as_code(info: str) -> bool:
|
|
193
|
+
"""Whether GitHub shows a fenced block with this info string as code. The first word names
|
|
194
|
+
the language; one that isn't a plain name could still decode to the name of a drawn block.
|
|
195
|
+
Zero-width spaces don't count, so that neutralising the text again can't change the answer."""
|
|
196
|
+
words = info.replace(ZERO_WIDTH_SPACE, "").split()
|
|
197
|
+
if not words:
|
|
198
|
+
return True
|
|
199
|
+
return _LANGUAGE.fullmatch(words[0]) is not None and words[0].lower() not in _DRAWN
|