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.
@@ -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 ![alt](src), 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 &#x40; and &commat;, 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