Skip to content

fix(agent-actions): pin a staged approve to its reviewed head SHA - #2377

Merged
JSONbored merged 5 commits into
mainfrom
claude/approve-head-pin
Jul 1, 2026
Merged

fix(agent-actions): pin a staged approve to its reviewed head SHA#2377
JSONbored merged 5 commits into
mainfrom
claude/approve-head-pin

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

What

decidePendingAgentAction's supersede check only fires when pending.params.expectedHeadSha is truthy, but planAgentMaintenanceActions set expectedHeadSha on a planned merge action and never on a planned approve action — so for every staged approve, stagedHead was undefined and the pre-check was a silent no-op.

The underlying consequence is more significant than a mislabeled audit row: unlike merge, approve had no head-SHA pin anywhere in the executor chain, and createPullRequestReview's APPROVE call had no commit_id parameter pinning it to a specific commit. A force-push between staging and accept meant the maintainer's accept would silently approve 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.

Fix

  • 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 via the existing (already action-class-agnostic) actionParams()/supersede-check machinery — no changes needed there, since the check itself was always correct, just never fed a value for approve.
  • Add an optional commitId parameter to createPullRequestReview (src/github/pr-actions.ts), sent as GitHub's commit_id when provided.
  • Pass the pinned head through from the executor's approve case (src/services/agent-action-executor.ts), mirroring the merge case's identical action.expectedHeadSha ?? ctx.headSha fallback pattern, so a live sweep (no staging) still pins to the current head and a staged replay pins to the reviewed one.

Tests

  • createPullRequestReview sends commit_id when a commitId is provided, and omits it when not.
  • Executor: a pinned approve action targets the reviewed head over the context head; falls back to an empty review body when unset.
  • Approval queue: accept executes a staged approve pinned to the reviewed SHA when the head is unchanged; accept supersedes (denies) a staged approve when the live head moved after staging — the core regression this issue asks for, mirroring the existing merge force-push test exactly.

Full unsharded test:coverage green (5604 passed); typecheck green; npm audit clean.

Advances #1936. Closes #2262.

decidePendingAgentAction's supersede check only fires when
pending.params.expectedHeadSha is truthy, but planAgentMaintenanceActions
set expectedHeadSha on a planned merge action and never on a planned
approve action — so for every staged approve, the check was a silent
no-op. Unlike merge, approve also had no head-SHA pin anywhere in the
executor chain, and createPullRequestReview's APPROVE call had no
commit_id pinning it to a specific commit. A force-push between staging
and accept meant the maintainer's accept would silently approve the
new, unreviewed commit rather than being denied or superseded.

Set expectedHeadSha on the planned approve action the same way merge
already does, so it persists into the pending row's params and the
existing (already-correct, action-class-agnostic) supersede check
actually engages. Add an optional commitId parameter to
createPullRequestReview and pass the pinned head through from the
executor's approve case, mirroring the merge case's identical pattern,
so the review itself is pinned to the reviewed commit via GitHub's
commit_id rather than always targeting the PR's current head.
@dosubot dosubot Bot added the size:S label Jul 1, 2026
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.84%. Comparing base (4389690) to head (6c8b30f).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2377   +/-   ##
=======================================
  Coverage   95.84%   95.84%           
=======================================
  Files         224      224           
  Lines       25048    25055    +7     
  Branches     9106     9109    +3     
=======================================
+ Hits        24007    24014    +7     
  Misses        427      427           
  Partials      614      614           
Files with missing lines Coverage Δ
src/github/pr-actions.ts 98.93% <100.00%> (+0.01%) ⬆️
src/services/agent-action-executor.ts 89.90% <100.00%> (+0.09%) ⬆️
src/services/agent-approval-queue.ts 100.00% <100.00%> (ø)
src/settings/agent-actions.ts 92.79% <100.00%> (+0.06%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@loopover-orb

loopover-orb Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Warning

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏸️ Gittensory review result - manual review recommended

Review updated: 2026-07-01 22:09:47 UTC

7 files · 1 AI reviewer · no blockers · readiness 93/100 · CI green · clean

⏸️ Suggested Action - Manual Review

  • Touches a guarded path — held for manual review

Review summary
The change correctly threads a reviewed-head pin through planned approve actions, queued approval acceptance, executor actuation, and the GitHub reviews API. The new unpinned-staged-approve rejection closes the legacy-row gap without blocking stale-approval retractions, and the tests cover the live pin, staged replay pin, moved-head rejection, and commit_id serialization paths. I do not see a reachable correctness break in the provided diff.

Nits — 6 non-blocking
  • nit: src/services/agent-approval-queue.ts:78 relies on a long explanatory comment where the core invariant is simple enough to state briefly; the detailed force-push rationale is already covered by the tests and PR body.
  • nit: src/services/agent-action-executor.ts:260 says GitHub's commit_id targeting keeps the approve from landing on a new commit, but the queue/freshness guards are the actual fail-closed protection for staged actions; tighten the comment so it does not overstate the API backstop.
  • src/services/agent-action-executor.ts:260: reword the approve comment to say commit_id records the reviewed commit while the accept-time/head-freshness checks prevent stale staged approvals from executing.
  • src/services/agent-approval-queue.ts:78: shorten the legacy-unpinned comment and keep the durable rule visible: unpinned approve grants are rejected, dismissStaleApproval retractions are exempt.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.
  • Touches a guarded path — held for manual review — A maintainer must review and merge this change.
Signal Result Evidence
Code review ✅ No blockers 1 reviewer
Linked issue ✅ Linked #2262
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (size label size:M; 1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 65 registered-repo PR(s), 55 merged, 572 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 65 PR(s), 572 issue(s).
Gate result ⚠️ Not blocking Advisory; not blocking this PR.
Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository registration is not available in the local Gittensory cache.
  • Public profile languages: Python, TypeScript, JavaScript, Ruby, Go, Kotlin, MDX, Shell
  • Official Gittensor activity: 65 PR(s), 572 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Triage stale or unlinked PRs.
  • No action.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by Gittensory, a quiet PR intelligence layer for OSS maintainers.

  • Re-run Gittensory review

@loopover-orb loopover-orb Bot added gittensor gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. labels Jul 1, 2026
@JSONbored JSONbored self-assigned this Jul 1, 2026
JSONbored added 4 commits July 1, 2026 14:43
# Conflicts:
#	src/services/agent-action-executor.ts
#	test/unit/agent-action-executor.test.ts
#	test/unit/agent-approval-queue.test.ts
…ge broke

"LIVE request_changes/approve without a reviewBody falls back to an empty
string" predates this PR's SHA-pinning change to the approve path
(performAction now always passes a 5th commit_id arg, falling back to
ctx.headSha) but was never updated to expect it -- a gap in the original
change, not something introduced by resolving the merge conflicts.
A staged 'approve' with no expectedHeadSha (e.g. staged by code
predating this PR's head-pin change, or a planning pass that ran while
the stored PR head SHA was transiently null) carries no record of what
was actually reviewed. Unlike merge's `sha` param -- which GitHub 409s
on if the head moved -- the reviews API's `commit_id` is purely
advisory: GitHub will happily post an APPROVE at any valid commit,
current or not. The existing supersede check in decidePendingAgentAction
only fires when a pin EXISTS and disagrees with the live head, so an
unpinned row fell through to the executor's `ctx.headSha` fallback and
would silently approve whatever commit is live at accept time -- ratifying
code that was never actually gate-evaluated, under the authority of a
review that happened against a different (or no) commit.

Extend the accept-flow's staleness gate: an approve staged without a
pin is refused outright (status: rejected, executionOutcome:
"unpinned_legacy_action") rather than replayed through the fallback.
dismissStaleApproval is exempt -- it retracts the bot's existing
approval rather than granting a new one at a specific commit, so it
carries no ratify-unreviewed-code risk.

This is deliberately scoped to the accept-flow gate, not
performAction's shared executor: the executor's ctx.headSha fallback is
still correct and necessary for a genuinely fresh, same-pass live plan
(where ctx.headSha and any omitted expectedHeadSha both come from the
same evaluation, so there is no staging-to-accept gap to bridge) --
changing that shared path would have broken the existing tests that
correctly model that case, per an adversarial-review pass that also
found the earlier "not exploitable today, legacy-rows-only" framing was
wrong (a live planning pass CAN omit expectedHeadSha today whenever the
stored PR head SHA is transiently null, and createPendingAgentActionIfAbsent's
onConflictDoNothing then freezes that row's missing pin until decided).
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 1, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
gittensory-ui 6c8b30f Commit Preview URL

Branch Preview URL
Jul 01 2026, 10:11 PM

@JSONbored
JSONbored merged commit a79975c into main Jul 1, 2026
13 checks passed
@JSONbored
JSONbored deleted the claude/approve-head-pin branch July 1, 2026 22:18
@github-project-automation github-project-automation Bot moved this from Todo to Done in gittensory - v1 roadmap Jul 1, 2026
JSONbored added a commit that referenced this pull request Jul 1, 2026
…anch (#2430)

performAction's update_branch case passed action.expectedHeadSha directly
into updatePullRequestBranch with no ?? ctx.headSha fallback, unlike the
approve/merge cases immediately above it. Add the same fallback for
parity, and document why update_branch does NOT need the accept-flow-level
"unpinned -> deny" gate #2377/#2422 added for approve/merge: it only
merges the base into the head, never contributor-controlled content, so
it cannot itself ratify unreviewed code, and it's already covered by the
generic freshness guard + the approval-queue's actionClass-agnostic
supersede check.

Closes #2424
JSONbored added a commit that referenced this pull request Jul 1, 2026
#2377 closed this gap for approve; merge had the identical fallback in
performAction (mergeSha = action.expectedHeadSha ?? ctx.headSha). The
usual "GitHub 409s on a stale sha" backstop doesn't cover this case: the
fallback substitutes whatever head is live right now, so it trivially
matches and no 409 is possible. Extend the same accept-flow denial gate
to merge.

Closes #2422
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

No open projects
Status: Done

Development

Successfully merging this pull request may close these issues.

fix(agent-actions): approval-queue accept skips the stale-head supersede check for staged approve actions

1 participant