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,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}
|