Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions internal/forge/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -2057,13 +2057,41 @@ func (c *LiveClient) DismissPullRequestReview(ctx context.Context, owner, repo s
}

// MergeChangeProposal squash-merges a pull request by number.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] doc-style

Function comment documents retry behavior but omits the delay duration (3 seconds) and total potential delay (~9s). Compare with retryOnTransient which documents timing details.

Suggested fix: Add timing details to the comment: 3-second delay, up to 3 retries (~9s total).

// If the merge fails with a 409 (head branch out of date), it updates the PR

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] doc-style

The function comment documents the retry behavior but omits the delay duration (3 seconds) and total potential delay (~9s). Compare with retryOnRepoRace which documents timing details.

// branch and retries up to 3 times with a short delay between attempts.
func (c *LiveClient) MergeChangeProposal(ctx context.Context, owner, repo string, number int) error {
resp, err := c.put(ctx, fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, number), map[string]string{"merge_method": "squash"})
if err != nil {
return fmt.Errorf("merge pull request #%d: %w", number, err)
const maxAttempts = 3
mergePath := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, number)
updatePath := fmt.Sprintf("/repos/%s/%s/pulls/%d/update-branch", owner, repo, number)

for attempt := range maxAttempts {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. range maxattempts invalid loop 📘 Rule violation ≡ Correctness

MergeChangeProposal uses for attempt := range maxAttempts, which is invalid Go syntax and will
fail compilation. This will cause make go-vet (and therefore linting in CI) to fail for this PR.
Agent Prompt
## Issue description
`MergeChangeProposal` contains an invalid Go loop: `for attempt := range maxAttempts { ... }`, which will not compile.

## Issue Context
This change is intended to retry merge attempts up to `maxAttempts` times, so the loop should iterate `attempt` from 0 to `maxAttempts-1`.

## Fix Focus Areas
- internal/forge/github/github.go[2060-2091]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

resp, err := c.put(ctx, mergePath, map[string]string{"merge_method": "squash"})
if err == nil {
resp.Body.Close()
return nil
}

var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusConflict {
return fmt.Errorf("merge pull request #%d: %w", number, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] error-handling-gap

The update-branch call result is never inspected for success. Since c.do() returns (resp, nil) for all non-retryable HTTP responses (including 4xx errors), the code only closes the body but never checks updateResp.StatusCode. If the update-branch endpoint returns 403, 422, or any other error status, the code silently proceeds to sleep and retry the merge. The existing UpdatePullRequestBranch method demonstrates the correct pattern: checkStatus(resp, http.StatusAccepted).

Suggested fix: After the c.do call, check updateResp.StatusCode for non-success codes (the endpoint returns 202 on success). Include update-branch failure context in the final error message.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] edge-case

On the final loop iteration (attempt == maxAttempts-1), when the merge fails with 409, the code still calls update-branch even though the loop will not execute another merge attempt. This is a wasted API call.

Suggested fix: Guard the update-branch call with if attempt < maxAttempts-1.

// Update the PR branch to incorporate base branch changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] error-handling-gap

The update-branch call's error is silently discarded. If c.do() returns an error (network failure, context cancellation, rate limit exhaustion), the code proceeds to sleep and retry the merge with no indication that the update failed. When all retries are exhausted, the final error message says 'branch remained out of date' with no context about update-branch failures.

Suggested fix: At minimum, log the update error. Consider including the last update-branch error in the final returned error message when all retries are exhausted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] edge-case

On the final loop iteration (attempt == maxAttempts-1), when the merge fails with 409, the code still calls update-branch even though the loop will not execute another merge attempt. Wasted work and unnecessary API call.

Suggested fix: Move the update-branch call inside the if attempt < maxAttempts-1 block.

updateResp, updateErr := c.do(ctx, http.MethodPut, updatePath, map[string]string{})
if updateErr == nil {
updateResp.Body.Close()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] pattern-inconsistency

Body close is conditional (if updateErr == nil). The safer defensive pattern is if updateResp != nil { updateResp.Body.Close() }.


if attempt < maxAttempts-1 {
select {
case <-time.After(3 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
Comment on lines +2079 to +2091

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Update-branch errors ignored 🐞 Bug ≡ Correctness

MergeChangeProposal calls the PR update-branch endpoint via do() but never checks the HTTP
status code nor returns updateErr, so it can keep retrying merges even when the branch update was
rejected/rate-limited and ultimately return a misleading “branch remained out of date” error. It
also performs an update-branch call on the final 409 attempt even though no subsequent merge retry
will occur.
Agent Prompt
## Issue description
`MergeChangeProposal` uses `c.do()` for `PUT .../update-branch` and ignores both (a) non-2xx HTTP status codes and (b) `updateErr`. Since `do()` does not treat non-2xx as errors, the code can proceed as if the branch update succeeded when it actually failed, and it may perform an unnecessary final `update-branch` call even when no further merge retry will happen.

## Issue Context
- `do()` returns `(*http.Response, nil)` for non-2xx responses; status validation must be done via `checkStatus` (or an equivalent helper).
- `update-branch` commonly returns `202 Accepted`, so the status checker should accept 202.

## Fix Focus Areas
- internal/forge/github/github.go[2059-2092]
- internal/forge/github/github.go[95-165]
- internal/forge/github/github.go[216-236]

## Suggested fix approach
1. Only call `update-branch` when `attempt < maxAttempts-1` (i.e., when a retry will actually occur).
2. After `updateResp, updateErr := c.do(...)`:
   - If `updateErr != nil`, return a wrapped error (e.g., `fmt.Errorf("update pull request #%d branch: %w", number, updateErr)`).
   - Otherwise `defer updateResp.Body.Close()` and validate status with `checkStatus(updateResp, http.StatusAccepted, http.StatusOK, http.StatusNoContent)` (whatever is correct for your usage).
   - If status validation fails, return a wrapped error so the caller sees the real cause.
3. Consider preserving the last merge 409 `err` and including it in the final failure message for better diagnostics (optional but helpful).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
resp.Body.Close()
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] error-message-format

The final error on retry exhaustion uses fmt.Errorf without %w, so it does not wrap the last merge error. This prevents callers from using errors.As/errors.Is on the result. Compare with retryOnRepoRace which wraps the last error with %w.

Suggested fix: Capture the last merge error and wrap it with %w so callers can inspect the underlying APIError.

return fmt.Errorf("merge pull request #%d: branch remained out of date after %d update-and-retry attempts", number, maxAttempts)
}

// UpdatePullRequestBranch updates a PR's head branch by merging the base
Expand Down
121 changes: 121 additions & 0 deletions internal/forge/github/github_merge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package github

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMergeChangeProposal_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPut, r.Method)
assert.Equal(t, "/repos/org/repo/pulls/42/merge", r.URL.Path)

var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "squash", body["merge_method"])

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"sha": "abc123"})
}))
defer srv.Close()

client := newTestClient(t, srv)
err := client.MergeChangeProposal(context.Background(), "org", "repo", 42)
require.NoError(t, err)
}

func TestMergeChangeProposal_409UpdatesBranchAndRetries(t *testing.T) {
var mergeAttempts atomic.Int32
var updateCalls atomic.Int32

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPut && r.URL.Path == "/repos/org/repo/pulls/7/merge":
attempt := mergeAttempts.Add(1)
if attempt == 1 {
// First merge attempt: 409 conflict.
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{
"message": "Head branch is out of date",
})
return
}
// Second merge attempt: success.
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"sha": "def456"})

case r.Method == http.MethodPut && r.URL.Path == "/repos/org/repo/pulls/7/update-branch":
updateCalls.Add(1)
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{"message": "Updating pull request branch."})

default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()

client := newTestClient(t, srv)
err := client.MergeChangeProposal(context.Background(), "org", "repo", 7)
require.NoError(t, err)
assert.Equal(t, int32(2), mergeAttempts.Load(), "should have attempted merge twice")
assert.Equal(t, int32(1), updateCalls.Load(), "should have called update-branch once")
}

func TestMergeChangeProposal_NonConflictErrorNotRetried(t *testing.T) {
var mergeAttempts atomic.Int32

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mergeAttempts.Add(1)
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]string{
"message": "Pull Request is not mergeable",
})
}))
defer srv.Close()

client := newTestClient(t, srv)
err := client.MergeChangeProposal(context.Background(), "org", "repo", 7)
require.Error(t, err)
assert.Contains(t, err.Error(), "not mergeable")
assert.Equal(t, int32(1), mergeAttempts.Load(), "should not retry non-409 errors")
}

func TestMergeChangeProposal_409PersistsAfterRetries(t *testing.T) {
var mergeAttempts atomic.Int32

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPut && r.URL.Path == "/repos/org/repo/pulls/7/merge":
mergeAttempts.Add(1)
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{
"message": "Head branch is out of date",
})

case r.Method == http.MethodPut && r.URL.Path == "/repos/org/repo/pulls/7/update-branch":
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{"message": "Updating pull request branch."})

default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] test-inadequate

TestMergeChangeProposal_409PersistsAfterRetries asserts mergeAttempts.Load() > 1 but the contract is exactly 3 attempts. A weaker assertion would pass even if retry logic changed to only retry once.

Suggested fix: Use assert.Equal(t, int32(3), mergeAttempts.Load()) for precise verification.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] test-inadequate

TestMergeChangeProposal_409PersistsAfterRetries asserts mergeAttempts.Load() > 1 but the contract is exactly 3 attempts (maxAttempts). A weaker assertion would still pass if the retry logic were accidentally changed.

Suggested fix: Use assert.Equal(t, int32(3), mergeAttempts.Load()).

}))
defer srv.Close()

client := newTestClient(t, srv)
err := client.MergeChangeProposal(context.Background(), "org", "repo", 7)
require.Error(t, err)
assert.Contains(t, err.Error(), "merge pull request #7")
// Should have tried multiple times before giving up.
assert.Greater(t, mergeAttempts.Load(), int32(1), "should have retried merge")
}
Loading