Skip to content

Commit 7eb1981

Browse files
committed
Add --force flag to re-dispatch reviewed PRs
- Add review --force CLI flag; requires --pr, rejects --loop - Thread force through run() into _evaluate_pr, bypassing decide - Forced dispatch tags reason "forced", keeps fingerprint sha/hash - Cover both fingerprint paths (--max capped and uncapped) - Add CLI usage-error and force-binding tests Force short-circuits the decide() land-guard so an already-decided PR (terminal LAND, unchanged hash) is re-dispatched on demand. It short-circuits only decide, not the fingerprint try/except: a fingerprint failure is still caught, recorded as failed, and raised as the aggregate scan error, so force never dispatches a failed PR or bypasses failure handling. Signed-off-by: Jean Schmidt <contato@jschmidt.me>
1 parent dfdd0c1 commit 7eb1981

4 files changed

Lines changed: 145 additions & 4 deletions

File tree

greenlight/src/greenlight/cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ def build_parser() -> argparse.ArgumentParser:
6161
default=DEFAULT_TIMEOUT_MINUTES,
6262
help="minutes before an in-flight or failed review is re-dispatched",
6363
)
64+
review_parser.add_argument(
65+
"--force",
66+
action="store_true",
67+
help="re-dispatch even if already reviewed; requires --pr, not allowed with --loop",
68+
)
6469

6570
verdict_parser = subparsers.add_parser(
6671
"verdict",
@@ -167,11 +172,16 @@ def main(argv: Sequence[str] | None = None) -> int:
167172
lock_path = config.lock_path
168173
if lock_path is not None:
169174
logger.info("using single-instance lock path %s", lock_path)
175+
if args.force and args.pr is None:
176+
parser.error("--force requires --pr")
177+
if args.force and args.loop:
178+
parser.error("--force cannot be combined with --loop")
170179
run = functools.partial(
171180
review.run,
172181
pr=args.pr,
173182
max_dispatches=args.max,
174183
ref=args.ref,
175184
timeout_minutes=args.timeout_minutes,
185+
force=args.force,
176186
)
177187
return _dispatch(config, run, loop=args.loop, lock_path=lock_path)

greenlight/src/greenlight/review.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,14 @@ def _evaluate_pr(
144144
now: datetime,
145145
timeout: timedelta,
146146
failed: list[int],
147+
force: bool,
147148
) -> _Candidate | None:
148149
try:
149150
head_sha, eval_hash = future.result()
150151
recorded = states.get(number)
152+
if force:
153+
logger.info("PR #%d: DISPATCH (forced)", number)
154+
return _Candidate(number, head_sha, eval_hash, recorded, "forced")
151155
outcome = decide(
152156
current_eval_hash=eval_hash,
153157
latest_status=recorded.status if recorded is not None else None,
@@ -176,6 +180,7 @@ def _fingerprint_all(
176180
now: datetime,
177181
timeout: timedelta,
178182
failed: list[int],
183+
force: bool,
179184
) -> list[_Candidate]:
180185
futures: dict[int, Future[tuple[str, str]]] = {}
181186
if worker_count:
@@ -185,7 +190,7 @@ def _fingerprint_all(
185190
}
186191
pending: list[_Candidate] = []
187192
for number in pr_numbers:
188-
candidate = _evaluate_pr(number, futures[number], states, now=now, timeout=timeout, failed=failed)
193+
candidate = _evaluate_pr(number, futures[number], states, now=now, timeout=timeout, failed=failed, force=force)
189194
if candidate is not None:
190195
pending.append(candidate)
191196
return pending
@@ -202,6 +207,7 @@ def _fingerprint_until_dispatchable(
202207
now: datetime,
203208
timeout: timedelta,
204209
failed: list[int],
210+
force: bool,
205211
) -> list[_Candidate]:
206212
ranked = sorted(pr_numbers, key=lambda number: _staleness_key_for_state(states.get(number)))
207213
pending: list[_Candidate] = []
@@ -213,7 +219,9 @@ def _fingerprint_until_dispatchable(
213219
batch = ranked[start : start + worker_count]
214220
futures = {number: pool.submit(_fingerprint_task, fingerprint, client_pool, number) for number in batch}
215221
for number in batch:
216-
candidate = _evaluate_pr(number, futures[number], states, now=now, timeout=timeout, failed=failed)
222+
candidate = _evaluate_pr(
223+
number, futures[number], states, now=now, timeout=timeout, failed=failed, force=force
224+
)
217225
if candidate is not None:
218226
pending.append(candidate)
219227
return pending
@@ -226,6 +234,7 @@ def run(
226234
max_dispatches: int | None = None,
227235
ref: str = DEFAULT_DISPATCH_REF,
228236
timeout_minutes: int = DEFAULT_TIMEOUT_MINUTES,
237+
force: bool = False,
229238
build_github: Callable[[str], Github] = github_client.build_client,
230239
fetch: Callable[[Github], list[OpenPR]] = _default_fetch,
231240
fingerprint: Callable[[Github, int], tuple[str, str]] = _default_fingerprint,
@@ -265,6 +274,7 @@ def run(
265274
now=evaluated_at,
266275
timeout=timeout,
267276
failed=failed,
277+
force=force,
268278
)
269279
else:
270280
pending = _fingerprint_until_dispatchable(
@@ -277,6 +287,7 @@ def run(
277287
now=evaluated_at,
278288
timeout=timeout,
279289
failed=failed,
290+
force=force,
280291
)
281292
_dispatch_pending(client, pending, ref=ref, max_dispatches=max_dispatches, dispatch=dispatch)
282293
if failed:

greenlight/tests/test_cli.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,13 @@ def fake_run_forever(config, *, run):
8282
bound = captured["run"]
8383
assert isinstance(bound, functools.partial)
8484
assert bound.func is review.run
85-
assert bound.keywords == {"pr": None, "max_dispatches": None, "ref": "main", "timeout_minutes": 30}
85+
assert bound.keywords == {
86+
"pr": None,
87+
"max_dispatches": None,
88+
"ref": "main",
89+
"timeout_minutes": 30,
90+
"force": False,
91+
}
8692

8793

8894
def test_main_oneshot_failure_returns_exit_failure(monkeypatch):
@@ -454,11 +460,14 @@ def fake_run(request, config):
454460

455461
def test_review_parser_parses_scan_flags():
456462
parser = cli.build_parser()
457-
args = parser.parse_args(["review", "--pr", "5", "--max", "3", "--ref", "release/2.9", "--timeout-minutes", "45"])
463+
args = parser.parse_args(
464+
["review", "--pr", "5", "--max", "3", "--ref", "release/2.9", "--timeout-minutes", "45", "--force"]
465+
)
458466
assert args.pr == 5
459467
assert args.max == 3
460468
assert args.ref == "release/2.9"
461469
assert args.timeout_minutes == 45
470+
assert args.force is True
462471

463472

464473
def test_review_parser_scan_flag_defaults():
@@ -468,6 +477,7 @@ def test_review_parser_scan_flag_defaults():
468477
assert args.max is None
469478
assert args.ref == DEFAULT_DISPATCH_REF
470479
assert args.timeout_minutes == DEFAULT_TIMEOUT_MINUTES
480+
assert args.force is False
471481

472482

473483
def test_main_review_binds_scan_flags_into_run(monkeypatch):
@@ -486,6 +496,7 @@ def test_main_review_binds_scan_flags_into_run(monkeypatch):
486496
"max_dispatches": 2,
487497
"ref": "release/2.9",
488498
"timeout_minutes": 45,
499+
"force": False,
489500
}
490501

491502

@@ -503,4 +514,43 @@ def test_main_review_defaults_bind_into_run(monkeypatch):
503514
"max_dispatches": None,
504515
"ref": DEFAULT_DISPATCH_REF,
505516
"timeout_minutes": DEFAULT_TIMEOUT_MINUTES,
517+
"force": False,
518+
}
519+
520+
521+
def test_main_force_without_pr_is_usage_error(monkeypatch):
522+
monkeypatch.setattr(cli, "configure_logging", Mock())
523+
run_mock = Mock()
524+
monkeypatch.setattr(review, "run", run_mock)
525+
with pytest.raises(SystemExit) as excinfo:
526+
cli.main(["review", "--force"])
527+
assert excinfo.value.code == 2
528+
run_mock.assert_not_called()
529+
530+
531+
def test_main_force_with_loop_is_usage_error(monkeypatch):
532+
monkeypatch.setattr(cli, "configure_logging", Mock())
533+
run_forever_mock = Mock()
534+
monkeypatch.setattr(cli, "run_forever", run_forever_mock)
535+
with pytest.raises(SystemExit) as excinfo:
536+
cli.main(["review", "--pr", "5", "--force", "--loop"])
537+
assert excinfo.value.code == 2
538+
run_forever_mock.assert_not_called()
539+
540+
541+
def test_main_review_force_binds_into_run(monkeypatch):
542+
review_mock = Mock()
543+
monkeypatch.setattr(review, "run", review_mock)
544+
monkeypatch.setattr(cli, "single_instance_lock", _noop_lock)
545+
monkeypatch.setattr(cli, "configure_logging", Mock())
546+
547+
rc = cli.main(["review", "--pr", "5", "--force"])
548+
549+
assert rc == EXIT_OK
550+
assert review_mock.call_args.kwargs == {
551+
"pr": 5,
552+
"max_dispatches": None,
553+
"ref": DEFAULT_DISPATCH_REF,
554+
"timeout_minutes": DEFAULT_TIMEOUT_MINUTES,
555+
"force": True,
506556
}

greenlight/tests/test_review.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def _run_scan(
7272
max_dispatches: int | None = None,
7373
ref: str = DEFAULT_DISPATCH_REF,
7474
timeout_minutes: int = DEFAULT_TIMEOUT_MINUTES,
75+
force: bool = False,
7576
now: datetime = _NOW,
7677
) -> _Scan:
7778
states = states or {}
@@ -102,6 +103,7 @@ def fake_dispatch(_client, number, head_sha, eval_hash, dispatch_ref):
102103
max_dispatches=max_dispatches,
103104
ref=ref,
104105
timeout_minutes=timeout_minutes,
106+
force=force,
105107
build_github=lambda _token: _CLIENT,
106108
fetch=fake_fetch,
107109
fingerprint=fake_fingerprint,
@@ -330,6 +332,74 @@ def test_pr_decided_still_skips(make_config):
330332
assert scan.listed_calls == 0
331333

332334

335+
def test_force_dispatches_decided_pr_via_fingerprint_all(make_config, caplog):
336+
with caplog.at_level(logging.INFO, logger="greenlight"):
337+
scan = _run_scan(
338+
make_config,
339+
pr=5,
340+
fingerprints={5: ("headsha5", _HASH_A)},
341+
states={5: _state(5, STATUS_LAND, _HASH_A, _NEW)},
342+
force=True,
343+
)
344+
345+
# The decided PR from test_pr_decided_still_skips (terminal LAND, unchanged hash) normally SKIPs;
346+
# force bypasses decide on the max_dispatches=None path (_fingerprint_all) and dispatches it,
347+
# still using the fingerprint's head_sha/eval_hash and tagging the reason "forced".
348+
assert scan.dispatched == [(5, "headsha5", _HASH_A, DEFAULT_DISPATCH_REF)]
349+
assert "PR #5: DISPATCH (forced)" in caplog.text
350+
assert "dispatched review for PR #5 (forced)" in caplog.text
351+
352+
353+
def test_force_dispatches_decided_pr_via_until_dispatchable(make_config, caplog):
354+
with caplog.at_level(logging.INFO, logger="greenlight"):
355+
scan = _run_scan(
356+
make_config,
357+
pr=5,
358+
fingerprints={5: ("headsha5", _HASH_A)},
359+
states={5: _state(5, STATUS_LAND, _HASH_A, _NEW)},
360+
max_dispatches=1,
361+
force=True,
362+
)
363+
364+
# The capped path (_fingerprint_until_dispatchable, selected by a non-None --max) must honor
365+
# force identically: the same decided PR is dispatched with reason "forced".
366+
assert scan.dispatched == [(5, "headsha5", _HASH_A, DEFAULT_DISPATCH_REF)]
367+
assert "PR #5: DISPATCH (forced)" in caplog.text
368+
369+
370+
def test_force_fingerprint_failure_still_raises(make_config, caplog):
371+
dispatched: list[int] = []
372+
373+
def boom_fingerprint(_client, _number):
374+
raise RuntimeError("fingerprint boom")
375+
376+
def fake_dispatch(_client, number, head_sha, eval_hash, dispatch_ref):
377+
dispatched.append(number)
378+
379+
with (
380+
caplog.at_level(logging.ERROR, logger="greenlight"),
381+
pytest.raises(RuntimeError, match=r"1 PR\(s\) failed during scan: \[7\]"),
382+
):
383+
review.run(
384+
make_config(github_token="t"),
385+
pr=7,
386+
force=True,
387+
build_github=lambda _token: _CLIENT,
388+
fetch=lambda _client: [],
389+
fingerprint=boom_fingerprint,
390+
read_state=lambda _repo, _numbers: {},
391+
dispatch=fake_dispatch,
392+
now=lambda: _NOW,
393+
)
394+
395+
# force short-circuits decide, not the try/except: the fingerprint failure is raised before the
396+
# force branch is reached, so PR7 is still caught, recorded as failed, and surfaces as the
397+
# aggregate RuntimeError -- force never bypasses failure handling nor dispatches a failed PR.
398+
assert dispatched == []
399+
assert "skipping PR #7" in caplog.text
400+
assert any(record.exc_info is not None for record in caplog.records)
401+
402+
333403
def test_ref_forwarded_to_dispatch(make_config):
334404
scan = _run_scan(
335405
make_config,

0 commit comments

Comments
 (0)