Skip to content
Merged
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
15 changes: 15 additions & 0 deletions packages/code-storage-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@ if err != nil {
fmt.Println(deletedEphemeral.Ephemeral)
```

### Preview merge

```go
includeContent := true
preview, err := repo.PreviewMerge(context.Background(), storage.PreviewMergeOptions{
SourceBranch: "feature",
TargetBranch: "main",
IncludeContent: &includeContent,
})
if err != nil {
log.Fatal(err)
}
fmt.Println(preview.Status, preview.Result, preview.ConflictPaths)
```

### Merge branches

```go
Expand Down
75 changes: 75 additions & 0 deletions packages/code-storage-go/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,81 @@ func (r *Repo) Merge(ctx context.Context, options MergeOptions) (MergeResult, er
}, nil
}

// PreviewMerge previews a branch merge without creating commits or updating refs.
func (r *Repo) PreviewMerge(ctx context.Context, options PreviewMergeOptions) (PreviewMergeResult, error) {
sourceBranch := strings.TrimSpace(options.SourceBranch)
if sourceBranch == "" {
return PreviewMergeResult{}, errors.New("previewMerge sourceBranch is required")
}
targetBranch := strings.TrimSpace(options.TargetBranch)
if targetBranch == "" {
return PreviewMergeResult{}, errors.New("previewMerge targetBranch is required")
}

ttl := resolveInvocationTTL(options.InvocationOptions, defaultTokenTTL)
jwtToken, err := r.client.generateJWT(r.ID, RemoteURLOptions{Permissions: []Permission{PermissionGitRead}, TTL: ttl})
if err != nil {
return PreviewMergeResult{}, err
}

params := url.Values{}
params.Set("source_branch", sourceBranch)
params.Set("target_branch", targetBranch)
if options.IncludeContent != nil {
params.Set("include_content", strconv.FormatBool(*options.IncludeContent))
}

resp, err := r.client.api.get(ctx, "repos/merge/preview", params, jwtToken, nil)
if err != nil {
return PreviewMergeResult{}, err
}
defer resp.Body.Close()

var payload previewMergeResponse
if err := decodeJSON(resp, &payload); err != nil {
return PreviewMergeResult{}, err
}

result := PreviewMergeResult{
Status: PreviewMergeStatus(payload.Status),
Result: PreviewMergeResultStatus(payload.Result),
SourceBranch: payload.SourceBranch,
TargetBranch: payload.TargetBranch,
SourceTipSHA: payload.SourceTipSHA,
TargetTipSHA: payload.TargetTipSHA,
MergeBaseSHA: payload.MergeBaseSHA,
ConflictPaths: payload.ConflictPaths,
FilteredConflicts: make([]PreviewMergeFilteredConflict, 0, len(payload.FilteredConflicts)),
Conflicts: make([]PreviewMergeConflict, 0, len(payload.Conflicts)),
}
for _, conflict := range payload.Conflicts {
result.Conflicts = append(result.Conflicts, PreviewMergeConflict{
Path: conflict.Path,
Result: previewMergeBlobResult(conflict.Result),
Base: previewMergeBlobResult(conflict.Base),
Ours: previewMergeBlobResult(conflict.Ours),
Theirs: previewMergeBlobResult(conflict.Theirs),
})
}
for _, conflict := range payload.FilteredConflicts {
result.FilteredConflicts = append(result.FilteredConflicts, PreviewMergeFilteredConflict{
Path: conflict.Path,
Reason: conflict.Reason,
})
}

return result, nil
}

func previewMergeBlobResult(raw previewMergeBlob) PreviewMergeBlob {
return PreviewMergeBlob{
OID: raw.OID,
Content: raw.Content,
Truncated: raw.Truncated,
Binary: raw.Binary,
}
}

func mergeSignaturePayload(field string, signature *CommitSignature) (*authorInfo, error) {
name := strings.TrimSpace(signature.Name)
email := strings.TrimSpace(signature.Email)
Expand Down
92 changes: 92 additions & 0 deletions packages/code-storage-go/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -809,6 +810,97 @@ func TestCreateBranchTTL(t *testing.T) {
}
}

func TestPreviewMergeRequestAndResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("unexpected method: %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/merge/preview" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.URL.Query().Get("source_branch"); got != "feature/preview" {
t.Fatalf("unexpected source_branch: %s", got)
}
if got := r.URL.Query().Get("target_branch"); got != "main" {
t.Fatalf("unexpected target_branch: %s", got)
}
if got := r.URL.Query().Get("include_content"); got != "true" {
t.Fatalf("unexpected include_content: %s", got)
}
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
claims := parseJWTFromToken(t, token)
scopes, ok := claims["scopes"].([]interface{})
if !ok || len(scopes) != 1 || scopes[0] != "git:read" {
t.Fatalf("unexpected scopes: %v", claims["scopes"])
}

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"conflicted","result":"merge_commit","source_branch":"feature/preview","target_branch":"main","source_tip_sha":"source123","target_tip_sha":"target123","merge_base_sha":"base123","conflict_paths":["docs/conflict.txt"],"conflicts":[{"path":"docs/conflict.txt","result":{"oid":"result-oid","content":"<<<<<<< ours","truncated":false,"binary":false},"base":{"oid":"base-oid","truncated":false,"binary":false},"ours":{"oid":"ours-oid","content":"ours","truncated":false,"binary":false},"theirs":{"oid":"theirs-oid","content":"theirs","truncated":false,"binary":false}}],"filtered_conflicts":[{"path":"src/app.go","reason":"max_conflict_files_exceeded"}]}`))
}))
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}

result, err := repo.PreviewMerge(nil, PreviewMergeOptions{
SourceBranch: " feature/preview ",
TargetBranch: " main ",
IncludeContent: boolPtr(true),
})
if err != nil {
t.Fatalf("preview merge error: %v", err)
}

expected := PreviewMergeResult{
Status: PreviewMergeStatusConflicted,
Result: PreviewMergeResultMergeCommit,
SourceBranch: "feature/preview",
TargetBranch: "main",
SourceTipSHA: "source123",
TargetTipSHA: "target123",
MergeBaseSHA: "base123",
ConflictPaths: []string{"docs/conflict.txt"},
Conflicts: []PreviewMergeConflict{
{
Path: "docs/conflict.txt",
Result: PreviewMergeBlob{
OID: "result-oid",
Content: "<<<<<<< ours",
Truncated: false,
Binary: false,
},
Base: PreviewMergeBlob{OID: "base-oid", Truncated: false, Binary: false},
Ours: PreviewMergeBlob{OID: "ours-oid", Content: "ours", Truncated: false, Binary: false},
Theirs: PreviewMergeBlob{OID: "theirs-oid", Content: "theirs", Truncated: false, Binary: false},
},
},
FilteredConflicts: []PreviewMergeFilteredConflict{
{Path: "src/app.go", Reason: "max_conflict_files_exceeded"},
},
}
if !reflect.DeepEqual(result, expected) {
t.Fatalf("unexpected result: %#v", result)
}
}

func TestPreviewMergeValidation(t *testing.T) {
client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: "https://api.example.com"})
if err != nil {
t.Fatalf("client error: %v", err)
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

if _, err = repo.PreviewMerge(nil, PreviewMergeOptions{TargetBranch: "main"}); err == nil || err.Error() != "previewMerge sourceBranch is required" {
t.Fatalf("unexpected source error: %v", err)
}
if _, err = repo.PreviewMerge(nil, PreviewMergeOptions{SourceBranch: "feature"}); err == nil || err.Error() != "previewMerge targetBranch is required" {
t.Fatalf("unexpected target error: %v", err)
}
}

func TestMergeGuardedTargetTipRequestAndResponse(t *testing.T) {
var captured map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
33 changes: 33 additions & 0 deletions packages/code-storage-go/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,39 @@ type mergeTargetRaw struct {
NewSHA string `json:"new_sha"`
}

type previewMergeResponse struct {
Status string `json:"status"`
Result string `json:"result"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
SourceTipSHA string `json:"source_tip_sha"`
TargetTipSHA string `json:"target_tip_sha"`
MergeBaseSHA string `json:"merge_base_sha"`
ConflictPaths []string `json:"conflict_paths"`
Conflicts []previewMergeConflict `json:"conflicts"`
FilteredConflicts []previewMergeFilteredConflict `json:"filtered_conflicts"`
}

type previewMergeBlob struct {
OID string `json:"oid"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
Binary bool `json:"binary"`
}

type previewMergeConflict struct {
Path string `json:"path"`
Result previewMergeBlob `json:"result"`
Base previewMergeBlob `json:"base"`
Ours previewMergeBlob `json:"ours"`
Theirs previewMergeBlob `json:"theirs"`
}

type previewMergeFilteredConflict struct {
Path string `json:"path"`
Reason string `json:"reason"`
}

type listTagsResponse struct {
Tags []tagInfoRaw `json:"tags"`
NextCursor string `json:"next_cursor"`
Expand Down
62 changes: 62 additions & 0 deletions packages/code-storage-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,23 @@ const (
MergeResultUnknown MergeResultStatus = "unknown"
)

// PreviewMergeStatus describes whether a merge preview is clean or conflicted.
type PreviewMergeStatus string

const (
PreviewMergeStatusClean PreviewMergeStatus = "clean"
PreviewMergeStatusConflicted PreviewMergeStatus = "conflicted"
)

// PreviewMergeResultStatus describes the result if the previewed merge were applied.
type PreviewMergeResultStatus string

const (
PreviewMergeResultMergeCommit PreviewMergeResultStatus = "merge_commit"
PreviewMergeResultFastForward PreviewMergeResultStatus = "fast_forward"
PreviewMergeResultNoOp PreviewMergeResultStatus = "no_op"
)

// MergeRef describes a merge source ref.
type MergeRef struct {
Branch string
Expand All @@ -485,6 +502,51 @@ type MergeResult struct {
PromotedCommits int
}

// PreviewMergeOptions configures read-only branch merge previews.
type PreviewMergeOptions struct {
InvocationOptions
SourceBranch string
TargetBranch string
IncludeContent *bool
}

// PreviewMergeBlob describes a conflict stage blob in a merge preview.
type PreviewMergeBlob struct {
OID string
Content string
Truncated bool
Binary bool
}

// PreviewMergeConflict describes inline conflict content for a path.
type PreviewMergeConflict struct {
Path string
Result PreviewMergeBlob
Base PreviewMergeBlob
Ours PreviewMergeBlob
Theirs PreviewMergeBlob
}

// PreviewMergeFilteredConflict describes omitted inline conflict content.
type PreviewMergeFilteredConflict struct {
Path string
Reason string
}

// PreviewMergeResult describes the read-only merge preview result.
type PreviewMergeResult struct {
Status PreviewMergeStatus
Result PreviewMergeResultStatus
SourceBranch string
TargetBranch string
SourceTipSHA string
TargetTipSHA string
MergeBaseSHA string
ConflictPaths []string
Conflicts []PreviewMergeConflict
FilteredConflicts []PreviewMergeFilteredConflict
}

// ListTagsOptions configures list tags.
type ListTagsOptions struct {
InvocationOptions
Expand Down
2 changes: 1 addition & 1 deletion packages/code-storage-go/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package storage

const (
PackageName = "code-storage-go-sdk"
PackageVersion = "0.10.0"
PackageVersion = "0.11.0"
)

func userAgent() string {
Expand Down
18 changes: 18 additions & 0 deletions packages/code-storage-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,15 @@ ephemeral_delete = await repo.delete_branch(
)
print(ephemeral_delete["ephemeral"]) # True

# Preview whether a source branch can merge without creating commits or updating refs
preview = await repo.preview_merge(
source_branch="feature/preview",
target_branch="main",
include_content=True, # optional bounded conflict blob content
)
print(preview["status"], preview["result"])
print(preview["conflict_paths"], preview["filtered_conflicts"])

# Merge one branch into another
merge_result = await repo.merge(
source_branch="feature/preview",
Expand Down Expand Up @@ -803,6 +812,15 @@ class Repo:
ref_policies: Optional[Refs] = None,
) -> MergeBranchesResult: ...

async def preview_merge(
self,
*,
source_branch: str,
target_branch: str,
include_content: Optional[bool] = None,
ttl: Optional[int] = None,
) -> PreviewMergeResult: ...

async def list_tags(
self,
*,
Expand Down
8 changes: 8 additions & 0 deletions packages/code-storage-python/pierre_storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
NoteWriteResult,
Op,
Ops,
PreviewMergeBlob,
PreviewMergeConflict,
PreviewMergeFilteredConflict,
PreviewMergeResult,
RefPolicy,
Refs,
RefUpdate,
Expand Down Expand Up @@ -114,6 +118,10 @@
"ListTagsResult",
"NoteReadResult",
"NoteWriteResult",
"PreviewMergeBlob",
"PreviewMergeConflict",
"PreviewMergeFilteredConflict",
"PreviewMergeResult",
"RefUpdate",
"RepoInfo",
"Repo",
Expand Down
Loading
Loading