Skip to content

fix(gitprovider): skip GitLab approve when the caller's approval already stands - #564

Merged
piekstra merged 1 commit into
mainfrom
piekstra/gitlab-reapprove-401
Aug 10, 2026
Merged

fix(gitprovider): skip GitLab approve when the caller's approval already stands#564
piekstra merged 1 commit into
mainfrom
piekstra/gitlab-reapprove-401

Conversation

@piekstra

Copy link
Copy Markdown
Contributor

Why

GitLab's POST /projects/:id/merge_requests/:iid/approve responds 401 Unauthorized when the caller already holds a standing approval on the merge request. The GitLab provider mapped that 401 through the approve-specific ErrAuth -> ErrPermission path, so a rerun of an already-approved MR failed the whole run:

SubmitReview: gitprovider: permission denied: SubmitReview: gitprovider: authentication failed: gitlab: status 401 (response body redacted)

Outcome: failed, exit 3, posted=0 failed_terminal=1 — even though the review itself completed and the approval already stood. The message sends the operator down a credential-debugging path for a token that is fine.

What

SubmitReview's approve path now checks whether the current user's approval already stands before writing, and treats a standing approval as already satisfied:

  • GET .../merge_requests/:iid/approvals — the same endpoint ListReviews already consumes; if approved_by is empty the approve proceeds with no further reads.
  • Only when approvals exist, GET /user resolves the authenticated user; a match in approved_by skips the approve write and the summary note posts as usual.

A pre-check was chosen over tolerating 401 on the approve write because that status is genuinely ambiguous on this endpoint (GitLab also uses it for approval-permission restrictions), and blanket tolerance would mask real failures. Genuine credential 401s still surface as ErrAuth: every read in the same submission (merge request, approvals, /user) fails loudly with a real bad token, and the approve write's existing 401 -> ErrPermission mapping is unchanged for callers who are not allowed to approve.

Tests

  • TestSubmitReviewApproveSkipsApproveWhenApprovalAlreadyStands — standing approval by the current user: no approve call (the fake would 401 it), summary note still posts, run succeeds.
  • TestSubmitReviewApproveStillApprovesWhenOnlyOthersApproved — approvals by other users do not suppress the approve write.
  • TestSubmitReviewSurfacesAuthFailureFromApprovalsRead — a genuine 401 on a read still surfaces as ErrAuth with no writes attempted.
  • Existing approve/unapprove/comment/stale-SHA/conflict/401-permission coverage updated for the approvals pre-read and kept green.

make check green locally (lint 0 issues; the one failing test, llmadapters/TestPiRPCReviewerExtensionLoadsInInstalledPi, fails identically on clean main — it shells out to the locally installed pi, whose version predates --no-builtin-tools).

Fixes #563

…ady stands

GitLab's approve endpoint responds 401 when the authenticated user
already holds a standing approval on the merge request, which cr mapped
to a permission/authentication failure and failed the whole run even
though the review completed. SubmitReview now reads the merge request
approvals first and treats a standing approval by the current user as
already satisfied, so reruns post their summary note and succeed. A
genuine credential 401 still surfaces as an authentication failure via
the reads that precede the approve write.

@monit-reviewer monit-reviewer 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.

Automated PR Review

Reviewed commit: f4bdd1f7e8c5
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 0
security:code-auditor 0
architecture:solid-reviewer-agnostic 1
architecture:solid-reviewer-agnostic (1 finding)

Minor - internal/gitprovider/gitlab/rest_writes.go:118

currentUserApproved is a read-only helper (two GETs, no writes) placed in rest_writes.go, and it rebuilds the approvals endpoint URL that ListReviews already builds verbatim at internal/gitprovider/gitlab/rest_reads.go:342. Both cut against the package's own file/helper split (U-S1, U-G1): the read helper the write path already depends on, getMergeRequest, lives in rest_reads.go (line 147) and is called from SubmitReview, and repeated endpoint construction is factored into a named method (c.notesURL, c.discussionsURL in discussions.go:169) rather than inlined at each call site.

Impact is small but it is the kind of drift that compounds: the approvals path/segment ordering now exists in two files and will need editing in both if the endpoint or project-segment handling changes, and reads/writes are no longer cleanly separated by file.

Suggested fix: add func (c *Client) approvalsURL(ref gitprovider.PRRef) string next to notesURL, use it from both ListReviews and the new helper, and move currentUserApproved into rest_reads.go alongside getMergeRequest (keeping the write-path decision — call it, and skip on true — in approve). No behavior change; the existing tests cover it.

The rest of the change reads well: the pre-check keeps approve's error mapping intact, the 401-vs-standing-approval ambiguity is documented at the call site, the extra /user read is gated on approvals being non-empty, and all three branches (standing approval, others' approvals, genuine 401 on the read) have direct tests.

Reviewer Coverage

  • go:implementation-tests — complete (broad); skipped: none; constraints: none
  • security:code-auditor — complete (broad); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (broad); inspected 2 assigned files (3 inspected across reviewers): internal/gitprovider/gitlab/rest_reads.go, internal/gitprovider/gitlab/rest_writes.go; skipped: none; constraints: Ran go test ./internal/gitprovider/gitlab/: all SubmitReview tests pass. TestProductionImportsStayInAdapterLayer fails, but only because the workbench checkout path contains spaces and the test splits go list output on whitespace — environmental, not diff-caused. Scope limited to the two assigned files; internal/gitprovider/gitlab/rest_writes_test.go was read as evidence for U-T1 but not reviewed as an assigned file.
Inspected files (3)
  • internal/gitprovider/gitlab/rest_reads.go
  • internal/gitprovider/gitlab/rest_writes.go
  • internal/gitprovider/gitlab/rest_writes_test.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 4m 42s | ~$3.40 (est.) | claude-sonnet-5, claude-opus-5 | cr 0.10.285
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, security:code-auditor, architecture:solid-reviewer-agnostic
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 4m 42s wall · 7m 19s compute
Cost ~$3.40 (est.)
Tokens 90 in / 22.3k out

Per-workstream usage

  • orchestrator-selection — claude-sonnet-5
    • In: 4
    • Out: 2.8k
    • Cache read: 31.2k
    • Cache create: 74.1k
    • Cost: ~$0.33 (est.)
    • Duration: 44s
  • go:implementation-tests — claude-sonnet-5
    • In: 26
    • Out: 5.3k
    • Cache read: 898.7k
    • Cache create: 91.0k
    • Cost: ~$0.69 (est.)
    • Duration: 1m 41s
  • security:code-auditor — claude-sonnet-5
    • In: 24
    • Out: 2.8k
    • Cache read: 747.6k
    • Cache create: 82.9k
    • Cost: ~$0.58 (est.)
    • Duration: 1m 06s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 30
    • Out: 10.8k
    • Cache read: 1.0M
    • Cache create: 98.9k
    • Cost: ~$1.41 (est.)
    • Duration: 3m 32s
  • orchestrator-rollup — claude-sonnet-5
    • In: 6
    • Out: 474
    • Cache read: 104.5k
    • Cache create: 94.2k
    • Cost: ~$0.39 (est.)
    • Duration: 15s

// standing approval on the merge request. The current-user lookup only happens
// when the merge request has approvals at all, so the common fresh-approve
// path costs one extra read.
func (c *Client) currentUserApproved(ctx context.Context, op gitprovider.Operation, ref gitprovider.PRRef) (bool, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

currentUserApproved is a read-only helper (two GETs, no writes) placed in rest_writes.go, and it rebuilds the approvals endpoint URL that ListReviews already builds verbatim at internal/gitprovider/gitlab/rest_reads.go:342. Both cut against the package's own file/helper split (U-S1, U-G1): the read helper the write path already depends on, getMergeRequest, lives in rest_reads.go (line 147) and is called from SubmitReview, and repeated endpoint construction is factored into a named method (c.notesURL, c.discussionsURL in discussions.go:169) rather than inlined at each call site.

Impact is small but it is the kind of drift that compounds: the approvals path/segment ordering now exists in two files and will need editing in both if the endpoint or project-segment handling changes, and reads/writes are no longer cleanly separated by file.

Suggested fix: add func (c *Client) approvalsURL(ref gitprovider.PRRef) string next to notesURL, use it from both ListReviews and the new helper, and move currentUserApproved into rest_reads.go alongside getMergeRequest (keeping the write-path decision — call it, and skip on true — in approve). No behavior change; the existing tests cover it.

The rest of the change reads well: the pre-check keeps approve's error mapping intact, the 401-vs-standing-approval ambiguity is documented at the call site, the extra /user read is gated on approvals being non-empty, and all three branches (standing approval, others' approvals, genuine 401 on the read) have direct tests.

Reply inline to this comment.

@piekstra
piekstra marked this pull request as ready for review August 10, 2026 20:42
@piekstra
piekstra merged commit 5851512 into main Aug 10, 2026
10 checks passed
@piekstra
piekstra deleted the piekstra/gitlab-reapprove-401 branch August 10, 2026 20:42
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.

GitLab: re-approving an already-approved MR returns 401 and is misreported as authentication failure

2 participants