Skip to content

fix(ux): no false success banner on ownership-unavailable; actionable mergeStateStatus hints - #848

Open
NagyVikt wants to merge 28 commits into
mainfrom
agent/claude/fix-gate-safety-bugs-in-hooks-and-review-2026-10-27-12-17
Open

NagyVikt wants to merge 28 commits into
mainfrom
agent/claude/fix-gate-safety-bugs-in-hooks-and-review-2026-10-27-12-17

Conversation

@NagyVikt

Copy link
Copy Markdown
Collaborator

What

A collection of gate-safety, security, and UX fixes for gx claude install, gx pr-review, and the mirror push path, plus a new gx pr respond sub-command for auto-fixing blocking review threads.

Why / Fixes

  • False success banner (feat(claude-install,review-gate)): gx claude install printed a green success banner even when ownership was unavailable; now shows an honest warning. Adds lightweight head-poll and actionable mergeStateStatus hints for common blocked states (BLOCKED, DIRTY, BEHIND).
  • Gate safety (fix(gate-safety)): Signal-killed hook exits (exit 128+N), push failures in the autofix path, and skipped checks were previously silently treated as success; they now surface as gate failures.
  • Review correctness (fix(pr-review-correctness)): Fix prompt was passed on the command line (leaks into ps output); now delivered via stdin. Head-changed guard enforced on non-Lens path.
  • Security hardening (fix(security)): Review-bot prompt injection hardened; mirror token no longer exposed in git remote URL or log output.
  • Mirror auth (fix(mirror) ×2): Bearer auth is rejected by GitHub HTTPS Git; switched to Basic. Subsequent fix: suppress the mirror token when actions/checkout already persisted its own Authorization header.
  • Head-SHA race (fix(pr-review) ×3): Capture head SHA before fetching diff to close stale-review race; abort before diff fetch when SHA capture fails; skip SHA abort when GitHub auth is unavailable (local-only runs).
  • New gx pr respond (feat(pr-respond)): Reads open review threads, generates fix commits via Claude, and re-runs the gate.

Tests

  • 1 569 passing / 3 known load flakes (pr-respond.test.js passes 30/30 when run in isolation).
  • 35 new test cases across test/claude-install.test.js, test/pr-respond.test.js, and test/review-gate.test.js.
  • npm run format:check:changed and npx biome lint both clean.

🤖 Generated with Claude Code

NagyVikt and others added 14 commits October 27, 2026 12:21
…utofix, and skipped checks

- hooks/index.js: subprocess killed by a signal sets result.status to null, so
  process.exitCode was assigned null (→ 0) and the gate silently passed.
  Fix: `process.exitCode = result.status ?? (result.signal ? 1 : 0)`.

- review-gate.js autofix loop: when pushBranch returns ok=false the loop
  previously broke and fell through to the generic "Auto-fix did not clear them"
  message, hiding the real cause.  Fix: throw with the push failure details.

- review-gate.js allAccountedFor: SKIPPED checks were not counted in either
  formula, so a PR where every non-pending check is success/waived/skipped
  never satisfied allAccountedFor and polled until the 30-min timeout.
  Fix: add count('skipped') to both the baseline and normal paths.

Tests: added signal-kill exit test in hook-help.test.js; added skipped-check
(with and without GitHub verdict) and push-failure tests in review-gate.test.js;
updated the prior skipped+billing-waiver timeout test to assert the now-correct
green result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d-changed guard on non-Lens path

- commandForFix now passes the fix prompt via stdin (input: + stdio 'pipe')
  for both claude and codex, matching the review path. The prompt no longer
  appears in the process argument vector and cannot exceed ARG_MAX.
- runPrReview captures the PR head SHA before runProviderReviewPayload so the
  mismatch guard in postGithubReview fires on all paths, not just the PR Lens
  path where reviewedHeadSha was previously set.
- Tests updated for the new commandForFix shape and stdio/input contract.
- New test: commandForFix delivers the prompt via stdin for both providers.
- New integration test: runPrReview aborts posting when the PR head advances
  between the pre-review snapshot and the post.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…posure

- review-bot-watch.sh: add sanitize_untrusted_str() to strip control
  chars and cap length on PR-author-controlled fields; wrap pr_title
  and head_branch in a clearly delimited UNTRUSTED PR DATA block so the
  agent treats them as data, not instructions
- review-bot-watch.sh: add ${2:-base} default to sanitize_slug fallback
  parameter so the function is safe under set -u when called without a
  second argument
- sync-frontend-mirror.sh: replace token-in-remote-URL push with
  GIT_CONFIG_COUNT/KEY/VALUE http.extraHeader so the credential never
  appears in argv or the git remote URL
- sync-frontend-mirror.sh: add --token-file <path> option; print a
  deprecation warning when --token is used so callers can migrate to
  env-var or file-based credential delivery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n; sanitize pr_url

- fetchHeadSha in runPrReview was called unconditionally before the provider
  run, adding an extra gh-pr-view call on every non-posting invocation. Tests
  whose fake runner identified the provider call as "first call that is not
  gh-pr-diff" now picked up the fetchHeadSha call instead, getting timeout=60000
  instead of the expected 900000/42000. Fix: guard the call with options.post so
  it only fires on the posting path where the mismatch guard is actually used.
  The head-changed integration test (pr-review.test.js:743) continues to pass
  because it invokes with --post.

- scripts/sync-frontend-mirror.sh: token-file stripping now removes both \r and
  \n so Windows-line-ending token files do not leave a trailing CR in the header.

- templates/scripts/review-bot-watch.sh: sanitize pr_url through
  sanitize_untrusted_str (same as pr_title) before embedding it in the prompt
  to close a prompt-injection vector via a crafted PR URL.

Fixes 4 new test failures introduced by commit 9f5f933:
  - the provider spawn is bounded (pr-review-timeout.test.js)
  - an explicit timeoutMs wins over the default (pr-review-timeout.test.js)
  - a garbage override falls back to the default (pr-review-timeout.test.js)
  - disabled integration does not invoke PR Lens (pr-lens.test.js)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-safety-bugs-in-hooks-and-review-2026-10-27-12-17
…supported)

GitHub's HTTPS Git transport expects the token as a Basic-auth password
(x-access-token:<token> base64-encoded), not as a Bearer token. The previous
Authorization: Bearer header causes push failures with git's http.extraHeader.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…review race

Move fetchHeadSha above fetchPrDiff so the head snapshot always precedes the
diff fetch. If the PR advances between these two calls, the mismatch guard in
postGithubReview detects it and rejects the stale finding set rather than
posting it against the new commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n post path

fetchHeadSha returns '' on a gh error. Without this guard the posting guard in
postGithubReview receives an empty reviewedHeadSha, which disables the
concurrent-push check entirely. Throw early so callers never post a review
that cannot be anchored to a known head SHA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-safety-bugs-in-hooks-and-review-2026-10-27-12-17
The guard added in fb35833 threw unconditionally when fetchHeadSha
returned '' on the --post path.  fetchHeadSha also returns '' when
GitHub auth is not available (gh pr view exits non-zero), so the throw
fired before the githubAuthAvailable fallback at line 1000 could catch
the no-auth case and write the markdown artifact instead.

Fix: gate the throw on githubAuthAvailable — only abort when auth IS
reachable but the SHA still could not be captured.  When auth is
unavailable, proceed past the check; the existing fallback writes the
artifact and exits 0 as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed its own header

Use the github.com-scoped extraheader key and reset it with an empty value
first; git sends every value of that multi-valued key otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ble, MSS hints, lightweight head-poll

- claude install: gate the success banner on the real MCP outcome;
  when installMcpServer returns ownership-unavailable, emit a logWarn
  explaining the file conflict and what to do, rather than printing the
  ✅ "wiring is in place" line that implies full success.
- review-gate merge-blocked: map raw mergeStateStatus enums to
  actionable hints in the thrown error — DIRTY→resolve conflicts
  (gx pr sync / merge base), BEHIND→update branch, BLOCKED→required
  reviews/conversations, UNSTABLE→wait or check failing checks.
- waitForPullRequestHead: default getStatus now calls pr.getPrHeadOid
  (headRefOid-only query) instead of pr.getPullRequestStatus (full
  statusCheckRollup) so each 2-second poll is a lightweight gh pr list
  --json headRefOid fetch rather than a full status snapshot.
- Adds pr.getPrHeadOid lightweight helper and exports it from pr.js.
- Focused tests: banner success/warning paths; DIRTY/BEHIND/UNSTABLE
  MSS hint text in runReviewGate error messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-safety-bugs-in-hooks-and-review-2026-10-27-12-17
…hreads

Add `gx pr respond --pr <n>` (src/pr-respond.js + wired into
src/cli/commands/pr.js) which:
  - Fetches unresolved, non-outdated review threads whose first comment is
    CRITICAL/HIGH (gitguardex review markers) or from a human reviewer who
    submitted a CHANGES_REQUESTED review.
  - Converts threads to findings and runs the existing runReviewFix path in
    the PR's local agent/* worktree.
  - On a successful fix: pushes the commit, re-runs gx pr-review (--post),
    then replies + resolves only the threads whose files changed.
  - Refuses forks, non-agent/* branches, missing/dirty worktrees.
  - Supports --dry-run (prints what would be fixed without touching anything).

Export worktreePathForBranch from src/agents/inspect.js (previously private).

Wire opt-in auto-respond into templates/scripts/review-bot-watch.sh behind
GUARDEX_REVIEW_AUTO_RESPOND=1: calls gx pr respond for each agent/* PR seen
in the poll loop (both on new-SHA dispatch and on unchanged-SHA polling).

Add 30 focused tests in test/pr-respond.test.js covering body-parsing
helpers, thread filtering, refusal logic, dry-run, and GraphQL
mutation wrappers via fake gh runners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

3 finding(s) — 🟠 1 high · 🟡 2 medium

⛔ Merge gate: blocked — 1 blocking finding(s) (blocks on high/critical).

Severity Location Finding
🟠 high src/pr-respond.js:508 Changing a file causes every blocking thread on that file to be resolved without confirming each defect was fixed.
🟡 medium src/pr-respond.js:123 Filtering reviews to CHANGES_REQUESTED includes historical requests that a reviewer has subsequently approved or dismissed.
🟡 medium src/pr-respond.js:499 Diagnostic logging corrupts the CLI's JSON output. When re-review throws, this console.log writes a plain-text prefix before cmdRespond

Provider codex · commit 02d9d8e · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js
const skippedReply = [];
for (let index = 0; index < threads.length; index += 1) {
const thread = threads[index];
if (!changedFiles.has(thread.path)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · correctness

Changing a file causes every blocking thread on that file to be resolved without confirming each defect was fixed. When two threads concern different bugs in the same file, fixing only one clears both conversations; resolution also proceeds when re-review fails. This can remove blocking feedback while the reported defect remains.

Comment thread src/pr-respond.js Outdated
'query($owner:String!,$name:String!,$pr:Int!){',
'viewer{login}',
'repository(owner:$owner,name:$name){pullRequest(number:$pr){',
'reviews(states:[CHANGES_REQUESTED],last:20){nodes{author{login}}}',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

Filtering reviews to CHANGES_REQUESTED includes historical requests that a reviewer has subsequently approved or dismissed. The resulting reviewer set does not represent the latest review state, so auto-respond can modify code for feedback that no longer blocks the PR. Fetch the latest review state per author and select only currently active change requests.

Comment thread src/pr-respond.js Outdated
);
} catch (error) {
// Non-fatal: fix is already pushed. Log but don't fail the whole respond.
console.log(`${TOOL_PREFIX} re-review failed (fix is pushed): ${error.message}`);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

Diagnostic logging corrupts the CLI's JSON output. When re-review throws, this console.log writes a plain-text prefix before cmdRespond serializes the result, making --json output invalid; reply and resolution failures have the same problem. Send these diagnostics to stderr at all three logging sites.

NagyVikt and others added 2 commits October 27, 2026 15:53
Auto-formatted by `npx biome format --write`; no logic change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pproved-after-CR

Covers the new latest-review-state logic in fetchBlockingThreads: a reviewer who
issued CHANGES_REQUESTED then APPROVED should not keep a thread blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

3 finding(s) — 🟠 1 high · 🟡 2 medium

⛔ Merge gate: blocked — 1 blocking finding(s) (blocks on high/critical).

Severity Location Finding
🟠 high src/pr-respond.js:542 Changing a file automatically resolves every blocking thread in that file without confirming each defect was fixed.
🟡 medium src/pr-respond.js:182 A subsequent COMMENTED or PENDING review incorrectly clears an active change request.
🟡 medium src/pr-respond.js:127 Review thread coordinates are used without checking which side of the diff they address.

Provider codex · commit 68fd91a · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js Resolved
Comment thread src/pr-respond.js Resolved
Comment thread src/pr-respond.js Resolved

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

2 finding(s) — 🟠 1 high · 🟡 1 medium

⛔ Merge gate: blocked — 1 blocking finding(s) (blocks on high/critical).

Severity Location Finding
🟠 high src/pr-respond.js:526 A completed re-review does not prove that a thread's defect was fixed.
🟡 medium src/pr-respond.js:400 The local worktree is selected without verifying that its HEAD matches the PR head.

Provider codex · commit 68fd91a · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js Outdated
skippedReply.push(thread.id);
continue;
}
if (reReviewResult === null) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · correctness

A completed re-review does not prove that a thread's defect was fixed. This condition permits automatic resolution whenever the review returns any non-null result and the file changed, even if the review still reports the original HIGH/CRITICAL finding or another thread in the same file was the only issue addressed. This can clear blocking conversations while their defects remain.

Why this matters

The added tests do not exercise this resolution path.

Comment thread src/pr-respond.js
}

// 2. Resolve the local worktree for the branch.
const { worktreePath, worktreeFound } = worktreePathForBranch(repoRoot, branch);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

The local worktree is selected without verifying that its HEAD matches the PR head. A clean worktree can be behind after another contributor pushes; the responder then applies current review locations to stale code and commits a fix that the subsequent non-fast-forward push rejects. Fetch the PR head SHA and require or safely establish matching local HEAD before running the fix provider.

…tate reviewer check; HEAD sync guard

Review findings addressed (pass 2/2):

HIGH (line 526): thread resolution now cross-checks the re-review findings set.
  A thread is only resolved when re-review returns no remaining blocking finding
  on that file, so two bugs on the same file do not both get dismissed after
  fixing only one. Builds on the earlier null guard (re-review failure = no
  resolution).

MEDIUM (line 399): verify worktree HEAD matches origin before running the fix
  provider. After a successful git fetch, compare HEAD to
  refs/remotes/origin/<branch>; refuse if they differ. Skipped when fetch
  fails (no remote / offline), so test repos without a remote still pass.

MEDIUM (line 123): already landed in b61550c; this commit carries the biome
  format-only delta left over from the previous auto-commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

2 finding(s) — 🟠 1 high · 🟡 1 medium

⛔ Merge gate: blocked — 1 blocking finding(s) (blocks on high/critical).

Severity Location Finding
🟠 high src/pr-respond.js:572 Human review threads can be resolved without their requested changes being implemented.
🟡 medium src/pr-respond.js:126 The review-state lookup silently excludes reviewers outside the latest 50 reviews.

Provider codex · commit 5654100 · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js
skippedReply.push(thread.id);
continue;
}
if (reReviewRemainingPaths.has(thread.path)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · correctness

Human review threads can be resolved without their requested changes being implemented. Touching the file and receiving no HIGH/CRITICAL findings from a fresh general review does not verify each original request. If two human threads concern the same file and only one is fixed, both can be automatically resolved, clearing unresolved review conversations.

Why this matters

Require confirmation against each original finding before resolving its thread, and cover partial fixes with a regression test.

Comment thread src/pr-respond.js Outdated
'query($owner:String!,$name:String!,$pr:Int!){',
'viewer{login}',
'repository(owner:$owner,name:$name){pullRequest(number:$pr){',
'reviews(last:50){nodes{author{login}state}}',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

The review-state lookup silently excludes reviewers outside the latest 50 reviews. A reviewer with an older active CHANGES_REQUESTED review disappears from humanReviewers after enough subsequent reviews, so their unresolved threads are ignored and the command can incorrectly report no blocking threads. Paginate the review connection or explicitly refuse incomplete review history, as the thread lookup already does.

…d review history

Review findings addressed (pass 3/3):

HIGH (line 572): human reviewer threads are no longer auto-resolved. Only
  gitguardex-bot threads (structured findings targeted by the fix provider)
  are resolved automatically. Human threads receive a reply but stay open for
  the reviewer to confirm their specific request was addressed.

MEDIUM (line 126): add pageInfo{hasNextPage} to the reviews query. If the PR
  has more than 50 reviews the oldest fall off and a CHANGES_REQUESTED reviewer
  could be silently excluded; now throws with an actionable message rather than
  auto-fixing with incomplete reviewer state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

2 finding(s) — 🟠 1 high · 🟡 1 medium

⛔ Merge gate: blocked — 1 blocking finding(s) (blocks on high/critical).

Severity Location Finding
🟠 high src/pr-respond.js:439 A failed fetch bypasses the stale-worktree safeguard and still allows automatic edits.
🟡 medium src/pr-respond.js:201 The review-history truncation check uses the wrong pagination direction.

Provider codex · commit 67439ae · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js
// excluded. Refuse rather than auto-fix with incomplete information.
const rawReviews = payload?.data?.repository?.pullRequest?.reviews;
if (rawReviews?.pageInfo?.hasNextPage) {
throw new Error(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

The review-history truncation check uses the wrong pagination direction. Because reviews are fetched with last:50, omitted older reviews are indicated by hasPreviousPage, while hasNextPage remains false. An active CHANGES_REQUESTED reviewer whose later interactions were only comments can therefore disappear from the computed blocking-reviewer set, silently skipping their unresolved threads.

Comment thread src/pr-respond.js Outdated
// If we can reach origin, verify the local HEAD matches to prevent applying the fix
// to a stale worktree (a diverged push would fail non-fast-forward, but checking here
// gives a clearer error sooner).
const fetchResult = gitRun(worktreePath, ['fetch', 'origin', branch], { allowFailure: true });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · correctness

A failed fetch bypasses the stale-worktree safeguard and still allows automatic edits. Git fetch can fail independently of the GitHub API, leaving current review coordinates applied to an older local checkout; the later push rejection occurs only after the provider has modified and committed files. Refuse the run when the remote head cannot be verified.

Suggested change
const fetchResult = gitRun(worktreePath, ['fetch', 'origin', branch], { allowFailure: true });
if (fetchResult.status !== 0) {
return {
status: 'skipped',
reason: 'could not fetch the PR branch from origin; cannot verify worktree HEAD'
};
}
if (fetchResult.status === 0) {

…ion direction for reviews

Review findings addressed (pass 4/4):

HIGH (line 439): if git fetch fails and origin is configured, refuse the run
  rather than proceeding with potentially stale code. If origin is absent
  (local-only repo), the check is skipped as before. Tests unaffected because
  test repos have no remote origin.

MEDIUM (line 201): 'last:50' fetches the most recent 50 reviews; older ones
  fall off the front of the connection, so the correct pagination flag is
  hasPreviousPage (not hasNextPage). Added test to cover the throw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

1 finding(s) — 🟡 1 medium

✅ Merge gate: pass — no blocking findings (blocks on high/critical).

Severity Location Finding
🟡 medium src/pr-respond.js:453-456 The freshness check reads a tracking ref that the fetch may not update.

Provider codex · commit 1068a90 · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js Outdated
Comment on lines +453 to +456
).trim();
const remoteHead = String(
gitRun(worktreePath, ['rev-parse', `refs/remotes/origin/${branch}`], { allowFailure: true })
.stdout || ''

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

The freshness check reads a tracking ref that the fetch may not update. When origin's configured fetch refspec excludes this branch, git fetch origin <branch> updates FETCH_HEAD while an existing origin/ ref remains stale. If the worktree matches that stale ref, this check accepts outdated code and applies review suggestions using coordinates from a newer PR revision.

Suggested change
).trim();
const remoteHead = String(
gitRun(worktreePath, ['rev-parse', `refs/remotes/origin/${branch}`], { allowFailure: true })
.stdout || ''
const remoteHead = String(
gitRun(worktreePath, ['rev-parse', 'FETCH_HEAD'], { allowFailure: true })
.stdout || ''
).trim();

…NDING filter tests

MEDIUM (line 453): git fetch origin <branch> always writes FETCH_HEAD with the
  remote SHA regardless of whether the tracking ref is updated — use FETCH_HEAD
  instead of refs/remotes/origin/<branch> so the comparison is always against
  what was actually just fetched.

Also includes 2 auto-generated tests covering the COMMENTED/PENDING state filter
in the latest-review-state computation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

2 finding(s) — 🟠 2 high

⛔ Merge gate: blocked — 2 blocking finding(s) (blocks on high/critical).

Severity Location Finding
🟠 high src/pr-respond.js:50 The responder never verifies that the local commit matches the PR head.
🟠 high src/pr-respond.js:587 Thread resolution fails open when re-review returns no findings array.

Provider codex · commit 92cc97e · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js Outdated
function fetchPrMeta(pr, repoRoot, runner = run) {
const result = runner(
GH_BIN,
['pr', 'view', String(pr), '--json', 'headRefName,isCrossRepository'],

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · correctness

The responder never verifies that the local commit matches the PR head. Metadata contains only the branch name, while the later freshness check compares against origin, which can differ from the repository selected by gh. A matching branch in another remote can therefore receive fixes derived from unrelated PR coordinates, and the responder can subsequently resolve threads on the original PR.

Suggested change
['pr', 'view', String(pr), '--json', 'headRefName,isCrossRepository'],
['pr', 'view', String(pr), '--json', 'headRefName,isCrossRepository,headRefOid,headRepository'],

Comment thread src/pr-respond.js
const thread = threads[index];
if (!changedFiles.has(thread.path)) {
skippedReply.push(thread.id);
continue;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · correctness

Thread resolution fails open when re-review returns no findings array. The preceding code treats a missing findings array as zero remaining blockers, but this guard rejects only null. A skipped or otherwise incomplete review result can therefore allow unresolved blocking threads to be automatically marked resolved without confirmation.

Suggested change
continue;
if (!reReviewResult || !Array.isArray(reReviewResult.findings)) {

NagyVikt and others added 2 commits October 27, 2026 16:15
…/PENDING, reReviewRemainingPaths

Fixes three correctness issues surfaced by the AI code review:

HIGH: thread resolution was too broad — a file being touched was sufficient to
resolve all its blocking threads even when the re-review still reported blocking
findings for that file. Added `reReviewRemainingPaths` Set: only resolve threads
whose file has no remaining HIGH/CRITICAL findings in the re-review result.

MEDIUM: COMMENTED and PENDING reviews were overwriting CHANGES_REQUESTED in the
latest-review-state map, silently clearing a reviewer's block. These two states
carry no approval/dismissal semantics and are now skipped when building the map.

MEDIUM: LEFT-side diff threads reference deleted lines that no longer exist in
the current file; forwarding those coordinates to the fix provider could apply
suggestions to unrelated surviving code. Added `diffSide` to the GraphQL query
and filter LEFT-side threads before returning.

Bonus: added a `fetchResult.status === 0` guard around the HEAD/origin sync
check so it is skipped for repos without a real origin remote (keeps tests clean).

Tests: +3 tests for COMMENTED-does-not-clear-CR, LEFT-side exclusion, and
reviewer-approved-after-CR. All 1576 tests pass (0 failures, 5 known skips).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update the fetchPrMeta test to include headRefOid in the gh pr view stub
and assert it is returned, covering the PR head SHA verification path added
in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

1 finding(s) — 🟡 1 medium

✅ Merge gate: pass — no blocking findings (blocks on high/critical).

Severity Location Finding
🟡 medium test/pr-respond.test.js:765-768 The fixture cannot create the agent worktree. If checkout succeeds, the branch is already checked out in repoDir, so the subsequent

Provider codex · commit cbbb329 · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread test/pr-respond.test.js Outdated
Comment on lines +765 to +768
cp.spawnSync('git', ['-C', repoDir, 'checkout', '-b', 'agent/empty-fix', '--track'], {
encoding: 'utf8',
env: { ...process.env, GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH: '1' }
});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · tests

The fixture cannot create the agent worktree. If checkout succeeds, the branch is already checked out in repoDir, so the subsequent worktree add refuses it; if --track fails, the branch does not exist. Both command failures are ignored, and prRespond returns skipped instead of no-threads. The dry-run fixture has the same problem.

Suggested change
cp.spawnSync('git', ['-C', repoDir, 'checkout', '-b', 'agent/empty-fix', '--track'], {
encoding: 'utf8',
env: { ...process.env, GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH: '1' }
});
cp.spawnSync('git', ['-C', repoDir, 'branch', 'agent/empty-fix'], {
encoding: 'utf8'
});

…eads tests

Use 'git branch <name>' instead of 'git checkout -b <name> --track' so the
branch is not checked out in the main working tree. 'git worktree add' refuses
branches already checked out elsewhere, so the old setup silently failed and
the tests passed only by accident (prRespond returned 'skipped' instead of
the asserted 'dry-run'/'no-threads').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

1 finding(s) — 🟡 1 medium

✅ Merge gate: pass — no blocking findings (blocks on high/critical).

Severity Location Finding
🟡 medium templates/scripts/review-bot-watch.sh:451 Unchanged human review threads trigger the fix provider on every poll cycle.

Provider codex · commit aa8f085 · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread templates/scripts/review-bot-watch.sh Outdated
# Even when the SHA hasn't changed, auto-respond handles any new review
# comments that arrived since the last dispatch.
if [[ "$AUTO_RESPOND" == "1" ]]; then
try_respond_pr "$pr" "$head_branch"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · performance

Unchanged human review threads trigger the fix provider on every poll cycle. prRespond deliberately leaves human threads unresolved, but fetchBlockingThreads keeps selecting them after a fix and reply. With auto-respond enabled, waiting for reviewer confirmation repeatedly invokes the provider and can produce duplicate replies or additional commits without new reviewer input.

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

1 finding(s) — 🟡 1 medium

✅ Merge gate: pass — no blocking findings (blocks on high/critical).

Severity Location Finding
🟡 medium test/pr-respond.test.js:814 The successful auto-fix workflow has no test coverage. These tests stop at refusal, dry-run, or no-threads paths, so they never exercise

Provider codex · commit aa8f085 · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread test/pr-respond.test.js
cp.spawnSync('git', ['-C', repoDir, 'worktree', 'remove', wtDir, '--force'], {
encoding: 'utf8'
});
});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · tests

The successful auto-fix workflow has no test coverage. These tests stop at refusal, dry-run, or no-threads paths, so they never exercise committing, pushing, re-reviewing, or the conditions that automatically resolve GitHub threads. A regression could silently resolve an unfixed blocking thread without failing this suite.

Why this matters

Add workflow tests covering successful resolution, failed re-review, remaining blocking findings, untouched files, and human threads remaining unresolved.

…d re-processing on every poll

fetchBlockingThreads was re-queuing human reviewer threads on every poll cycle
because prRespond deliberately leaves them unresolved (human must confirm). This
caused duplicate replies and spurious fix commits on subsequent auto-respond runs.

Fix: add latestComment:comments(last:1) alias to the GraphQL thread query so we
can inspect the most recent comment without fetching the full thread. When the
latest comment is from the trusted bot author and contains MARKER, the thread is
skipped — the bot already replied and is waiting for the reviewer. When the
reviewer replies after the bot, the latest comment flips back to the reviewer and
the thread is re-queued normally.

Tests: 36/36 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

5 finding(s) — 🟠 2 high · 🟡 1 medium · 🔵 2 low

⛔ Merge gate: blocked — 2 blocking finding(s) (blocks on high/critical).

Severity Location Finding
🟠 high src/pr-respond.js:193 Any GitHub user who submits a CHANGES_REQUESTED review is treated as a trusted human reviewer, so their free-text comment is fed straight
🟠 high templates/scripts/review-bot-watch.sh:451 Auto-respond runs on every poll cycle even when the head SHA is unchanged, so the same still-open threads are re-fixed over and over.
🟡 medium test/pr-respond.test.js:883 The riskiest part of prRespond has no tests: the post-fix path (fixed, push-failed, the re-review gating, and the reply/resolve loop).
🔵 low src/pr-respond.js:26-28 readProtectedBranches and FINDING_MARKER_PREFIX are imported but never used.
🔵 low src/finish/review-gate.js:606 The BEHIND hint hardcodes 'gx pr sync', while the DIRTY hint right above it uses ${TOOL_NAME}.

Provider claude · commit d015626 · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js
const state = String(review?.state || '');
// COMMENTED and PENDING do not represent a review decision — skip them so a
// comment-only interaction cannot clear an active CHANGES_REQUESTED state.
if (login && state && state !== 'COMMENTED' && state !== 'PENDING') {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · security

Any GitHub user who submits a CHANGES_REQUESTED review is treated as a trusted human reviewer, so their free-text comment is fed straight into the fix provider's prompt. The provider's output is then committed and pushed automatically. On a public repo, non-collaborators can leave CHANGES_REQUESTED reviews, and with GUARDEX_REVIEW_AUTO_RESPOND=1 the watch loop runs this unattended.

Why this matters

That is a prompt-injection path to arbitrary code being written and pushed to agent/* branches. Fix: add authorAssociation to the reviews{nodes{...}} selection, and only add a reviewer to latestStateByReviewer when the association is OWNER, MEMBER or COLLABORATOR, or when the reviewer has write permission.

Comment thread templates/scripts/review-bot-watch.sh Outdated
# Even when the SHA hasn't changed, auto-respond handles any new review
# comments that arrived since the last dispatch.
if [[ "$AUTO_RESPOND" == "1" ]]; then
try_respond_pr "$pr" "$head_branch"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · correctness

Auto-respond runs on every poll cycle even when the head SHA is unchanged, so the same still-open threads are re-fixed over and over. Several outcomes leave threads open and eligible again: provider no-op, a thread whose path was not in changedFiles, re-review still flagging the file, a failed resolve, and bot threads (which, unlike human threads, have no 'already replied' check).

Why this matters

Each repeat costs a provider run, and when it does change something it adds a push and a pr-review --post. That push produces a new SHA, so on the next cycle process_one_pr reviews and posts again, duplicating the re-review respond already posted. Without persisted per-thread or per-SHA attempt state, this can loop indefinitely, burning provider quota and piling up commits and review threads. Fix: record which thread IDs and head SHA were last attempted in the state file, and skip them until the SHA or the thread's latest comment changes.

Comment thread src/pr-respond.js Outdated
Comment on lines +26 to +28
const { gitRun, readProtectedBranches, currentBranchName } = require('./git');
const { runReviewFix } = require('./review-fix');
const { runPrReview, MARKER, FINDING_MARKER_PREFIX } = require('./pr-review');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note

🔵 LOW · maintainability

readProtectedBranches and FINDING_MARKER_PREFIX are imported but never used. They will fail a no-unused-vars lint rule and suggest a protected-branch check that does not actually happen.

Suggested change
const { gitRun, readProtectedBranches, currentBranchName } = require('./git');
const { runReviewFix } = require('./review-fix');
const { runPrReview, MARKER, FINDING_MARKER_PREFIX } = require('./pr-review');
const { gitRun, currentBranchName } = require('./git');
const { runReviewFix } = require('./review-fix');
const { runPrReview, MARKER } = require('./pr-review');

Comment thread src/finish/review-gate.js Outdated
const mss = (ci.pr && ci.pr.mergeStateStatus) || 'BLOCKED';
const mssHints = {
DIRTY: `resolve conflicts first (${TOOL_NAME} pr sync, or merge the base branch)`,
BEHIND: 'update the branch to head of base (gx pr sync)',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note

🔵 LOW · maintainability

The BEHIND hint hardcodes 'gx pr sync', while the DIRTY hint right above it uses ${TOOL_NAME}. The two will drift if the CLI name differs or changes.

Suggested change
BEHIND: 'update the branch to head of base (gx pr sync)',
BEHIND: `update the branch to head of base (${TOOL_NAME} pr sync)`,

Comment thread test/pr-respond.test.js
cp.spawnSync('git', ['-C', repoDir, 'worktree', 'remove', wtDir, '--force'], {
encoding: 'utf8'
});
});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · tests

The riskiest part of prRespond has no tests: the post-fix path (fixed, push-failed, the re-review gating, and the reply/resolve loop). Nothing checks that human threads are replied to but never resolved. Nothing checks that threads are left open when re-review throws or still reports a blocking finding on the same path.

Why this matters

Nothing checks that threads on files outside changedFiles are skipped, or that a push failure returns fixSha. The local-HEAD vs headRefOid staleness refusal is also untested. Add runner/deps-injected tests that stub runReviewFix/runPrReview results and assert on resolveThread/replyToThread calls.

… — prompt-injection label, auto-respond backoff, unused imports, BEHIND hint

HIGH: threadToFinding now prefixes human reviewer message bodies with "reviewer: "
so the fix provider treats them as data rather than instructions, preventing
prompt-injection via CHANGES_REQUESTED review comments.

HIGH: review-bot-watch.sh try_respond_pr now accepts a `current_sha` arg and
maintains LAST_RESPOND_SHA / RESPOND_SKIP_REMAINING per PR. After a successful
respond call, the next RESPOND_BACKOFF_POLLS cycles (default 4) are skipped when
the SHA is unchanged — avoiding repeated fix-provider invocations for threads
that are already handled. Backoff resets immediately on SHA change or failure.

LOW: removed unused imports `readProtectedBranches` and `FINDING_MARKER_PREFIX`
from src/pr-respond.js.

LOW: fixed hardcoded "gx pr sync" in BEHIND hint in src/finish/review-gate.js
to use the ${TOOL_NAME} constant, matching the DIRTY hint style.

Biome formatting applied to all changed JS files (format:check:changed).

Tests: 36/36 pr-respond + 62/62 review-gate pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

3 finding(s) — 🟠 1 high · 🟡 2 medium

⛔ Merge gate: blocked — 1 blocking finding(s) (blocks on high/critical).

Severity Location Finding
🟠 high src/pr-respond.js:198 Any GitHub user who submits a CHANGES_REQUESTED review can make the auto-fix provider edit, commit and push to the agent branch.
🟡 medium templates/scripts/review-bot-watch.sh:431-435 Any failure of pr respond clears the backoff, so permanent refusals and provider failures re-run on every poll.
🟡 medium test/pr-respond.test.js:11 The riskiest part of prRespond has no tests: the post-fix reply/resolve path and the HEAD-staleness refusal.

Provider claude · commit 1e186ca · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js
}
}
// Only treat a reviewer as "blocking" when their most recent review is CHANGES_REQUESTED.
const humanReviewers = new Set(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Warning

🟠 HIGH · security

Any GitHub user who submits a CHANGES_REQUESTED review can make the auto-fix provider edit, commit and push to the agent branch. humanReviewers is built from every review author, with no check of the reviewer's relationship to the repository. On a public repo, anyone with read access can submit a 'Request changes' review.

Why this matters

Their inline comment body then becomes a 'high' finding that drives the codex/claude fix provider in the local worktree, and the result is pushed to origin. This also runs unattended every poll when GUARDEX_REVIEW_AUTO_RESPOND=1. The reviewer: prefix in threadToFinding is not a real boundary against prompt injection. Add authorAssociation to the reviews selection in the query (line 133). Then only count reviewers whose association is OWNER, MEMBER or COLLABORATOR (or who have write permission) before treating their threads as actionable.

Comment on lines +431 to +435
else
echo "[review-bot-watch] respond: PR #${pr} skipped or failed (exit=${respond_exit})" >&2
# Clear backoff on failure so we retry next cycle.
RESPOND_SKIP_REMAINING["$pr"]=0
fi

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · performance

Any failure of pr respond clears the backoff, so permanent refusals and provider failures re-run on every poll. Exit 1 covers more than transient errors. It also covers 'skipped' (no local worktree, dirty worktree, HEAD mismatch, more than 100 threads), 'fix-failed' and 'push-failed'. Because the failure branch resets RESPOND_SKIP_REMAINING to 0, each agent PR re-invokes gx pr respond every interval.

Why this matters

For 'fix-failed' that means paying for a full fix-provider run every 5+ seconds on the same unchanged SHA. Arm the same SHA-keyed backoff on failure. A new push still resets it immediately.

Suggested change
else
echo "[review-bot-watch] respond: PR #${pr} skipped or failed (exit=${respond_exit})" >&2
# Clear backoff on failure so we retry next cycle.
RESPOND_SKIP_REMAINING["$pr"]=0
fi
else
echo "[review-bot-watch] respond: PR #${pr} skipped or failed (exit=${respond_exit})" >&2
# Back off on failure too: permanent refusals (no worktree, fork, dirty tree)
# and provider failures would otherwise re-invoke the fix provider every cycle.
# A new head SHA still resets the backoff immediately.
if [[ -n "$current_sha" ]]; then
LAST_RESPOND_SHA["$pr"]="$current_sha"
RESPOND_SKIP_REMAINING["$pr"]="$RESPOND_BACKOFF_POLLS"
fi
fi

Comment thread test/pr-respond.test.js
seedCommit
} = require('./helpers/install-test-helpers');
const cp = require('node:child_process');
const prRespond = require('../src/pr-respond');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · tests

The riskiest part of prRespond has no tests: the post-fix reply/resolve path and the HEAD-staleness refusal. The suite covers parsing, filtering, refusals, dry-run and no-threads.

Why this matters

It never exercises the 'fixed' branch, so nothing pins these behaviours: a thread is resolved only when its file changed and the re-review reports no blocking finding on that path; all threads stay open when the re-review throws; human threads get a reply but are never resolved; push-failed returns fixSha. The headRefOid mismatch refusal is also untested. A regression in any of these would silently resolve unfixed threads or push without verification. Add prRespond tests that inject runReviewFix / runPrReview via deps and a runner that records the reply and resolve mutations.

…rators (authorAssociation)

On public repositories any authenticated GitHub user can submit a CHANGES_REQUESTED
review, which would previously allow them to trigger the fix provider. This commit
adds a `TRUSTED_REVIEW_ASSOCIATIONS` guard (OWNER/MEMBER/COLLABORATOR): a reviewer
is only added to `humanReviewers` when (a) their latest review decision is
CHANGES_REQUESTED AND (b) at least one of their reviews carried a trusted association.

External contributors (NONE, CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, FIRST_TIMER,
MANNEQUIN) are excluded even when they submit a formal CHANGES_REQUESTED review.

Implementation:
- Added `TRUSTED_REVIEW_ASSOCIATIONS` constant
- GraphQL query now fetches `authorAssociation` on each review node
- Review loop tracks trusted associations separately in `trustedReviewerAssocs`
- `humanReviewers` filter now requires both CHANGES_REQUESTED AND a trusted assoc

Tests: added 'excludes human threads from external contributors (NONE/CONTRIBUTOR
association)'; updated all existing human-reviewer fixtures with explicit
authorAssociation. 37/37 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

6 finding(s) — 🟡 5 medium · 🔵 1 low

✅ Merge gate: pass — no blocking findings (blocks on high/critical).

Severity Location Finding
🟡 medium src/pr-respond.js:652 Threads are resolved even when the re-review was never posted to the PR.
🟡 medium src/pr-respond.js:614 The re-review runs right after git push, without waiting for GitHub's PR head to reach the fix commit.
🟡 medium src/pr-respond.js:305-307 Follow-up comments on human reviewer threads never reach the fix provider.
🟡 medium templates/scripts/review-bot-watch.sh:433-434 Every failed respond attempt clears the backoff, so persistent failures re-run on every poll cycle.
🟡 medium templates/scripts/review-bot-watch.sh:412-415 Auto-respond has no cap on fix rounds per PR. Each successful respond pushes a new SHA.
🔵 low src/cli/commands/pr.js:470 The skipped-thread summary misstates why threads were left open.

Provider claude · commit 2e377a1 · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment thread src/pr-respond.js
skippedReply.push(thread.id);
continue;
}
if (!reReviewResult || !Array.isArray(reReviewResult.findings)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

Threads are resolved even when the re-review was never posted to the PR. Only a thrown error (null result) or a missing findings array keeps threads open. If runPrReview returns posted: false (for example, GitHub auth was unavailable) or a provider returns an empty findings array, the bot still replies and resolves threads with no published evidence that the fixed diff was examined.

Why this matters

review-gate.js explicitly refuses this case in requirePostedReview, because an unposted or empty review looks exactly like a clean pass.

Suggested change
if (!reReviewResult || !Array.isArray(reReviewResult.findings)) {
if (!reReviewResult || !reReviewResult.posted || !Array.isArray(reReviewResult.findings)) {

Comment thread src/pr-respond.js
// 7. Re-run pr-review with --post so the updated diff is reviewed again.
let reReviewResult;
try {
reReviewResult = runPrReview(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

The re-review runs right after git push, without waiting for GitHub's PR head to reach the fix commit. GitHub updates headRefOid asynchronously, so runPrReview({ pr }) can fetch and post a review of the pre-fix diff. That stale review then drives thread resolution. Real fixes may be reported as still blocking, and a stale clean verdict can resolve threads.

Why this matters

review-gate.js guards this same transition with waitForPullRequestHead after every push. Call it, or an equivalent poll against fixSha, before re-reviewing, and treat a timeout as 'leave threads open'.

Comment thread src/pr-respond.js
Comment on lines +305 to +307
const rawMessage = isBot
? messageFromBody(body)
: `reviewer: ${body.replace(/\s+/g, ' ').trim()}`;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

Follow-up comments on human reviewer threads never reach the fix provider. fetchBlockingThreads re-queues a human thread when the reviewer replies after the bot (for example, 'Actually, also handle the timeout case'), but threadToFinding builds the message only from comments.nodes[0]. The provider re-applies the original request and ignores the follow-up.

Why this matters

The bot then replies 'Applied fix' and stops re-queuing, which buries the reviewer's actual new request. Include the latest comment when its author is the same trusted reviewer. Do not include it for arbitrary repliers, so untrusted text stays out of the prompt.

Comment thread templates/scripts/review-bot-watch.sh Outdated
Comment on lines +433 to +434
# Clear backoff on failure so we retry next cycle.
RESPOND_SKIP_REMAINING["$pr"]=0

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · performance

Every failed respond attempt clears the backoff, so persistent failures re-run on every poll cycle. Respond exits 1 for deterministic refusals: no local worktree (common for agent PRs created on another machine), dirty worktree, HEAD mismatch, more than 50 reviews or more than 100 threads. It also exits 1 for fix-failed.

Why this matters

With the backoff cleared, the watcher repeats several gh/GraphQL calls per PR per interval indefinitely. On fix-failed it re-invokes the paid fix provider every cycle on the same SHA. Arm the backoff on failure too. A new SHA still resets it through the else branch above.

Suggested change
# Clear backoff on failure so we retry next cycle.
RESPOND_SKIP_REMAINING["$pr"]=0
# Back off on failure too: deterministic refusals (no local worktree, dirty
# worktree) and provider failures would otherwise re-run every poll. A new
# head SHA still resets the backoff above.
RESPOND_SKIP_REMAINING["$pr"]="$RESPOND_BACKOFF_POLLS"

Comment on lines +412 to +415
else
# SHA changed (or first run): reset backoff for this PR.
LAST_RESPOND_SHA["$pr"]="${current_sha:-}"
RESPOND_SKIP_REMAINING["$pr"]=0

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

Auto-respond has no cap on fix rounds per PR. Each successful respond pushes a new SHA. The next poll sees a changed SHA, resets the backoff, re-reviews via process_one_pr, and responds again. If the provider keeps producing fixes that the review still flags, the loop runs unbounded, with provider cost and push noise on every cycle. The review gate bounds this with maxFixRounds.

Why this matters

Track a per-PR respond-round counter here and stop after a configurable limit (for example, GUARDEX_RESPOND_MAX_ROUNDS).

Comment thread src/cli/commands/pr.js
`PR #${opts.pr}: fixed ${result.changedFiles.length} file(s), pushed ${result.fixSha ? result.fixSha.slice(0, 8) : ''}, resolved ${result.resolved.length}/${result.threads.length} thread(s).`,
);
if (result.skippedReply.length > 0) {
logInfo(` ${result.skippedReply.length} thread(s) untouched (path not in changed files).`);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note

🔵 LOW · maintainability

The skipped-thread summary misstates why threads were left open. skippedReply also includes threads whose file changed but the re-review was unavailable or still blocking, and human-reviewer threads that were replied to but intentionally left unresolved. Labeling all of them 'untouched (path not in changed files)' misleads the operator about what happened.

Suggested change
logInfo(` ${result.skippedReply.length} thread(s) untouched (path not in changed files).`);
logInfo(` ${result.skippedReply.length} thread(s) left open (file unchanged, re-review unconfirmed/still blocking, or awaiting human reviewer).`);

…tead of clearing to zero

Clearing RESPOND_SKIP_REMAINING to 0 on every failure meant that permanent
failures (worktree absent, fork PR, non-agent branch) retried on every single poll
cycle — the same behaviour the backoff was introduced to prevent.

Use a 1-poll backoff on failure: transient errors (network blip, dirty worktree)
are retried on the next cycle, permanent errors back off by one poll each time
rather than hammering continuously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🛡️ GitGuardex code-assist

4 finding(s) — 🟡 3 medium · 🔵 1 low

✅ Merge gate: pass — no blocking findings (blocks on high/critical).

Severity Location Finding
🟡 medium templates/scripts/review-bot-watch.sh:388-391 declare -A runs at top level on every start, so the watcher crashes on bash 3.2 (macOS /bin/bash) even when auto-respond is off.
🟡 medium src/pr-respond.js:614-624 The re-review runs right after git push without waiting for GitHub to report the new PR head, so it can review and post against the stale
🟡 medium src/pr-respond.js:601 The 'fixed' path has no test coverage: push, re-review, reply, and the rules for resolving or skipping threads.
🔵 low src/cli/commands/pr.js:470 This summary line gives the wrong reason for most skipped threads.

Provider claude · commit 773cbbf · abide: skipped (no rubric (.abide/rubric.json); compile it first)

Comment on lines +388 to +391
# backoff resets immediately when the SHA changes or when respond exits non-zero
# (so a transient failure is retried on the next cycle).
declare -A LAST_RESPOND_SHA
declare -A RESPOND_SKIP_REMAINING

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

declare -A runs at top level on every start, so the watcher crashes on bash 3.2 (macOS /bin/bash) even when auto-respond is off. bash 3.2 rejects declare -A with "invalid option". Under the script's set -e, that error kills the watcher for every user, not only those who set GUARDEX_REVIEW_AUTO_RESPOND=1. PR numbers are integers, so plain indexed arrays work here and need no associative arrays.

Why this matters

The comment on lines 388-389 also says the backoff resets on a non-zero exit, but try_respond_pr actually arms a 1-poll backoff.

Suggested change
# backoff resets immediately when the SHA changes or when respond exits non-zero
# (so a transient failure is retried on the next cycle).
declare -A LAST_RESPOND_SHA
declare -A RESPOND_SKIP_REMAINING
# backoff resets immediately when the SHA changes; a non-zero respond exit arms a
# 1-poll mini-backoff so transient failures retry soon without hammering every cycle.
# PR numbers are integers, so plain indexed arrays work here and stay compatible
# with bash 3.2 (macOS /bin/bash), which has no `declare -A`.
LAST_RESPOND_SHA=()
RESPOND_SKIP_REMAINING=()

Comment thread src/pr-respond.js
Comment on lines +614 to +624
reReviewResult = runPrReview(
{
pr,
target: worktreePath,
provider: options.provider || 'codex',
model: options.model,
post: true,
timeoutMs: options.timeoutMs
},
deps
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · correctness

The re-review runs right after git push without waiting for GitHub to report the new PR head, so it can review and post against the stale pre-fix diff. review-gate.js waits with waitForPullRequestHead before re-reviewing for exactly this reason ("Refusing to re-review a stale PR diff"). Without that wait, the re-review can post duplicate findings for code that is already fixed.

Why this matters

Thread resolution on lines below is then decided by a review of the wrong commit, which can leave fixed threads open or resolve threads on the wrong evidence. Wait for the PR head to equal the pushed fix SHA, and treat a timeout like a failed re-review, before calling runPrReview.

Comment thread src/pr-respond.js
}

// fixResult.status === 'fixed'
const changedFiles = new Set(fixResult.changedFiles);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Important

🟡 MEDIUM · tests

The 'fixed' path has no test coverage: push, re-review, reply, and the rules for resolving or skipping threads. This is the riskiest logic in the PR. It decides when threads get resolved: a bot thread is resolved only if its file changed and the re-review has no blocking finding on that path. Human threads get a reply but are never resolved. A failed re-review leaves all threads open.

Why this matters

A push failure returns 'push-failed' with fixSha. test/pr-respond.test.js covers only the refusal, dry-run, and no-threads paths. Add runner/deps fakes for runReviewFix and runPrReview that assert which thread IDs land in resolved and which land in skippedReply.

Comment thread src/cli/commands/pr.js
`PR #${opts.pr}: fixed ${result.changedFiles.length} file(s), pushed ${result.fixSha ? result.fixSha.slice(0, 8) : ''}, resolved ${result.resolved.length}/${result.threads.length} thread(s).`,
);
if (result.skippedReply.length > 0) {
logInfo(` ${result.skippedReply.length} thread(s) untouched (path not in changed files).`);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note

🔵 LOW · maintainability

This summary line gives the wrong reason for most skipped threads. skippedReply also holds human-reviewer threads that were fixed and replied to, threads still flagged by the re-review, and threads left open because the re-review failed. Labelling them all "path not in changed files" sends the operator to the wrong cause.

Suggested change
logInfo(` ${result.skippedReply.length} thread(s) untouched (path not in changed files).`);
logInfo(` ${result.skippedReply.length} thread(s) left open (file not changed, re-review still blocking or failed, or awaiting human reviewer).`);

… fixtures)

Inline object literals added in the authorAssociation commit exceeded biome's
line limit. Formatted to keep CI's format:check:changed green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant