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
9 changes: 9 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

### Typed merge errors

- Changed stable merge conflict and guard 409 responses to return
`RefUpdateError` in the TypeScript, Python, and Go SDKs.
- Added typed conflict paths, merge base, guard, expected SHA, and actual SHA
fields. Unknown 409 codes and non-409 failures remain API errors.
- This changes merge error behavior and requires a coordinated non-patch SDK
release.

### Ephemeral merge previews

- Added source and target ephemeral namespace flags to merge previews in the TypeScript, Python, and Go SDKs.
Expand Down
13 changes: 13 additions & 0 deletions packages/code-storage-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,24 @@ result, err := repo.Merge(context.Background(), storage.MergeOptions{
Author: &storage.CommitSignature{Name: "Merge Bot", Email: "merge@example.com"},
})
if err != nil {
if refErr, ok := err.(*storage.RefUpdateError); ok {
switch {
case refErr.Reason == storage.RefUpdateReasonConflict:
fmt.Println(refErr.ConflictPaths, refErr.MergeBaseSHA)
case refErr.Reason == storage.RefUpdateReasonPreconditionFailed && refErr.Guard == storage.MergeGuardTarget:
fmt.Println("Target moved", refErr.ExpectedSHA, refErr.ActualSHA)
case refErr.Reason == storage.RefUpdateReasonPreconditionFailed && refErr.Guard == storage.MergeGuardSource:
fmt.Println("Source moved", refErr.ExpectedSHA, refErr.ActualSHA)
}
}
log.Fatal(err)
}
fmt.Println(result.Result, result.Target.NewSHA)
```

Known merge 409 responses return `*RefUpdateError`. An unknown merge 409 code
and every non-409 merge failure return `*APIError`.

### Create a commit

```go
Expand Down
103 changes: 99 additions & 4 deletions packages/code-storage-go/errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package storage

import (
"net/http"
"strings"
)

Expand Down Expand Up @@ -35,12 +36,106 @@ const (
RefUpdateReasonUnknown RefUpdateReason = "unknown"
)

// MergeGuard identifies the ref protected by a failed merge guard.
type MergeGuard string

const (
MergeGuardTarget MergeGuard = "target"
MergeGuardSource MergeGuard = "source"
)

// RefUpdateError describes failed ref updates.
type RefUpdateError struct {
Message string
Status string
Reason RefUpdateReason
RefUpdate *RefUpdate
Message string
Status string
Reason RefUpdateReason
RefUpdate *RefUpdate
Guard MergeGuard
ExpectedSHA string
ActualSHA string
ConflictPaths []string
MergeBaseSHA string
}

func parseMergeRefUpdateError(apiErr *APIError) *RefUpdateError {
if apiErr == nil || apiErr.Status != http.StatusConflict {
return nil
}
body, ok := apiErr.Body.(map[string]interface{})
if !ok {
return nil
}
code, ok := body["code"].(string)
if !ok {
return nil
}

switch code {
case "merge_conflict":
paths, ok := mergeErrorStringSlice(body, "conflict_paths")
if !ok {
return nil
}
mergeBaseSHA, ok := mergeErrorOptionalString(body, "merge_base_sha")
if !ok {
return nil
}
return &RefUpdateError{
Message: apiErr.Message,
Status: code,
Reason: RefUpdateReasonConflict,
ConflictPaths: paths,
MergeBaseSHA: mergeBaseSHA,
}
case "precondition_failed":
guard, ok := body["guard"].(string)
if !ok || (guard != string(MergeGuardTarget) && guard != string(MergeGuardSource)) {
return nil
}
expectedSHA, expectedOK := body["expected_sha"].(string)
actualSHA, actualOK := body["actual_sha"].(string)
if !expectedOK || !actualOK {
return nil
}
return &RefUpdateError{
Message: apiErr.Message,
Status: code,
Reason: RefUpdateReasonPreconditionFailed,
Guard: MergeGuard(guard),
ExpectedSHA: expectedSHA,
ActualSHA: actualSHA,
}
default:
return nil
}
}

func mergeErrorStringSlice(body map[string]interface{}, key string) ([]string, bool) {
value, exists := body[key]
if !exists {
return []string{}, true
}
items, ok := value.([]interface{})
if !ok {
return nil, false
}
result := make([]string, len(items))
for i, item := range items {
result[i], ok = item.(string)
if !ok {
return nil, false
}
}
return result, true
}

func mergeErrorOptionalString(body map[string]interface{}, key string) (string, bool) {
value, exists := body[key]
if !exists {
return "", true
}
result, ok := value.(string)
return result, ok
}

func (e *RefUpdateError) Error() string {
Expand Down
6 changes: 6 additions & 0 deletions packages/code-storage-go/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,12 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er

resp, err := r.client.api.post(ctx, r.apiPath("merge"), nil, body, jwtToken, nil)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
if refUpdateErr := parseMergeRefUpdateError(apiErr); refUpdateErr != nil {
return MergeResult{}, refUpdateErr
}
}
return MergeResult{}, err
}
defer resp.Body.Close()
Expand Down
177 changes: 149 additions & 28 deletions packages/code-storage-go/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1145,38 +1145,159 @@ func TestMergeValidation(t *testing.T) {
}
}

func TestMergeConflictPreservesBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/repos/repo/merge" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":"merge conflict","conflict_paths":["README.md"],"merge_base_sha":"base123"}`))
}))
defer server.Close()

client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL})
if err != nil {
t.Fatalf("client error: %v", err)
func TestMergeReturnsTypedRefUpdateErrors(t *testing.T) {
tests := []struct {
name string
body map[string]interface{}
expected RefUpdateError
}{
{
name: "merge conflict",
body: map[string]interface{}{
"error": "merge conflict",
"code": "merge_conflict",
"conflict_paths": []string{"README.md"},
"merge_base_sha": "base123",
},
expected: RefUpdateError{
Message: "merge conflict",
Status: "merge_conflict",
Reason: RefUpdateReasonConflict,
ConflictPaths: []string{"README.md"},
MergeBaseSHA: "base123",
},
},
{
name: "stale target guard",
body: map[string]interface{}{
"error": "target branch moved",
"code": "precondition_failed",
"guard": "target",
"expected_sha": "expected-target",
"actual_sha": "actual-target",
},
expected: RefUpdateError{
Message: "target branch moved",
Status: "precondition_failed",
Reason: RefUpdateReasonPreconditionFailed,
Guard: MergeGuardTarget,
ExpectedSHA: "expected-target",
ActualSHA: "actual-target",
},
},
{
name: "stale source guard",
body: map[string]interface{}{
"error": "source ref no longer contains the expected commit",
"code": "precondition_failed",
"guard": "source",
"expected_sha": "expected-source",
"actual_sha": "actual-source",
},
expected: RefUpdateError{
Message: "source ref no longer contains the expected commit",
Status: "precondition_failed",
Reason: RefUpdateReasonPreconditionFailed,
Guard: MergeGuardSource,
ExpectedSHA: "expected-source",
ActualSHA: "actual-source",
},
},
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

_, err = repo.Merge(nil, MergeOptions{SourceBranch: "feature", TargetBranch: "main", Strategy: MergeStrategyMerge})
if err == nil {
t.Fatalf("expected conflict error")
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T", err)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/repos/repo/merge" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(tc.body)
}))
defer server.Close()

client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL})
if err != nil {
t.Fatalf("client error: %v", err)
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

_, err = repo.Merge(nil, MergeOptions{SourceRef: "feature", TargetBranch: "main", Strategy: MergeStrategyMerge})
var refErr *RefUpdateError
if !errors.As(err, &refErr) {
t.Fatalf("expected RefUpdateError, got %T", err)
}
if !reflect.DeepEqual(*refErr, tc.expected) {
t.Fatalf("unexpected RefUpdateError: %#v", refErr)
}
})
}
body, ok := apiErr.Body.(map[string]interface{})
if !ok || body["error"] != "merge conflict" || body["merge_base_sha"] != "base123" {
t.Fatalf("unexpected error body: %#v", apiErr.Body)
}

func TestMergeKeepsOtherAPIErrors(t *testing.T) {
tests := []struct {
name string
statusCode int
body map[string]interface{}
}{
{
name: "unknown 409 code",
statusCode: http.StatusConflict,
body: map[string]interface{}{
"error": "merge conflict in README.md",
"code": "future_merge_error",
},
},
{
name: "403 response",
statusCode: http.StatusForbidden,
body: map[string]interface{}{
"error": "merge conflict",
"code": "merge_conflict",
},
},
{
name: "500 response",
statusCode: http.StatusInternalServerError,
body: map[string]interface{}{
"error": "target branch moved",
"code": "precondition_failed",
"guard": "target",
"expected_sha": "expected-target",
"actual_sha": "actual-target",
},
},
}
paths, ok := body["conflict_paths"].([]interface{})
if !ok || len(paths) != 1 || paths[0] != "README.md" {
t.Fatalf("unexpected conflict paths: %#v", body["conflict_paths"])

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.statusCode)
_ = json.NewEncoder(w).Encode(tc.body)
}))
defer server.Close()

client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL})
if err != nil {
t.Fatalf("client error: %v", err)
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

_, err = repo.Merge(nil, MergeOptions{SourceRef: "feature", TargetBranch: "main", Strategy: MergeStrategyMerge})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T", err)
}
if apiErr.Status != tc.statusCode || !reflect.DeepEqual(apiErr.Body, tc.body) {
t.Fatalf("unexpected APIError: %#v", apiErr)
}
var refErr *RefUpdateError
if errors.As(err, &refErr) {
t.Fatalf("expected APIError only, got RefUpdateError: %#v", refErr)
}
})
}
}

Expand Down
Loading