Skip to content

Feat: Sweep approved fork PRs on a schedule - #455

Merged
keanjapesan merged 2 commits into
lfreleng-actions:mainfrom
modeseven-lfreleng-actions:feat/scheduled-sweep
Sep 24, 2026
Merged

keanjapesan merged 2 commits into
lfreleng-actions:mainfrom
modeseven-lfreleng-actions:feat/scheduled-sweep

Conversation

@ModeSevenIndustrialSolutions

Copy link
Copy Markdown
Contributor

Summary

Closes #421

A caller of the reusable workflow that subscribes to schedule now gets the zero-touch fork flow: a maintainer approves in the ordinary way, and the next sweep transfers the change. Nobody has to comment @github2gerrit check. That comment still works, and is still the faster route.

on:
  schedule:
    - cron: "17 * * * *"

This builds on #454. The per-PR fan-out and the transfer record were the two pieces #421 said a correct sweep needed.

Commits

  • Test: Share the reusable workflow test harness: a refactor that moves the expression evaluator and enumeration runner out of test_bulk_fan_out.py into tests/workflow_harness.py, so the new tests can reuse them. No assertion changes. test_precommit_mypy_pins now derives local test modules from the tracked files instead of a fixed list, so the next shared helper won't trip it either.
  • Feat: Sweep approved fork PRs on a schedule: the feature.

#421's requirements, one by one

Each transfer is serialised against per-PR runs. A schedule fans out through the enumerate job exactly as a bulk dispatch does: one leg per PR, each in the concurrency group every event run for that PR uses. A sweep leg and a comment re-check for the same commit — the collision #421 thought likeliest — now queue instead of both creating a first Gerrit change.

The sweep visits only PRs the gate can block. A scheduled listing keeps a PR only when both of these hold:

  • Its head is not known to be in this repository. This is head_repo_is_trusted's own rule: name for name, ignoring case. A head whose repository no longer exists (a deleted fork) is visited, because the gate applies to unresolved provenance.
  • Some approving review covers its current head. The gate can't authorise without one, so this only drops PRs it would block anyway. Who approved, and whether changes are requested, are still for the leg's gate to decide. If GitHub truncates the review list, the PR is kept rather than guessed about.

Same-repository PRs, including every automation PR, never enter a scheduled sweep. Bulk dispatch still visits everything. A schedule has no cleanup leg, and a run with nothing to visit starts no jobs.

An already-transferred PR is not submitted again. Its leg reads the #419 record and stops. The record isn't read during enumeration, since that would mean reimplementing _sweep_can_skip's reading of the comment thread in jq.

Covered by tests, not just documented. See Validation below.

Tool side

schedule joins RECHECK_EVENTS, which gives a scheduled leg everything a comment re-check already has:

  • UPDATE mode, so an existing change gains a patchset rather than a sibling.
  • The create-missing fallback, so the first transfer isn't refused.
  • The same-repository short-circuit, as a second line of defence behind the listing filter.

The existing parametrised tests over RECHECK_EVENTS now cover schedule automatically.

The composite action accepts PR_NUMBER on schedule, which is how a leg names its PR. Without one, or with 0, the action fails with an explanation rather than sweeping every PR in one job. A composite action can't start jobs, and one job over every PR is exactly the race #421 describes.

Trade-offs worth a reviewer's eye

  • Repeated jobs. A PR that is approved but not yet transferred, or transferred but still open, gets a short job on every run until its head moves or it closes; the transfer record makes that job stop early. Hourly suits most projects, and the docs say so.
  • Schedule suspension. GitHub suspends schedules in a public repo after 60 days without activity. The comment re-check is unaffected, and the docs note this.
  • Listing query. The query now also fetches headRefOid, headRepository and up to 100 approving reviews per PR. That is still about 1 GraphQL point per page of 100 PRs.

Validation

  • uv run pytest tests/: full suite passes.
  • ruff, mypy and basedpyright are clean.
  • prek passes on both commits (actionlint with shellcheck, yamllint, check-yaml, write-good, markdownlint, reuse, pytest), and gitlint passes.
  • aislop ci --changes --base upstream/main: 100/100, nothing new.
  • zizmor --persona auditor: no findings in github2gerrit.yaml; the 3 pre-existing ones in testing.yaml are untouched.
  • tests/test_scheduled_sweep.py covers:
    • routing: a schedule starts a sweep, and the single job doesn't run;
    • the lock a scheduled leg shares with pull_request_target and comment runs, evaluated from the workflow's own expressions;
    • the listing filter case by case against the stubbed API: approved fork, same repo, case-only difference, deleted fork, unapproved, stale approval, truncated reviews, mixed pages, bulk dispatch unaffected;
    • the 256/257 matrix limit;
    • the action steps a scheduled leg passes through;
    • RECHECK_EVENTS membership.
  • I mutation-tested each guard (10 mutations); every one fails at least one test.

As with #454, this hasn't run on real runners. After merge, adding a schedule trigger to testing.yaml's reusable-workflow job, which runs with G2G_NO_GERRIT, would exercise it safely.

The fan-out tests (lfreleng-actions#422) carry a small evaluator for GitHub
expressions and a runner for the enumerate job's script against a
stubbed GraphQL endpoint. The scheduled sweep (lfreleng-actions#421) needs both, and a
second copy would drift from the first, so they move to
tests/workflow_harness.py. It is not a test module; pytest puts tests/
on sys.path, so test files import it by name.

Two small changes come with the move. A page handed to the stub may
now hold full GraphQL nodes as well as bare numbers, which the
scheduled listing's filter needs, and the page types are Sequences so
the type checkers accept lists of either.

test_precommit_mypy_pins treated the new import as an unpinned
third-party package, since its first-party set was a fixed list. It
now derives the local modules from the tracked files in tests/, so
the next shared helper will not need an edit there either.

No test changes what it asserts.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>
Closes lfreleng-actions#421.

The comment re-check transfers an approved fork pull request once
somebody posts '@github2gerrit check'. A caller of the reusable
workflow that also subscribes to schedule now gets the zero-touch
design: a maintainer approves in the ordinary way and the next sweep
transfers the change. lfreleng-actions#421 recorded why the version first tried in
lfreleng-actions#418 was withdrawn; each of its requirements holds here.

Serialisation. A schedule fans out through the enumerate job exactly
as a bulk dispatch does (lfreleng-actions#422): a leg per pull request, each in the
group every event run for that pull request uses. A sweep leg and a
comment re-check for the same commit, the collision lfreleng-actions#421 thought
likeliest, queue behind each other rather than both creating a first
Gerrit change.

Scope. A scheduled listing keeps only what the gate could be holding
back: a head not known to be in this repository, by
head_repo_is_trusted's own rule and ignoring case, so a head whose
repository no longer exists is still visited; and an approving review
of the current head, which the gate cannot authorise without. The
approval test only drops pull requests the gate would block anyway,
and a review list GitHub truncated keeps the pull request. Who
approved and whether changes are requested are still the leg's gate
to decide. The listing query gains the fields this reads; a bulk
dispatch still visits everything and ignores them. A schedule has no
cleanup leg, so all 256 matrix jobs are pull requests, and a run with
nothing to visit starts no legs.

Idempotency. A pull request already transferred at its head still
gets a leg, which reads the transfer record and stops (lfreleng-actions#419). The
record is not read during enumeration: that would duplicate
_sweep_can_skip's reading of the comment thread in jq.

schedule joins RECHECK_EVENTS, which gives a scheduled leg what a
comment re-check has. It runs as UPDATE, so an existing change gains a
patchset rather than a sibling. The create-missing fallback lets the
first transfer through. A same-repository head is left alone, behind
the listing's own filter. The existing parametrised tests over that
set now cover schedule too.

The composite action accepts PR_NUMBER on schedule, which is how a
leg names its pull request. Without one, or with 0, it fails and says
why, rather than sweeping every pull request in one job: a composite
action cannot start jobs, and one job over every pull request is the
race lfreleng-actions#421 describes.

tests/test_scheduled_sweep.py covers the routing, the lock a
scheduled leg shares, the listing filter case by case against the
stubbed API, the matrix limit, the action steps a leg passes through,
and the recheck membership. Each was checked to fail against the
behaviour it guards.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

It introduces unattended Gerrit transfers and intricate workflow concurrency that warrant validation on real runners.

Review effort: Balanced
Findings: None

What changed in this PR

Adds scheduled, zero-touch processing of approved fork pull requests while preserving per-PR serialization and transfer idempotency.

Changes:

  • Adds scheduled candidate enumeration and per-PR fan-out.
  • Treats scheduled legs as privileged rechecks with validated PR numbers.
  • Adds documentation and comprehensive workflow tests.
File Description
.github/​workflows/​github2gerrit.yaml Implements scheduled enumeration and fan-out.
action.yaml Accepts validated scheduled sweep legs.
src/​github2gerrit/​models.py Classifies schedules as recheck events.
README.md Documents scheduled reusable-workflow usage.
docs/​features.md Explains scheduling behavior and caveats.
tests/​workflow_harness.py Provides shared workflow-test utilities.
tests/​test_scheduled_sweep.py Covers filtering, locking, routing, and limits.
tests/​test_bulk_fan_out.py Reuses the shared test harness.
tests/​test_precommit_mypy_pins.py Recognizes test helper modules as first-party.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@keanjapesan
keanjapesan merged commit df12d7f into lfreleng-actions:main Sep 24, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a scheduled sweep to lift the fork gate

3 participants