github2gerrit 2.2.1__py3-none-any.whl → 2.3.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.
github2gerrit/cli.py CHANGED
@@ -84,8 +84,10 @@ from .pr_approval import APPROVAL_MARKER
84
84
  from .pr_approval import ApprovalStatus
85
85
  from .pr_approval import describe_approver_policy
86
86
  from .pr_approval import evaluate_fork_approval
87
+ from .pr_approval import recorded_transfer
87
88
  from .pr_approval import render_blocked_comment
88
89
  from .pr_approval import render_cleared_comment
90
+ from .pr_approval import render_transferred_comment
89
91
  from .pr_commands import CMD_CHECK
90
92
  from .pr_commands import MENTION_PREFIX
91
93
  from .pr_commands import find_open_command
@@ -120,6 +122,18 @@ def _exit_for_pr_state_error(pr_number: int, pr_state: str) -> None:
120
122
  exit_for_pr_state_error(pr_number, pr_state)
121
123
 
122
124
 
125
+ def _is_sweep_leg() -> bool:
126
+ """Report whether this run is one leg of a fanned-out bulk sweep.
127
+
128
+ The reusable workflow splits a bulk dispatch into a leg per open
129
+ pull request, plus a leg that runs only the repository-wide cleanup
130
+ (#422). ``G2G_SWEEP_LEG`` marks those legs. A leg naming a pull
131
+ request is still a sweep, not a request for that pull request, so
132
+ it keeps the bulk semantics the single-job sweep had.
133
+ """
134
+ return env_bool("G2G_SWEEP_LEG", False)
135
+
136
+
123
137
  def _stop_for_pr_state(
124
138
  gh: GitHubContext, pr_state: str, progress_tracker: Any = None
125
139
  ) -> NoReturn:
@@ -133,11 +147,17 @@ def _stop_for_pr_state(
133
147
  misreport that. The report behind #441 cited exactly this run and
134
148
  took it for the close handler failing.
135
149
 
150
+ A sweep leg is the same case: the pull request was open when the
151
+ sweep listed it and closed before its leg ran.
152
+
136
153
  A dispatch or direct invocation naming a closed pull request is
137
154
  different: somebody asked for that one specifically, and being told
138
155
  it cannot be processed is the useful answer.
139
156
  """
140
- if gh.event_name in ("pull_request", "pull_request_target"):
157
+ if _is_sweep_leg() or gh.event_name in (
158
+ "pull_request",
159
+ "pull_request_target",
160
+ ):
141
161
  log.info(
142
162
  "Pull request #%s is %s; nothing to do for a '%s' event. Cleanup "
143
163
  "for a closed pull request runs on the 'closed' event itself.",
@@ -266,7 +286,9 @@ def _check_automation_only(
266
286
 
267
287
  close_pr(pr_obj, comment=comment)
268
288
 
269
- sys.exit(1)
289
+ # A sweep leg counts this as the single bulk job did: a
290
+ # pull request skipped, not the sweep failed.
291
+ sys.exit(0 if _is_sweep_leg() else 1)
270
292
  except Exception:
271
293
  log.exception("Failed to close non-automation PR")
272
294
  raise
@@ -684,6 +706,25 @@ def _check_fork_approval(
684
706
  return False, ""
685
707
 
686
708
 
709
+ def _marker_comments_newest_first(pr_obj: Any) -> list[Any]:
710
+ """Return the comments carrying the approval marker, newest first.
711
+
712
+ Empty when the comments cannot be read, which every caller treats
713
+ as "no notice": the edit then falls back to posting one, and a
714
+ sweep to processing the pull request.
715
+ """
716
+ try:
717
+ comments = list(pr_obj.as_issue().get_comments())
718
+ except Exception as exc:
719
+ log.debug("Could not read comments for approval notice: %s", exc)
720
+ return []
721
+ return [
722
+ comment
723
+ for comment in reversed(comments)
724
+ if APPROVAL_MARKER in (getattr(comment, "body", "") or "")
725
+ ]
726
+
727
+
687
728
  def _edit_owned_marker_comment(pr_obj: Any, body: str) -> bool:
688
729
  """Replace the tool's own approval notice, if one exists.
689
730
 
@@ -695,17 +736,8 @@ def _edit_owned_marker_comment(pr_obj: Any, body: str) -> bool:
695
736
  Returns:
696
737
  ``True`` when a notice of ours was updated.
697
738
  """
698
- try:
699
- issue = pr_obj.as_issue()
700
- comments = list(issue.get_comments())
701
- except Exception as exc:
702
- log.debug("Could not read comments for approval notice: %s", exc)
703
- return False
704
-
705
739
  # Newest first: if several carry the marker, keep the most recent.
706
- for comment in reversed(comments):
707
- if APPROVAL_MARKER not in (getattr(comment, "body", "") or ""):
708
- continue
740
+ for comment in _marker_comments_newest_first(pr_obj):
709
741
  try:
710
742
  comment.edit(body)
711
743
  except Exception as exc:
@@ -741,6 +773,74 @@ def _clear_fork_approval_notice(
741
773
  )
742
774
 
743
775
 
776
+ def _record_fork_transfer(
777
+ pr_obj: Any | None, approved_sha: str, *, pushed: bool
778
+ ) -> None:
779
+ """Note on the approval notice that the approved head transferred.
780
+
781
+ Gives a later sweep a way to tell that this head has already gone
782
+ to Gerrit (#419). Only a gated pull request carries an approved
783
+ commit, and only a run that pushed transferred one: a dry run, and
784
+ a pull request reconciled against changes already merged or
785
+ abandoned, both succeed without pushing, and recording either
786
+ would have sweeps skip a head that never reached Gerrit. Called
787
+ after the push, never before, for the same reason.
788
+
789
+ Edits the existing notice without creating one, like the
790
+ retraction. Best-effort: the transfer has happened, and a missing
791
+ record only costs a later sweep a redundant visit.
792
+ """
793
+ if not approved_sha or not pushed or pr_obj is None:
794
+ return
795
+ if env_bool("CI_TESTING", False):
796
+ return
797
+ _edit_owned_marker_comment(
798
+ pr_obj, render_transferred_comment(head_sha=approved_sha)
799
+ )
800
+
801
+
802
+ def _sweep_can_skip(pr_obj: Any | None, gh: GitHubContext) -> bool:
803
+ """Report whether a sweep may pass over an already-transferred PR.
804
+
805
+ True only when the newest approval notice records the pull
806
+ request's current head as transferred (#419). Anything less — no
807
+ notice, no record, an unreadable comment list, a head that has
808
+ moved since — means "process it", so a record that failed to be
809
+ written degrades to visiting the pull request as before rather
810
+ than stranding it.
811
+
812
+ Sweeps alone consult this. A dispatch naming the pull request, a
813
+ push and a comment are each somebody asking for this pull request,
814
+ and must work whatever a comment says.
815
+
816
+ The record is not proof of authorship, and nothing here assumes the
817
+ tool wrote it: anyone may paste one into a comment. Believing a
818
+ forgery is safe because skipping is the harmless direction. It
819
+ transfers nothing and delays only the sweep, which the explicit
820
+ routes above still override. The record is read only for heads the
821
+ approval gate applies to, so a forgery cannot keep a
822
+ same-repository pull request out of a sweep either.
823
+ """
824
+ if gh.head_is_trusted or pr_obj is None:
825
+ return False
826
+ head_sha = str(
827
+ getattr(getattr(pr_obj, "head", None), "sha", "") or ""
828
+ ).strip()
829
+ if not head_sha:
830
+ return False
831
+ notices = _marker_comments_newest_first(pr_obj)
832
+ body = str(getattr(notices[0], "body", "") or "") if notices else ""
833
+ if recorded_transfer(body) != head_sha.lower():
834
+ return False
835
+ log.info(
836
+ "⏩ Pull request #%s already transferred at %s; nothing for a "
837
+ "sweep to add",
838
+ gh.pr_number,
839
+ head_sha[:7],
840
+ )
841
+ return True
842
+
843
+
744
844
  def _post_fork_approval_notice(
745
845
  pr_obj: Any,
746
846
  status: ApprovalStatus,
@@ -1523,10 +1623,10 @@ def main(
1523
1623
  help="Gerrit server hostname (optional; overrides .gitreview).",
1524
1624
  ),
1525
1625
  gerrit_server_port: int = typer.Option(
1526
- 29418,
1626
+ 0,
1527
1627
  "--gerrit-server-port",
1528
1628
  envvar="GERRIT_SERVER_PORT",
1529
- help="Gerrit SSH port (default: 29418).",
1629
+ help="Gerrit SSH port; .gitreview's when unset, else 29418.",
1530
1630
  ),
1531
1631
  gerrit_ssh_privkey_g2g: str = typer.Option(
1532
1632
  "",
@@ -1899,9 +1999,10 @@ def _build_inputs_from_env() -> Inputs:
1899
1999
  dry_run=env_bool("DRY_RUN", False),
1900
2000
  normalise_commit=env_bool("NORMALISE_COMMIT", True),
1901
2001
  gerrit_server=env_str("GERRIT_SERVER", ""),
1902
- gerrit_server_port=int(
1903
- env_str("GERRIT_SERVER_PORT", "29418") or "29418"
1904
- ),
2002
+ # 0 means unset. A blank value is what the action passes when the
2003
+ # caller gave none, and the distinction matters: an explicit
2004
+ # port outranks .gitreview's, a defaulted one must not.
2005
+ gerrit_server_port=int(env_str("GERRIT_SERVER_PORT", "").strip() or 0),
1905
2006
  gerrit_project=env_str("GERRIT_PROJECT"),
1906
2007
  issue_id=env_str("ISSUE_ID", ""),
1907
2008
  issue_id_lookup_json=env_str("ISSUE_ID_LOOKUP_JSON", ""),
@@ -2114,6 +2215,10 @@ def _process_bulk_pr(
2114
2215
  log.debug("PR #%d rejected by automation_only check", pr_number)
2115
2216
  return "skipped", None, None
2116
2217
 
2218
+ # A sweep has nothing to add for a head already transferred
2219
+ if _sweep_can_skip(pr, per_ctx):
2220
+ return "skipped", None, None
2221
+
2117
2222
  # Fork PRs need a maintainer's approval before anything is fetched
2118
2223
  allowed, approved_sha = _check_fork_approval(pr, per_ctx, progress_tracker)
2119
2224
  if not allowed:
@@ -2125,9 +2230,13 @@ def _process_bulk_pr(
2125
2230
  if skip_result is not None:
2126
2231
  return skip_result
2127
2232
 
2128
- return _submit_bulk_pr(
2233
+ result = _submit_bulk_pr(
2129
2234
  data, per_ctx, pr_number, progress_tracker, approved_sha
2130
2235
  )
2236
+ status, submission, _exc = result
2237
+ if status == "success" and submission is not None:
2238
+ _record_fork_transfer(pr, approved_sha, pushed=submission.pushed)
2239
+ return result
2131
2240
 
2132
2241
 
2133
2242
  def _record_bulk_success(
@@ -3321,6 +3430,18 @@ def _handle_bulk_mode(
3321
3430
  ):
3322
3431
  return False
3323
3432
 
3433
+ if _is_sweep_leg():
3434
+ # The reusable workflow gave every pull request a leg of its
3435
+ # own (#422); this one keeps only the repository-wide part.
3436
+ log.info(
3437
+ "Bulk sweep fanned out per pull request; running the "
3438
+ "repository-wide cleanup only"
3439
+ )
3440
+ _run_gerrit_cleanup_tasks(
3441
+ data, gh, no_gerrit=no_gerrit, gerrit_cleanup_info_log=True
3442
+ )
3443
+ return True
3444
+
3324
3445
  bulk_success = _process_bulk(data, gh)
3325
3446
 
3326
3447
  try:
@@ -3444,9 +3565,35 @@ def _check_single_pr_duplicates(
3444
3565
  gh.pr_number,
3445
3566
  )
3446
3567
  except DuplicateChangeError as exc:
3568
+ if _is_sweep_leg():
3569
+ _skip_duplicate_in_sweep(exc, gh, progress_tracker)
3447
3570
  _handle_single_pr_duplicate_error(exc, progress_tracker)
3448
3571
 
3449
3572
 
3573
+ def _skip_duplicate_in_sweep(
3574
+ exc: DuplicateChangeError,
3575
+ gh: GitHubContext,
3576
+ progress_tracker: G2GProgressTracker | DummyProgressTracker,
3577
+ ) -> NoReturn:
3578
+ """End a sweep leg cleanly on a blocked duplicate.
3579
+
3580
+ With ``ALLOW_DUPLICATES`` off, the single bulk job skipped a
3581
+ duplicate and carried on; a run naming the pull request fails on
3582
+ one, which is the answer somebody asking for it needs. A leg is a
3583
+ sweep, so it keeps the bulk behaviour rather than marking the
3584
+ whole sweep failed.
3585
+ """
3586
+ progress_tracker.duplicate_skipped()
3587
+ progress_tracker.stop()
3588
+ log.warning(
3589
+ "Skipping PR #%s due to duplicate detection: %s. Use "
3590
+ "--allow-duplicates to override this check.",
3591
+ gh.pr_number,
3592
+ exc,
3593
+ )
3594
+ sys.exit(int(ExitCode.SUCCESS))
3595
+
3596
+
3450
3597
  def _print_single_pr_summary(
3451
3598
  progress_tracker: G2GProgressTracker | DummyProgressTracker,
3452
3599
  pipeline_success: bool,
@@ -3479,6 +3626,62 @@ def _print_single_pr_summary(
3479
3626
  safe_console_print(f"🔗 Gerrit change: {url}", style="green")
3480
3627
 
3481
3628
 
3629
+ def _gate_single_pr(
3630
+ gh: GitHubContext,
3631
+ data: Inputs,
3632
+ progress_tracker: G2GProgressTracker | DummyProgressTracker,
3633
+ ) -> tuple[GitHubContext, Any | None, str]:
3634
+ """Resolve the pull request and run every check ahead of transfer.
3635
+
3636
+ Exits successfully when there is nothing to transfer: a re-check
3637
+ with no gate to lift, a sweep leg whose head already transferred,
3638
+ or a fork pull request still awaiting approval.
3639
+
3640
+ Returns:
3641
+ The context with any metadata recovered from the pull request,
3642
+ the pull request itself, and the commit the approval gate
3643
+ authorised (empty when no gate applied).
3644
+ """
3645
+ pr_obj = _extract_and_display_pr_info(gh, data, progress_tracker)
3646
+
3647
+ # Fork PRs need a maintainer's approval before anything is
3648
+ # fetched and before the Gerrit key is materialised. Resolve
3649
+ # the PR here when the display step could not, so a missing
3650
+ # token cannot skip the gate.
3651
+ if pr_obj is None:
3652
+ pr_obj = _resolve_pr_for_gate(gh, data)
3653
+
3654
+ # Second chance at the pull request's own metadata, and a
3655
+ # second look at the short-circuit above. An issue_comment
3656
+ # payload carries none of it, so the check before this
3657
+ # depended on _augment_pr_refs_if_needed's API call; that
3658
+ # helper swallows a failure and returns the context
3659
+ # unresolved, while the fetch just above is a separate call
3660
+ # that may well have succeeded. Without this, one transient
3661
+ # failure would let a commenter resubmit an unchanged
3662
+ # same-repository pull request — exactly what the
3663
+ # short-circuit exists to prevent — and would leave the base
3664
+ # ref empty for the transfer that followed.
3665
+ gh = _recover_pr_metadata(gh, pr_obj)
3666
+ if _recheck_has_nothing_to_unblock(gh):
3667
+ log.info(
3668
+ "Re-check (%s) for PR #%s, whose head is in this "
3669
+ "repository; nothing to unblock, so no transfer is needed",
3670
+ gh.event_name,
3671
+ gh.pr_number,
3672
+ )
3673
+ sys.exit(int(ExitCode.SUCCESS))
3674
+
3675
+ # A sweep leg has nothing to add for a head already transferred
3676
+ if _is_sweep_leg() and _sweep_can_skip(pr_obj, gh):
3677
+ sys.exit(int(ExitCode.SUCCESS))
3678
+
3679
+ allowed, approved_sha = _check_fork_approval(pr_obj, gh, progress_tracker)
3680
+ if not allowed:
3681
+ sys.exit(int(ExitCode.SUCCESS))
3682
+ return gh, pr_obj, approved_sha
3683
+
3684
+
3482
3685
  def _handle_single_pr(
3483
3686
  data: Inputs,
3484
3687
  gh: GitHubContext,
@@ -3538,42 +3741,9 @@ def _handle_single_pr(
3538
3741
 
3539
3742
  # Display PR information with Rich formatting
3540
3743
  approved_sha = ""
3744
+ pr_obj: Any | None = None
3541
3745
  if gh.pr_number:
3542
- pr_obj = _extract_and_display_pr_info(gh, data, progress_tracker)
3543
-
3544
- # Fork PRs need a maintainer's approval before anything is
3545
- # fetched and before the Gerrit key is materialised. Resolve
3546
- # the PR here when the display step could not, so a missing
3547
- # token cannot skip the gate.
3548
- if pr_obj is None:
3549
- pr_obj = _resolve_pr_for_gate(gh, data)
3550
-
3551
- # Second chance at the pull request's own metadata, and a
3552
- # second look at the short-circuit above. An issue_comment
3553
- # payload carries none of it, so the check before this
3554
- # depended on _augment_pr_refs_if_needed's API call; that
3555
- # helper swallows a failure and returns the context
3556
- # unresolved, while the fetch just above is a separate call
3557
- # that may well have succeeded. Without this, one transient
3558
- # failure would let a commenter resubmit an unchanged
3559
- # same-repository pull request — exactly what the
3560
- # short-circuit exists to prevent — and would leave the base
3561
- # ref empty for the transfer that followed.
3562
- gh = _recover_pr_metadata(gh, pr_obj)
3563
- if _recheck_has_nothing_to_unblock(gh):
3564
- log.info(
3565
- "Re-check (%s) for PR #%s, whose head is in this "
3566
- "repository; nothing to unblock, so no transfer is needed",
3567
- gh.event_name,
3568
- gh.pr_number,
3569
- )
3570
- sys.exit(int(ExitCode.SUCCESS))
3571
-
3572
- allowed, approved_sha = _check_fork_approval(
3573
- pr_obj, gh, progress_tracker
3574
- )
3575
- if not allowed:
3576
- sys.exit(int(ExitCode.SUCCESS))
3746
+ gh, pr_obj, approved_sha = _gate_single_pr(gh, data, progress_tracker)
3577
3747
 
3578
3748
  # Check for duplicates in single-PR mode (before workspace setup)
3579
3749
  if gh.pr_number and not env_bool("SYNC_ALL_OPEN_PRS", False):
@@ -3590,9 +3760,13 @@ def _handle_single_pr(
3590
3760
  )
3591
3761
 
3592
3762
  # Run abandoned-PR and Gerrit cleanup if the pipeline was successful
3593
- # Skip in G2G_NO_GERRIT: no Gerrit server to query
3763
+ # Skip in G2G_NO_GERRIT: no Gerrit server to query. A sweep leg
3764
+ # leaves it to the sweep's cleanup leg, rather than every leg
3765
+ # racing to close and abandon the same things.
3594
3766
  if pipeline_success:
3595
- _run_gerrit_cleanup_tasks(data, gh, no_gerrit=no_gerrit)
3767
+ _record_fork_transfer(pr_obj, approved_sha, pushed=result.pushed)
3768
+ if not _is_sweep_leg():
3769
+ _run_gerrit_cleanup_tasks(data, gh, no_gerrit=no_gerrit)
3596
3770
 
3597
3771
  try:
3598
3772
  log_api_metrics_summary()
@@ -4030,8 +4204,9 @@ def _add_full_config(
4030
4204
  # Show Gerrit settings if they have values
4031
4205
  if data.gerrit_server:
4032
4206
  config_info["GERRIT_SERVER"] = data.gerrit_server
4033
- # Only show non-default port (29418 is default)
4034
- if data.gerrit_server_port and data.gerrit_server_port != 29418:
4207
+ # Any set port is shown: 0 is the unset sentinel, and an explicit
4208
+ # 29418 is a real override of whatever .gitreview names.
4209
+ if data.gerrit_server_port:
4035
4210
  config_info["GERRIT_SERVER_PORT"] = str(data.gerrit_server_port)
4036
4211
  if data.gerrit_project:
4037
4212
  config_info["GERRIT_PROJECT"] = data.gerrit_project