Fix stale-approval bypass in BCR PR reviewer's review-freshness check (#2764)
## Summary
`actions/bcr-pr-reviewer/index.js`'s `getPrApprovers()` decides whether
an existing GitHub review is still valid by comparing its `submitted_at`
against the timestamp of "the latest commit" -- but it computes that
timestamp only from commits whose `parents.length === 1`, explicitly
filtering out merge commits:
```js
// Filter out the merge commits whose parents length is larger than 1
const nonMergeCommits = commits.filter(commit => commit.parents.length === 1);
const latestCommit = nonMergeCommits[nonMergeCommits.length - 1];
const latestCommitTime = new Date(latestCommit.commit.author.date);
...
reviewEvents.forEach(review => {
if (new Date(review.submitted_at) < latestCommitTime) {
return; // review is stale, ignore it
}
...
});
```
A merge commit can itself carry diff content -- conflict resolution, or
content brought in from whatever was merged -- so excluding it from "the
latest commit" means a review submitted *before* that merge commit still
counts as approving the PR's *current* head, even though the reviewer
never saw the merge commit's content.
Concretely: a contributor gets an early, innocuous commit approved by a
maintainer, then pushes a merge commit that changes the PR's actual
content. `getPrApprovers()` still returns that maintainer as a current
approver, because the merge commit is invisible to its freshness
calculation.
`reviewPR()` (in the same file) uses `getPrApprovers()`'s output to
decide whether to submit its own `APPROVE` review and call
`pulls.merge()`:
```js
if (allModulesApproved && !hasSensitiveMetadataChange) {
if (!approvers.has(myLogin)) {
await octokit.rest.pulls.createReview({ ..., event: 'APPROVE', ... });
}
await octokit.rest.pulls.merge({ ... });
...
}
```
So this bug lets the bot submit its own fresh `APPROVE` review and merge
a PR based on a maintainer's approval of content that is no longer
what's actually being merged.
**Why this is the sole gate, not defense-in-depth:**
`bazel-central-registry`'s `CODEOWNERS` file explicitly excludes
`modules/` from GitHub's native code-owner review requirement:
```
* @bazelbuild/bcr-maintainers
/modules/
```
with the comment "which has a bot that manages review requests" -- i.e.
this bot's own approval decision is the *only* review gate for module
changes, there's no redundant native check backing it up for that path.
I verified the mechanics with a standalone script (not just by reading
the source) that reproduces `getPrApprovers()`'s exact logic against
fabricated commit/review data shaped like the real GitHub API responses:
a review approving commit C1, followed by a 2-parent merge commit C2 as
the new PR head, still returns the reviewer as an approver of C2's
content.
## Fix
Use the PR's actual last commit (`commits[commits.length - 1]`) as the
freshness cutoff, without excluding merge commits. `pulls.listCommits`
already returns commits in chronological order, so the last entry is
always the true current PR head regardless of its parent count --
there's no need to special-case merge commits at all.
Also fixed two accidentally-undeclared variables (`existingSubmittedAt`,
`submittedAt`) in the same block (missing `const`, silently created
implicit globals in this non-strict-mode script).
## Test plan
- `node -c index.js`: parses cleanly.
- Added `index.test.js` (Node's built-in `node:test` runner, zero new
dependencies -- this action's `package.json` had no test setup at all
before this) with a fake `octokit` object supplying fabricated
`listCommits`/`listReviews` data directly, covering:
1. The bypass scenario above -- asserts the stale approval no longer
counts.
2. A review genuinely submitted *after* a merge commit -- asserts it
still counts (no regression for the legitimate "approve after syncing
with main" case).
3. The plain no-merge-commits case -- asserts unaffected.
- `npm test` (updated from the placeholder `"no test specified"` script
to `node --test`): all 3 pass.
Independent from, and a different root cause than, my two earlier fixes
in this file's sibling
(`buildkite/bazel-central-registry/bcr_presubmit.py`, #2739 and #2759)
-- this one's in the PR-reviewer bot's own approval logic, not the
presubmit command/Starlark generation.3 files changed