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,277 @@
1
+ """Waiting out GitHub's rate limits instead of failing the run.
2
+
3
+ GitHub refuses a rate-limited request with 403 or 429 and doesn't carry it out, so the request,
4
+ even a POST, can be sent again once the limit allows:
5
+
6
+ - primary limit (`x-ratelimit-remaining: 0`): wait until `x-ratelimit-reset` (epoch seconds)
7
+ - secondary limit: wait `retry-after` (seconds, or an HTTP date), or a minute when GitHub
8
+ doesn't say
9
+
10
+ A wait longer than GITHUB_RATE_LIMIT_MAX_WAIT_SECONDS raises RateLimitExceededException instead,
11
+ which the client reports as RateLimitError, with the time GitHub lets the request through again:
12
+ while the PR is fetched that stops the run (exit code 2), while the review is posted it becomes
13
+ a warning. Waits are logged as warnings, and they can be cancelled: when a parallel fetch fails
14
+ or is interrupted (Ctrl-C), its waits end at once and none of its requests is sent again.
15
+
16
+ This is a urllib3 Retry of our own rather than PyGithub's GithubRetry because GithubRetry (2.10)
17
+ caps only primary-limit waits: a 403 with Retry-After falls through to urllib3, which sleeps for
18
+ whatever it says (up to six hours, silently). It ignores 429, retries 5xx errors on POST too (a
19
+ 502 after GitHub created a review would post the review twice), and logs through the standard
20
+ logging module, which this project doesn't show. The plain urllib3 Retry used before had the
21
+ same silent Retry-After sleep on 429 and 503 responses.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import math
27
+ import threading
28
+ import time
29
+ from collections.abc import Callable, Mapping
30
+ from datetime import timezone
31
+ from email.utils import parsedate_to_datetime
32
+ from types import TracebackType
33
+ from typing import TYPE_CHECKING, Any, Self
34
+
35
+ import structlog
36
+ from github import RateLimitExceededException
37
+ from github.Requester import Requester
38
+ from urllib3.exceptions import MaxRetryError
39
+ from urllib3.util.retry import Retry
40
+
41
+ if TYPE_CHECKING:
42
+ # annotations only: BaseHTTPResponse is new in urllib3 2, and PyGithub and requests still
43
+ # allow urllib3 1.26
44
+ from urllib3.connectionpool import ConnectionPool
45
+ from urllib3.response import BaseHTTPResponse
46
+
47
+ log = structlog.get_logger(__name__)
48
+
49
+ FORBIDDEN = 403
50
+ TOO_MANY_REQUESTS = 429
51
+ RATE_LIMIT_STATUSES = frozenset({FORBIDDEN, TOO_MANY_REQUESTS})
52
+ # GitHub: without a retry-after header, "wait for at least one minute before retrying"
53
+ SECONDARY_LIMIT_WAIT_SECONDS = 60.0
54
+ DEFAULT_MAX_WAIT_SECONDS = 60.0
55
+ # transient server errors, retried on idempotent requests only
56
+ SERVER_ERRORS = (500, 502, 503, 504)
57
+ MAX_RETRIES = 3
58
+
59
+
60
+ def lowercase(headers: Mapping[str, str]) -> dict[str, str]:
61
+ return {key.lower(): value for key, value in headers.items()}
62
+
63
+
64
+ def has_limit_headers(headers: Mapping[str, str]) -> bool:
65
+ return "retry-after" in headers or headers.get("x-ratelimit-remaining") == "0"
66
+
67
+
68
+ def number(value: str | None) -> float | None:
69
+ try:
70
+ parsed = float(value) if value is not None else None
71
+ except ValueError:
72
+ return None
73
+ return parsed if parsed is not None and math.isfinite(parsed) else None
74
+
75
+
76
+ def retry_after(value: str | None, now: float) -> float | None:
77
+ """A Retry-After header in seconds from `now`: it gives either seconds or an HTTP date."""
78
+ seconds = number(value)
79
+ if seconds is None and value:
80
+ try:
81
+ date = parsedate_to_datetime(value)
82
+ if date.tzinfo is None: # "-0000": UTC, says RFC 5322
83
+ date = date.replace(tzinfo=timezone.utc)
84
+ seconds = date.timestamp() - now
85
+ except (ValueError, OverflowError):
86
+ return None
87
+ return max(seconds, 0.0) if seconds is not None else None
88
+
89
+
90
+ def rate_limit_wait(headers: Mapping[str, str], now: float) -> float:
91
+ """Seconds GitHub asks for before a rate-limited request may be sent again."""
92
+ headers = lowercase(headers)
93
+ after = retry_after(headers.get("retry-after"), now)
94
+ if after is not None:
95
+ return after
96
+ reset = number(headers.get("x-ratelimit-reset"))
97
+ if headers.get("x-ratelimit-remaining") == "0" and reset is not None:
98
+ # +1: the reset time is in whole seconds, and when in that second it comes is unknown
99
+ return max(reset - now, 0.0) + 1
100
+ return SECONDARY_LIMIT_WAIT_SECONDS
101
+
102
+
103
+ def read_json(response: BaseHTTPResponse) -> Any:
104
+ # parsed the way PyGithub parses an error body, so the exception raised from it matches
105
+ text = response.read(decode_content=True).decode("utf-8", errors="replace")
106
+ if not text:
107
+ return None
108
+ try:
109
+ return json.loads(text)
110
+ except ValueError:
111
+ return {"data": text}
112
+
113
+
114
+ class RateLimitExceeded(RateLimitExceededException):
115
+ """A rate limit that isn't waited out, and when GitHub lets the request through again."""
116
+
117
+ def __init__(self, status: int, data: Any, headers: dict[str, str], retry_at: float):
118
+ super().__init__(status, data, headers)
119
+ self.retry_at = retry_at # epoch seconds
120
+
121
+
122
+ class RequestsCancelled(Exception):
123
+ """Raised instead of waiting, retrying or sending a request once its fetch is cancelled."""
124
+
125
+
126
+ class _Deadline:
127
+ """When the run's requests may go on, on the monotonic clock: shared by a pause and its
128
+ scopes."""
129
+
130
+ def __init__(self) -> None:
131
+ self.lock = threading.Lock()
132
+ self.at = 0.0
133
+
134
+
135
+ class RateLimitPause:
136
+ """The rate-limit waits of one run, shared by all its GitHub clients and threads: while one
137
+ request waits for a limit to reset, the others hold back instead of adding to it.
138
+
139
+ Waits are on an Event rather than time.sleep, so cancel() ends them at once: Ctrl-C reaches
140
+ only the main thread, and nothing else could stop a worker thread's wait. Deadlines are on
141
+ the monotonic clock, which a change to the system time doesn't move; `clock` is the wall
142
+ clock that GitHub's reset times are read against."""
143
+
144
+ def __init__(
145
+ self,
146
+ clock: Callable[[], float] = time.time,
147
+ monotonic: Callable[[], float] = time.monotonic,
148
+ new_event: Callable[[], threading.Event] = threading.Event,
149
+ ):
150
+ self.clock = clock
151
+ self.monotonic = monotonic
152
+ self.new_event = new_event
153
+ self.cancelled = new_event()
154
+ self._resume = _Deadline()
155
+
156
+ def scope(self) -> RateLimitPause:
157
+ """A pause with this one's waits but a cancel() of its own: one per parallel fetch, so
158
+ that ending the fetch stops its requests, not the client's later ones."""
159
+ scoped = RateLimitPause(self.clock, self.monotonic, self.new_event)
160
+ scoped._resume = self._resume
161
+ return scoped
162
+
163
+ def cancel(self) -> None:
164
+ """End every wait at once; from then on each wait, retry and request raises."""
165
+ self.cancelled.set()
166
+
167
+ def raise_if_cancelled(self) -> None:
168
+ if self.cancelled.is_set():
169
+ raise RequestsCancelled("the GitHub requests were cancelled")
170
+
171
+ def sleep(self, seconds: float) -> None:
172
+ """Wait `seconds` without holding back other requests, unless cancel() comes first."""
173
+ if seconds > 0 and not self.cancelled.is_set():
174
+ self.cancelled.wait(seconds)
175
+ self.raise_if_cancelled()
176
+
177
+ def wait(self, seconds: float, **context: Any) -> None:
178
+ with self._resume.lock:
179
+ now = self.monotonic()
180
+ self._resume.at = max(self._resume.at, now + seconds)
181
+ remaining = self._resume.at - now
182
+ # a warning, so the default log level shows it: the run can stand still for minutes
183
+ log.warning("github_rate_limit_wait", seconds=round(remaining, 1), **context)
184
+ self.sleep(remaining)
185
+
186
+ def wait_if_paused(self) -> None:
187
+ with self._resume.lock:
188
+ remaining = self._resume.at - self.monotonic()
189
+ self.sleep(remaining)
190
+
191
+
192
+ class RateLimitRetry(Retry):
193
+ """urllib3's retry policy for GitHub requests: transient server errors on idempotent requests,
194
+ as before, plus rate limits on any request, waited out for up to `max_wait` seconds."""
195
+
196
+ def __init__(
197
+ self,
198
+ max_wait: float = DEFAULT_MAX_WAIT_SECONDS,
199
+ pause: RateLimitPause | None = None,
200
+ **kwargs: Any,
201
+ ):
202
+ super().__init__(**kwargs)
203
+ self.max_wait = max_wait
204
+ self.pause = pause if pause is not None else RateLimitPause()
205
+ self.rate_limit_wait: float | None = None # set on the copy that increment() returns
206
+
207
+ def new(self, **kw: Any) -> Self:
208
+ kw.setdefault("max_wait", self.max_wait)
209
+ kw.setdefault("pause", self.pause)
210
+ return super().new(**kw)
211
+
212
+ def is_retry(self, method: str, status_code: int, has_retry_after: bool = False) -> bool:
213
+ if status_code in RATE_LIMIT_STATUSES:
214
+ return True # increment() tells a rate limit from a refusal
215
+ return super().is_retry(method, status_code, has_retry_after)
216
+
217
+ def increment(
218
+ self,
219
+ method: str | None = None,
220
+ url: str | None = None,
221
+ response: BaseHTTPResponse | None = None,
222
+ error: Exception | None = None,
223
+ _pool: ConnectionPool | None = None,
224
+ _stacktrace: TracebackType | None = None,
225
+ ) -> Self:
226
+ self.pause.raise_if_cancelled() # a cancelled fetch sends nothing again, whatever failed
227
+ if response is None or response.status not in RATE_LIMIT_STATUSES:
228
+ return super().increment(method, url, response, error, _pool, _stacktrace)
229
+
230
+ headers = lowercase(response.headers)
231
+ data = None
232
+ if response.status == FORBIDDEN and not has_limit_headers(headers):
233
+ # without the headers, only the message tells a secondary limit from "forbidden".
234
+ # Reading it uses up the body, so a refusal is raised here, as PyGithub would raise it
235
+ data = read_json(response)
236
+ message = data.get("message") if isinstance(data, dict) else None
237
+ if not Requester.isRateLimitError(message or ""):
238
+ raise Requester.createException(response.status, headers, data)
239
+
240
+ now = self.pause.clock()
241
+ wait = rate_limit_wait(headers, now)
242
+ if wait > self.max_wait:
243
+ raise RateLimitExceeded(response.status, data, headers, retry_at=now + wait)
244
+ try:
245
+ retry = super().increment(method, url, response, error, _pool, _stacktrace)
246
+ except MaxRetryError:
247
+ raise RateLimitExceeded(response.status, data, headers, retry_at=now + wait) from None
248
+ retry.rate_limit_wait = wait
249
+ return retry
250
+
251
+ def sleep(self, response: BaseHTTPResponse | None = None) -> None:
252
+ if self.rate_limit_wait is None:
253
+ # the back-off before retrying a server error or a lost connection (github_retry has
254
+ # urllib3 ignore their Retry-After), which cancel() cuts short too
255
+ self.pause.sleep(self.get_backoff_time())
256
+ return
257
+ last = self.history[-1] if self.history else None
258
+ self.pause.wait(
259
+ self.rate_limit_wait,
260
+ status=last.status if last else None,
261
+ url=last.url if last else None,
262
+ attempt=len(self.history),
263
+ )
264
+
265
+
266
+ def github_retry(max_wait: float, pause: RateLimitPause | None = None) -> RateLimitRetry:
267
+ return RateLimitRetry(
268
+ max_wait=max_wait,
269
+ pause=pause,
270
+ total=MAX_RETRIES,
271
+ backoff_factor=1,
272
+ status_forcelist=SERVER_ERRORS,
273
+ raise_on_status=False,
274
+ # urllib3 would otherwise sleep out a Retry-After on a 429 or 503 itself: for up to six
275
+ # hours, without a word, and past GITHUB_RATE_LIMIT_MAX_WAIT_SECONDS
276
+ respect_retry_after_header=False,
277
+ )
File without changes
@@ -0,0 +1,149 @@
1
+ """Splitting a PR's diff into LLM requests of at most MAX_DIFF_LINES diff lines each.
2
+
3
+ A PR whose diff fits is one request, with exactly the prompt it always had. A bigger one becomes
4
+ several requests, each holding whole files in PR order. A file too big for one request is cut at
5
+ hunk boundaries, and a hunk too big for one request (a large new file is a single hunk) at line
6
+ boundaries, under a hunk header that keeps the line numbers right. A new 5,000-line file with
7
+ MAX_DIFF_LINES=3000 goes out in two parts:
8
+
9
+ @@ -0,0 +1,5000 @@ lines 1-2998 (the file's name and this header use up two lines)
10
+ @@ -0 +2999 @@ lines 2999-5000
11
+
12
+ Each request's prompt is built by build_prompt from a copy of the PR that holds only that
13
+ request's files, so every request is laid out, and guarded, like a single one.
14
+ """
15
+ import math
16
+ import re
17
+ from collections import Counter
18
+ from dataclasses import dataclass, replace
19
+
20
+ from pr_analyzer.llm.prompts import build_prompt
21
+ from pr_analyzer.models import FileChange, PRSummary, Suggestion
22
+
23
+ # a hunk header with both start lines: @@ -<old>[,<count>] +<new>[,<count>] @@
24
+ HUNK_RANGES = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
25
+ # rough size of a prompt in tokens: about four characters each
26
+ CHARS_PER_TOKEN = 4
27
+
28
+
29
+ @dataclass
30
+ class ReviewRequest:
31
+ prompt: str
32
+ filenames: list[str] # the files whose diff, or part of it, the prompt holds
33
+
34
+
35
+ def estimate_tokens(text: str) -> int:
36
+ return math.ceil(len(text) / CHARS_PER_TOKEN)
37
+
38
+
39
+ def diff_lines(file: FileChange) -> int:
40
+ # what build_prompt sends for a file: its "--- name ---" line and every line of its patch
41
+ return 1 + len((file.patch or "").splitlines())
42
+
43
+
44
+ def hunks(lines: list[str]) -> list[list[str]]:
45
+ """The patch's lines, one list per hunk (anything before the first header is a list too)."""
46
+ groups: list[list[str]] = []
47
+ for line in lines:
48
+ if not groups or HUNK_RANGES.match(line):
49
+ groups.append([line])
50
+ else:
51
+ groups[-1].append(line)
52
+ return groups
53
+
54
+
55
+ def advance(old: int, new: int, lines: list[str]) -> tuple[int, int]:
56
+ """Line numbers in the old and new file after `lines`, counted the way annotate_patch does."""
57
+ for line in lines:
58
+ if line.startswith("-"):
59
+ old += 1
60
+ elif line.startswith("+"):
61
+ new += 1
62
+ elif not line.startswith("\\"): # "" is no line at all
63
+ old += 1
64
+ new += 1
65
+ return old, new
66
+
67
+
68
+ def cut_hunk(hunk: list[str], max_lines: int) -> list[list[str]]:
69
+ """Cut one hunk into parts of at most `max_lines` lines. Every part after the first starts
70
+ with a header naming the lines it starts at, so its lines keep their numbers."""
71
+ ranges = HUNK_RANGES.match(hunk[0])
72
+ if ranges is None: # text before the first hunk: it has no line numbers to keep
73
+ return [hunk[i:i + max_lines] for i in range(0, len(hunk), max(max_lines, 1))]
74
+
75
+ room = max(max_lines - 1, 1) # lines that fit beside a part's header
76
+ old, new = int(ranges.group(1)), int(ranges.group(2))
77
+ parts = []
78
+ for start in range(1, len(hunk), room):
79
+ body = hunk[start:start + room]
80
+ header = hunk[0] if start == 1 else f"@@ -{old} +{new} @@"
81
+ parts.append([header, *body])
82
+ old, new = advance(old, new, body)
83
+ return parts or [hunk]
84
+
85
+
86
+ def split_patch(patch: str, max_lines: int) -> list[str]:
87
+ """Cut a patch into pieces of at most `max_lines` lines, at hunk boundaries where it can."""
88
+ pieces: list[list[str]] = []
89
+ for hunk in hunks(patch.splitlines()):
90
+ for part in cut_hunk(hunk, max_lines) if len(hunk) > max_lines else [hunk]:
91
+ if pieces and len(pieces[-1]) + len(part) <= max_lines:
92
+ pieces[-1].extend(part)
93
+ else:
94
+ pieces.append(list(part))
95
+ return ["\n".join(piece) for piece in pieces]
96
+
97
+
98
+ def plan_chunks(files: list[FileChange], max_lines: int) -> list[list[FileChange]]:
99
+ """Group the files that have a patch into chunks of at most `max_lines` diff lines, in order.
100
+ A file too big for one chunk is split into copies that each hold one piece of its patch."""
101
+ chunks: list[list[FileChange]] = []
102
+ size = 0
103
+ for file in files:
104
+ if not file.patch:
105
+ continue
106
+ pieces = [file]
107
+ if diff_lines(file) > max_lines:
108
+ pieces = [replace(file, patch=p) for p in split_patch(file.patch, max_lines - 1)]
109
+ for piece in pieces:
110
+ lines = diff_lines(piece)
111
+ if chunks and size + lines <= max_lines:
112
+ chunks[-1].append(piece)
113
+ size += lines
114
+ else:
115
+ chunks.append([piece])
116
+ size = lines
117
+ return chunks
118
+
119
+
120
+ def review_requests(
121
+ pr: PRSummary, existing_findings: list[Suggestion], max_lines: int
122
+ ) -> list[ReviewRequest]:
123
+ chunks = plan_chunks(pr.files, max_lines)
124
+ if len(chunks) <= 1:
125
+ # a diff that fits is sent as it always was
126
+ names = [f.filename for f in pr.files if f.patch]
127
+ return [ReviewRequest(build_prompt(pr, existing_findings, max_lines), names)]
128
+
129
+ split = {f.filename for f in pr.files if f.patch and diff_lines(f) > max_lines}
130
+ requests = []
131
+ for number, chunk in enumerate(chunks, start=1):
132
+ names = list(dict.fromkeys(f.filename for f in chunk))
133
+ # the findings already reported for this chunk's files, so the model doesn't repeat them
134
+ findings = [s for s in existing_findings if s.filename in names]
135
+ prompt = build_prompt(replace(pr, files=chunk), findings, max_lines)
136
+ partly = " (some of them only in part)" if split.intersection(names) else ""
137
+ note = (
138
+ f"This is part {number} of {len(chunks)} of the review of this pull request: the diff "
139
+ f"above covers {len(names)} of its {len(pr.files)} changed files{partly}. The other "
140
+ f"parts are reviewed in separate requests."
141
+ )
142
+ requests.append(ReviewRequest(f"{prompt}\n\n{note}", names))
143
+ return requests
144
+
145
+
146
+ def split_files(requests: list[ReviewRequest]) -> set[str]:
147
+ """The files reviewed in parts: those whose diff went out in more than one request."""
148
+ counts = Counter(name for request in requests for name in request.filenames)
149
+ return {name for name, count in counts.items() if count > 1}