Skip to content

Commit b1e530c

Browse files
committed
Update (base update)
[ghstack-poisoned]
1 parent 94da6a8 commit b1e530c

4 files changed

Lines changed: 59 additions & 13 deletions

File tree

greenlight/src/greenlight/review.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ def run(
246246
else:
247247
fingerprint_numbers = pr_numbers
248248
failed: list[int] = []
249+
abandoned: list[int] = []
249250
skips: list[tuple[int, ReviewSkip]] = []
250251
worker_count = min(_FINGERPRINT_WORKERS, len(fingerprint_numbers))
251252
# PyGithub is not thread-safe, so each concurrent task borrows a client for its
@@ -268,6 +269,7 @@ def run(
268269
now=evaluated_at,
269270
timeout=timeout,
270271
failed=failed,
272+
abandoned=abandoned,
271273
skips=skips,
272274
force=force,
273275
)
@@ -284,9 +286,17 @@ def run(
284286
now=evaluated_at,
285287
timeout=timeout,
286288
failed=failed,
289+
abandoned=abandoned,
287290
skips=skips,
288291
force=force,
289292
)
293+
if abandoned:
294+
logger.warning(
295+
"rate limit hit: abandoned %d of %d fingerprint(s) (not evaluated); %d candidate(s) dispatchable",
296+
len(abandoned),
297+
len(fingerprint_numbers),
298+
len(pending),
299+
)
290300
dispatch_failed = scan_runner._dispatch_pending(
291301
client, pending, ref=ref, max_dispatches=max_dispatches, dispatch=dispatch, emit_dispatched=emit_dispatched
292302
)
@@ -297,10 +307,12 @@ def run(
297307
scan_runner.post_refusals(
298308
client, TARGET_REPO, skips, bot_login=bot_login, get_pr=get_pr, upsert_comment=upsert_comment
299309
)
300-
if failed or dispatch_failed:
310+
if failed or dispatch_failed or abandoned:
301311
errors: list[str] = []
302312
if failed:
303313
errors.append(f"{len(failed)} PR(s) failed during scan: {sorted(failed)}")
304314
if dispatch_failed:
305315
errors.append(f"failed to dispatch {len(dispatch_failed)} PR(s): {sorted(dispatch_failed)}")
316+
if abandoned:
317+
errors.append(f"{len(abandoned)} PR(s) abandoned due to rate limit: {sorted(abandoned)}")
306318
raise RuntimeError("; ".join(errors))

greenlight/src/greenlight/scan_runner.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,17 @@ def _evaluate_pr(
102102
now: datetime,
103103
timeout: timedelta,
104104
failed: list[int],
105+
abandoned: list[int],
105106
skips: list[tuple[int, ReviewSkip]],
106107
force: bool,
107108
) -> _Candidate | None:
108109
try:
109110
result = future.result()
110111
# A cancelled task never ran its fingerprint (an earlier task hit a rate limit and tripped
111-
# the shared cancel event), so it is neither a candidate nor a failure: drop it silently.
112+
# the shared cancel event), so it is not a failure: record it as abandoned (never evaluated),
113+
# kept distinct from `failed` so the end-of-scan signal can report both without conflating them.
112114
if result is _CANCELLED:
115+
abandoned.append(number)
113116
return None
114117
# A ReviewSkip is a human decision, never a fingerprint failure: check before unpacking
115118
# so it is collected/dropped, not mistaken for an error and appended to failed.
@@ -154,6 +157,7 @@ def _fingerprint_all(
154157
now: datetime,
155158
timeout: timedelta,
156159
failed: list[int],
160+
abandoned: list[int],
157161
skips: list[tuple[int, ReviewSkip]],
158162
force: bool,
159163
) -> list[_Candidate]:
@@ -176,7 +180,15 @@ def _fingerprint_all(
176180
pending: list[_Candidate] = []
177181
for number in pr_numbers:
178182
candidate = _evaluate_pr(
179-
number, futures[number], states, now=now, timeout=timeout, failed=failed, skips=skips, force=force
183+
number,
184+
futures[number],
185+
states,
186+
now=now,
187+
timeout=timeout,
188+
failed=failed,
189+
abandoned=abandoned,
190+
skips=skips,
191+
force=force,
180192
)
181193
if candidate is not None:
182194
pending.append(candidate)
@@ -196,6 +208,7 @@ def _fingerprint_until_dispatchable(
196208
now: datetime,
197209
timeout: timedelta,
198210
failed: list[int],
211+
abandoned: list[int],
199212
skips: list[tuple[int, ReviewSkip]],
200213
force: bool,
201214
) -> list[_Candidate]:
@@ -230,6 +243,7 @@ def _fingerprint_until_dispatchable(
230243
now=now,
231244
timeout=timeout,
232245
failed=failed,
246+
abandoned=abandoned,
233247
skips=skips,
234248
force=force,
235249
)

greenlight/tests/test_review.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,7 +1281,7 @@ def boom(_client, _number, _authorized, _skip):
12811281
assert not cancel_event.is_set()
12821282

12831283

1284-
def test_rate_limit_abandons_remaining_fingerprints(make_config, monkeypatch):
1284+
def test_rate_limit_abandons_remaining_fingerprints(make_config, monkeypatch, caplog):
12851285
from github import RateLimitExceededException
12861286

12871287
# Pin one worker for a deterministic FIFO order: PR1 runs first and trips the cancel event before
@@ -1299,7 +1299,7 @@ def fingerprint(_client, number, _authorized, _skip):
12991299
def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13001300
dispatched.append(number)
13011301

1302-
with pytest.raises(RuntimeError, match=r"1 PR\(s\) failed during scan: \[1\]"):
1302+
with caplog.at_level(logging.WARNING, logger="greenlight"), pytest.raises(RuntimeError) as excinfo:
13031303
review.run(
13041304
make_config(github_token="t"),
13051305
build_github=lambda _token: _CLIENT,
@@ -1312,13 +1312,20 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13121312
)
13131313

13141314
# PR1's rate limit sets the shared cancel event, so the queued PR2/PR3 short-circuit to _CANCELLED
1315-
# without ever calling fingerprint (only PR1 appears). A cancelled task is not a failure, so only
1316-
# PR1 lands in `failed`, and nothing dispatches.
1315+
# without ever calling fingerprint (only PR1 appears). A cancelled task is not a failure: PR1 lands
1316+
# in `failed`, PR2/PR3 are counted as abandoned (not failed), and nothing dispatches.
13171317
assert fingerprinted == [1]
13181318
assert dispatched == []
1319+
# The abandonment is surfaced, never silently dropped: a warning names the count, and the
1320+
# end-of-scan RuntimeError carries both the failed PR and the abandoned ones distinctly so the
1321+
# scan still fails closed on an incomplete pass.
1322+
message = str(excinfo.value)
1323+
assert "1 PR(s) failed during scan: [1]" in message
1324+
assert "2 PR(s) abandoned due to rate limit: [2, 3]" in message
1325+
assert "abandoned 2 of 3" in caplog.text
13191326

13201327

1321-
def test_rate_limit_still_dispatches_earlier_completed_candidate(make_config, monkeypatch):
1328+
def test_rate_limit_still_dispatches_earlier_completed_candidate(make_config, monkeypatch, caplog):
13221329
from github import RateLimitExceededException
13231330

13241331
monkeypatch.setattr(review, "_FINGERPRINT_WORKERS", 1)
@@ -1334,7 +1341,7 @@ def fingerprint(_client, number, _authorized, _skip):
13341341
def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13351342
dispatched.append(number)
13361343

1337-
with pytest.raises(RuntimeError, match=r"1 PR\(s\) failed during scan: \[2\]"):
1344+
with caplog.at_level(logging.WARNING, logger="greenlight"), pytest.raises(RuntimeError) as excinfo:
13381345
review.run(
13391346
make_config(github_token="t"),
13401347
build_github=lambda _token: _CLIENT,
@@ -1348,9 +1355,15 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13481355

13491356
# PR1 completes as a candidate before PR2 hits the rate limit, so the partial success is preserved
13501357
# -- PR1 still dispatches. PR2 trips the cancel event, so PR3 short-circuits without being
1351-
# fingerprinted, and only PR2 lands in `failed`.
1358+
# fingerprinted: PR2 lands in `failed`, PR3 is counted as abandoned (not failed).
13521359
assert fingerprinted == [1, 2]
13531360
assert dispatched == [1]
1361+
# The failure and the abandonment are surfaced distinctly in the fail-closed signal, and the
1362+
# abandonment is logged as its own warning rather than folded into the failure count.
1363+
message = str(excinfo.value)
1364+
assert "1 PR(s) failed during scan: [2]" in message
1365+
assert "1 PR(s) abandoned due to rate limit: [3]" in message
1366+
assert "abandoned 1 of 3" in caplog.text
13541367

13551368

13561369
def test_rate_limit_abandonment_breaks_max_dispatch_batches(make_config, monkeypatch):

greenlight/tests/test_scan_runner.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ def upsert(pr, **_kwargs):
167167

168168
def test_evaluate_pr_iteration_timeout_propagates():
169169
failed: list[int] = []
170+
abandoned: list[int] = []
170171
skips: list[tuple[int, ReviewSkip]] = []
171172
future = cast(
172173
"Future[tuple[str, str] | ReviewSkip | scan_runner._Cancelled]", _RaisingFuture(IterationTimeout("boom"))
@@ -182,11 +183,13 @@ def test_evaluate_pr_iteration_timeout_propagates():
182183
now=datetime(2026, 1, 1, tzinfo=UTC),
183184
timeout=timedelta(hours=1),
184185
failed=failed,
186+
abandoned=abandoned,
185187
skips=skips,
186188
force=False,
187189
)
188190

189191
assert failed == []
192+
assert abandoned == []
190193
assert skips == []
191194

192195

@@ -264,8 +267,9 @@ def fingerprint(*_args):
264267
assert pool.get_nowait() is client
265268

266269

267-
def test_evaluate_pr_cancelled_result_returns_none_without_failing():
270+
def test_evaluate_pr_cancelled_result_records_abandoned_not_failed():
268271
failed: list[int] = []
272+
abandoned: list[int] = []
269273
skips: list[tuple[int, ReviewSkip]] = []
270274
future = cast(
271275
"Future[tuple[str, str] | ReviewSkip | scan_runner._Cancelled]", _ResultFuture(scan_runner._CANCELLED)
@@ -278,13 +282,16 @@ def test_evaluate_pr_cancelled_result_returns_none_without_failing():
278282
now=datetime(2026, 1, 1, tzinfo=UTC),
279283
timeout=timedelta(hours=1),
280284
failed=failed,
285+
abandoned=abandoned,
281286
skips=skips,
282287
force=False,
283288
)
284289

285-
# A cancelled task was never attempted, so it is neither a candidate nor a failure: it is dropped
286-
# without landing in `failed` (only tasks that actually hit the limit do).
290+
# A cancelled task was never attempted, so it is not a candidate and not a failure: it is recorded
291+
# as abandoned (distinct from `failed`, which holds only tasks that actually hit the limit) so the
292+
# scan can surface it instead of dropping it silently.
287293
assert result is None
294+
assert abandoned == [5]
288295
assert failed == []
289296
assert skips == []
290297

0 commit comments

Comments
 (0)