Parent: #1936
Problem
decidePendingAgentAction derives stagedHead = pending.params.expectedHeadSha and only runs the supersede/reject branch when stagedHead is truthy. planAgentMaintenanceActions sets expectedHeadSha on a planned merge action but never on a planned approve action — so for every staged approve, stagedHead is undefined and the pre-check is a silent no-op. The underlying consequence is more significant than a mislabeled audit row: since approve has no head-SHA pin anywhere in the chain (unlike merge), and createPullRequestReview's APPROVE call has no commit_id parameter pinning it to a specific commit, a force-push between staging and accept means the maintainer's accept silently approves the NEW, unreviewed commit rather than being denied or superseded.
Failure scenario: a repo runs approve at auto_with_approval; gittensory stages an approve, the contributor force-pushes, and the maintainer taps Accept believing they're approving what was originally staged. The approve executes against GitHub's current (unreviewed) head, since there's no SHA pin anywhere in the approve path to detect or prevent this.
Requirements
- A staged approve must be pinned to the head it was actually reviewed against, and a force-push since staging must be detected and denied — matching the guarantee
merge already has.
Deliverables
- Set
expectedHeadSha on the planned approve action the same way merge already does (src/settings/agent-actions.ts), so it's persisted into the pending row's params.
- Pass
commit_id (the pinned head) to createPullRequestReview's APPROVE call in the executor, so the review is pinned to the reviewed commit rather than always targeting GitHub's current head.
- Add a regression test: stage an approve, force-push, accept — assert the accept is superseded/denied rather than approving the new commit.
Acceptance criteria
- A staged approve force-pushed after staging is rejected on accept, matching merge's existing behavior.
- A staged approve accepted with the head unchanged still executes normally.
Expected outcome
Auto-approve gets the same force-push protection every other staged action class already has, closing a path where a maintainer's accept could silently approve code they never reviewed.
Parent: #1936
Problem
decidePendingAgentActionderivesstagedHead = pending.params.expectedHeadShaand only runs the supersede/reject branch whenstagedHeadis truthy.planAgentMaintenanceActionssetsexpectedHeadShaon a plannedmergeaction but never on a plannedapproveaction — so for every staged approve,stagedHeadisundefinedand the pre-check is a silent no-op. The underlying consequence is more significant than a mislabeled audit row: sinceapprovehas no head-SHA pin anywhere in the chain (unlike merge), andcreatePullRequestReview's APPROVE call has nocommit_idparameter pinning it to a specific commit, a force-push between staging and accept means the maintainer's accept silently approves the NEW, unreviewed commit rather than being denied or superseded.Failure scenario: a repo runs
approveatauto_with_approval; gittensory stages an approve, the contributor force-pushes, and the maintainer taps Accept believing they're approving what was originally staged. The approve executes against GitHub's current (unreviewed) head, since there's no SHA pin anywhere in the approve path to detect or prevent this.Requirements
mergealready has.Deliverables
expectedHeadShaon the plannedapproveaction the same waymergealready does (src/settings/agent-actions.ts), so it's persisted into the pending row's params.commit_id(the pinned head) tocreatePullRequestReview's APPROVE call in the executor, so the review is pinned to the reviewed commit rather than always targeting GitHub's current head.Acceptance criteria
Expected outcome
Auto-approve gets the same force-push protection every other staged action class already has, closing a path where a maintainer's accept could silently approve code they never reviewed.