-
Notifications
You must be signed in to change notification settings - Fork 55
fix(#2432): retry merge on 409 after updating PR branch #2434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2057,13 +2057,41 @@ func (c *LiveClient) DismissPullRequestReview(ctx context.Context, owner, repo s | |
| } | ||
|
|
||
| // MergeChangeProposal squash-merges a pull request by number. | ||
| // If the merge fails with a 409 (head branch out of date), it updates the PR | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. range maxattempts invalid loop 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
|
||
| 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) | ||
| } | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Update-branch errors ignored 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
|
||
| } | ||
| resp.Body.Close() | ||
| return nil | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| 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) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
| } | ||
There was a problem hiding this comment.
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).