From 7e40b1a8b77ba7c67caab759772a666b85c63fb3 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Sat, 15 Aug 2026 06:50:37 -0700 Subject: [PATCH] shamhub: Model native stack operations Submit and merge integration need a deterministic forge that exposes the same optional native-stack boundary as production providers. Add an opt-in ShamHub repository implementing the complete `WithStacks` contract while leaving the capability absent by default for existing scripts. Plan immutable stack replacements without mutating stored state. Execution atomically validates and replaces the touched relationships together with their provider-facing bases, allowing submit tests to detect sequencing regressions. Preserve divergent relationships in storage and derive disjoint linear merge plans from that representation. Each executable range validates and constructs its merge or squash result before one compare-and-swap publication, so failures cannot expose a partial atomic merge. Refs #1388 --- internal/forge/shamhub/admin.go | 33 + internal/forge/shamhub/cli_main.go | 19 + internal/forge/shamhub/forge.go | 49 +- internal/forge/shamhub/forge_test.go | 61 ++ internal/forge/shamhub/integration_test.go | 9 +- internal/forge/shamhub/merge_range.go | 612 ++++++++++++++++++ internal/forge/shamhub/merge_range_test.go | 294 +++++++++ internal/forge/shamhub/repo.go | 8 + internal/forge/shamhub/shamhub.go | 6 + internal/forge/shamhub/stacks.go | 189 ++++++ internal/forge/shamhub/stacks_test.go | 146 +++++ .../TestIntegration/Stacks/bottomBranch | 1 + .../TestIntegration/Stacks/middleBranch | 1 + .../testdata/TestIntegration/Stacks/topBranch | 1 + .../fixtures/TestIntegration/Stacks.yaml | 162 +++++ 15 files changed, 1586 insertions(+), 5 deletions(-) create mode 100644 internal/forge/shamhub/merge_range.go create mode 100644 internal/forge/shamhub/merge_range_test.go create mode 100644 internal/forge/shamhub/stacks.go create mode 100644 internal/forge/shamhub/stacks_test.go create mode 100644 internal/forge/shamhub/testdata/TestIntegration/Stacks/bottomBranch create mode 100644 internal/forge/shamhub/testdata/TestIntegration/Stacks/middleBranch create mode 100644 internal/forge/shamhub/testdata/TestIntegration/Stacks/topBranch create mode 100644 internal/forge/shamhub/testdata/fixtures/TestIntegration/Stacks.yaml diff --git a/internal/forge/shamhub/admin.go b/internal/forge/shamhub/admin.go index bec990961..7d7a8217b 100644 --- a/internal/forge/shamhub/admin.go +++ b/internal/forge/shamhub/admin.go @@ -1,6 +1,7 @@ package shamhub import ( + "cmp" "context" json "encoding/json/v2" "errors" @@ -73,6 +74,10 @@ var ( "GET /_shamhub/admin/dump/changes/{number}", (*ShamHub).handleAdminDumpChange, ) + _ = shamhubRESTHandler( + "GET /_shamhub/admin/dump/stacks/{owner}/{repo}", + (*ShamHub).handleAdminDumpStacks, + ) _ = shamhubHTTPHandler( "GET /_shamhub/admin/dump/comments", (*ShamHub).handleAdminDumpComments, @@ -631,6 +636,34 @@ type adminDumpCommentsResponse struct { Comments []*ChangeComment `json:"comments"` } +type adminDumpStacksRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` +} + +type adminDumpStacksResponse struct { + Changes []stackChange `json:"changes"` +} + +// Stack dumps return stored native relationships for script assertions. +func (sh *ShamHub) handleAdminDumpStacks( + _ context.Context, + req adminDumpStacksRequest, +) (*adminDumpStacksResponse, error) { + sh.mu.RLock() + defer sh.mu.RUnlock() + + bases := sh.stackBases[repoID{Owner: req.Owner, Name: req.Repo}] + changes := make([]stackChange, 0, len(bases)) + for number, base := range bases { + changes = append(changes, stackChange{Number: number, Base: base}) + } + slices.SortFunc(changes, func(a, b stackChange) int { + return cmp.Compare(a.Number, b.Number) + }) + return &adminDumpStacksResponse{Changes: changes}, nil +} + // Comment dumps keep repeated change query parameters for script ergonomics. func (sh *ShamHub) handleAdminDumpComments( w http.ResponseWriter, diff --git a/internal/forge/shamhub/cli_main.go b/internal/forge/shamhub/cli_main.go index 181ea39ca..ee0fe73fd 100644 --- a/internal/forge/shamhub/cli_main.go +++ b/internal/forge/shamhub/cli_main.go @@ -762,6 +762,25 @@ func (c *shamhubCLI) dump(args []string) error { } return encodeJSON(c.stdout, res.Change) + case "stacks": + if len(args) != 2 { + return errors.New("usage: shamhub dump stacks ") + } + owner, repo, err := parseOwnerRepo(args[1]) + if err != nil { + return err + } + + var res adminDumpStacksResponse + if err := c.client.Get( + c.ctx, + "/_shamhub/admin/dump/stacks/"+owner+"/"+repo, + &res, + ); err != nil { + return err + } + return encodeJSON(c.stdout, res.Changes) + default: return fmt.Errorf("unknown dump command: %s", args[0]) } diff --git a/internal/forge/shamhub/forge.go b/internal/forge/shamhub/forge.go index 1e5145182..8ad88669d 100644 --- a/internal/forge/shamhub/forge.go +++ b/internal/forge/shamhub/forge.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strings" "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/git/giturl" @@ -15,6 +16,40 @@ import ( "go.abhg.dev/gs/internal/silog" ) +// stacksMode controls whether ShamHub exposes native stack capabilities. +// Its zero value disables them. +type stacksMode uint8 + +const ( + stacksOff stacksMode = iota + stacksOn +) + +// UnmarshalText accepts the case-insensitive configuration values `on`, `off`, +// `1`, and `0`. +func (m *stacksMode) UnmarshalText(text []byte) error { + switch strings.ToLower(string(text)) { + case "off", "0": + *m = stacksOff + case "on", "1": + *m = stacksOn + default: + return fmt.Errorf("invalid value %q: expected on or off", text) + } + return nil +} + +func (m stacksMode) String() string { + switch m { + case stacksOff: + return "off" + case stacksOn: + return "on" + default: + return fmt.Sprintf("stacksMode(%d)", m) + } +} + // Options defines CLI options for the ShamHub forge. type Options struct { // URL is the base URL for Git repositories @@ -24,6 +59,12 @@ type Options struct { // APIURL is the base URL for the ShamHub API. APIURL string `name:"shamhub-api-url" hidden:"" env:"SHAMHUB_API_URL" help:"Base URL for ShamHub API requests"` + + // Stacks controls whether ShamHub exposes native stack operations. + // The default is `off`. + // Opened repositories expose optional native stack capabilities only when + // the value is `on`. + Stacks stacksMode `name:"shamhub-stacks" hidden:"" config:"forge.shamhub.stacks" default:"off" help:"Whether to expose ShamHub native stack operations. One of 'on' and 'off'."` } // Definition configures ShamHub forge instances. @@ -135,14 +176,18 @@ func newRepository(f *Forge, token *AuthenticationToken, rid *RepositoryID, http return nil, fmt.Errorf("parse API URL: %w", err) } - return &forgeRepository{ + repo := &forgeRepository{ forge: f, owner: rid.owner, repo: rid.repo, apiURL: apiURL, log: f.Log, client: client, - }, nil + } + if f.Stacks == stacksOn { + return &stackRepository{forgeRepository: repo}, nil + } + return repo, nil } // RepositoryID is a unique identifier for a ShamHub repository. diff --git a/internal/forge/shamhub/forge_test.go b/internal/forge/shamhub/forge_test.go index da1365607..6be2a86d1 100644 --- a/internal/forge/shamhub/forge_test.go +++ b/internal/forge/shamhub/forge_test.go @@ -1,13 +1,74 @@ package shamhub import ( + "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/git/giturl" ) +func TestNewRepository_stacksCapability(t *testing.T) { + tests := []struct { + name string + mode stacksMode + want bool + }{ + {name: "Default"}, + {name: "Off", mode: stacksOff}, + {name: "On", mode: stacksOn, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, err := newRepository( + &Forge{ + URL: "https://shamhub.example", + APIURL: "https://api.shamhub.example", + Stacks: tt.mode, + }, + &AuthenticationToken{tok: "test"}, + &RepositoryID{ + url: "https://shamhub.example/acme/repo.git", + owner: "acme", + repo: "repo", + }, + http.DefaultClient, + ) + require.NoError(t, err) + + _, got := repo.(forge.StackRepository) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestStacksMode_UnmarshalText(t *testing.T) { + tests := []struct { + name string + give string + want stacksMode + }{ + {name: "Off", give: "off", want: stacksOff}, + {name: "Zero", give: "0", want: stacksOff}, + {name: "On", give: "on", want: stacksOn}, + {name: "One", give: "1", want: stacksOn}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got stacksMode + require.NoError(t, got.UnmarshalText([]byte(tt.give))) + assert.Equal(t, tt.want, got) + }) + } + + var got stacksMode + assert.Error(t, got.UnmarshalText([]byte("invalid"))) +} + func TestForge_ParseRepositoryPath_knownForge(t *testing.T) { f := &Forge{URL: "https://shamhub.example"} remoteURL, err := giturl.Parse("git@shamhub-alias:example/repo.git") diff --git a/internal/forge/shamhub/integration_test.go b/internal/forge/shamhub/integration_test.go index cf28f88c2..9b57deff1 100644 --- a/internal/forge/shamhub/integration_test.go +++ b/internal/forge/shamhub/integration_test.go @@ -132,6 +132,7 @@ func TestIntegration(t *testing.T) { shamForge := &Forge{ URL: gitURL, APIURL: apiURL, + Stacks: stacksOn, Log: silogtest.New(t), } @@ -157,7 +158,7 @@ func TestIntegration(t *testing.T) { }, MergeChange: func(t *testing.T, repo forge.Repository, changeID forge.ChangeID) { if forgetest.Update() { - r := repo.(*forgeRepository) + r := repo.(*stackRepository).forgeRepository require.NoError(t, shamhub.MergeChange(MergeChangeRequest{ Owner: r.owner, Repo: r.repo, @@ -167,7 +168,7 @@ func TestIntegration(t *testing.T) { }, CloseChange: func(t *testing.T, repo forge.Repository, changeID forge.ChangeID) { if forgetest.Update() { - r := repo.(*forgeRepository) + r := repo.(*stackRepository).forgeRepository require.NoError(t, shamhub.RejectChange(RejectChangeRequest{ Owner: r.owner, Repo: r.repo, @@ -184,7 +185,7 @@ func TestIntegration(t *testing.T) { check forge.ChangeCheck, ) { require.NoError(t, - repo.(*forgeRepository).setChangeCheck( + repo.(*stackRepository).setChangeCheck( t.Context(), changeID, check, @@ -196,5 +197,7 @@ func TestIntegration(t *testing.T) { ReviewThreadCommitHash: true, Reviewers: []string{"reviewer1", "reviewer2"}, Assignees: []string{"assignee1", "assignee2"}, + TestStacks: true, + TestMergeRange: true, }) } diff --git a/internal/forge/shamhub/merge_range.go b/internal/forge/shamhub/merge_range.go new file mode 100644 index 000000000..c64f5faac --- /dev/null +++ b/internal/forge/shamhub/merge_range.go @@ -0,0 +1,612 @@ +package shamhub + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "time" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/graph" +) + +type planMergeRangesRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` + + Changes []stackChange `json:"changes"` +} + +type planMergeRangesResponse struct { + Ranges [][]int `json:"ranges"` +} + +var _ = shamhubRESTHandler( + "POST /{owner}/{repo}/stack/merge-ranges/plan", + (*ShamHub).handlePlanMergeRanges, +) + +func (sh *ShamHub) handlePlanMergeRanges( + _ context.Context, + req *planMergeRangesRequest, +) (*planMergeRangesResponse, error) { + requestedBaseByChange := make(map[int]int, len(req.Changes)) + for _, change := range req.Changes { + requestedBaseByChange[change.Number] = change.Base + } + + sh.mu.RLock() + storedBaseByChange := maps.Clone( + sh.stackBases[repoID{Owner: req.Owner, Name: req.Repo}], + ) + sh.mu.RUnlock() + + // Keep only relationships represented by both the selected forest and + // ShamHub's current native stacks. A selected base must be the stored base; + // a stored base outside the selection remains the range's external base. + eligibleBaseByChange := make(map[int]int, len(req.Changes)) + for change, requestedBase := range requestedBaseByChange { + storedBase, stacked := storedBaseByChange[change] + if !stacked { + continue + } + _, storedBaseSelected := requestedBaseByChange[storedBase] + if requestedBase != 0 && requestedBase != storedBase || + requestedBase == 0 && storedBaseSelected { + continue + } + eligibleBaseByChange[change] = storedBase + } + + ordered, err := graph.Toposort( + slices.Sorted(maps.Keys(eligibleBaseByChange)), + func(change int) (int, bool) { + base := eligibleBaseByChange[change] + _, selected := eligibleBaseByChange[base] + return base, selected + }, + ) + if err != nil { + return nil, fmt.Errorf("order native stack changes: %w", err) + } + + aboves := make(map[int][]int, len(ordered)) + for _, change := range ordered { + base := eligibleBaseByChange[change] + if _, selected := eligibleBaseByChange[base]; selected { + aboves[base] = append(aboves[base], change) + } + } + + // A fork ends the shared range. Each divergent branch begins another + // disjoint range, allowing the handler to preserve ordinary scheduling + // dependencies without assigning one change to multiple atomic operations. + assigned := make(map[int]struct{}, len(ordered)) + var ranges [][]int + for _, bottom := range ordered { + if _, ok := assigned[bottom]; ok { + continue + } + + var mergeRange []int + for current := bottom; ; { + mergeRange = append(mergeRange, current) + assigned[current] = struct{}{} + if len(aboves[current]) != 1 { + break + } + current = aboves[current][0] + } + ranges = append(ranges, mergeRange) + } + return &planMergeRangesResponse{Ranges: ranges}, nil +} + +type mergeRangeChange struct { + Number int `json:"number"` + Base string `json:"base"` + Head string `json:"head"` + HeadHash string `json:"headHash"` +} + +type mergeRangeRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` + + Changes []mergeRangeChange `json:"changes"` + MergeMethod string `json:"mergeMethod,omitempty"` +} + +type mergeRangeResponse struct{} + +var _ = shamhubRESTHandler( + "POST /{owner}/{repo}/change/merge-range", + (*ShamHub).handleMergeRange, +) + +func (sh *ShamHub) handleMergeRange( + ctx context.Context, + req *mergeRangeRequest, +) (*mergeRangeResponse, error) { + method := MergeMethod(req.MergeMethod) + if method == "" { + sh.mu.RLock() + method = sh.defaultMergeMethod + sh.mu.RUnlock() + } else if _, err := parseMergeMethod(string(method)); err != nil { + return nil, badRequestErrorf("%s", err) + } + + if err := sh.mergeRange(ctx, req.Owner, req.Repo, req.Changes, method); err != nil { + return nil, err + } + return &mergeRangeResponse{}, nil +} + +// preparedMergeRange holds validated server state used to construct and +// publish one atomic range merge. +type preparedMergeRange struct { + // rootBaseHash is the target branch value used by the publishing CAS. + rootBaseHash string + + // changes retains the validated server records in bottom-to-top order. + changes []preparedMergeRangeChange +} + +// preparedMergeRangeChange pairs a validated change snapshot with the mutable +// entry updated only after atomic publication succeeds. +type preparedMergeRangeChange struct { + // index is the snapshot's position in ShamHub.changes. + index int + + // change is the validated snapshot used to construct the merge result. + change shamChange + + // headHash is the resolved head value validated against the request. + headHash string +} + +// mergeRange validates and builds the result before one compare-and-swap +// publishes it. Commit construction may leave unreachable objects on failure, +// but the root ref and in-memory change states remain unchanged. Holding mu +// across the ref update and state transition makes those observable effects +// one operation to ShamHub clients. +func (sh *ShamHub) mergeRange( + ctx context.Context, + owner string, + repo string, + changes []mergeRangeChange, + method MergeMethod, +) error { + if owner == "" || repo == "" { + return errors.New("owner and repo are required") + } + if len(changes) == 0 { + return errors.New("changes must not be empty") + } + + sh.mu.Lock() + defer sh.mu.Unlock() + + prepared, err := sh.prepareMergeRange(ctx, owner, repo, changes) + if err != nil { + return err + } + + commit, err := sh.buildMergeRangeCommit(ctx, owner, repo, method, prepared) + if err != nil { + return err + } + + rootRef := "refs/heads/" + prepared.changes[0].change.Base.Name + if err := sh.gitCmd( + ctx, + owner, + repo, + "update-ref", + rootRef, + commit, + prepared.rootBaseHash, + ).Run(); err != nil { + return fmt.Errorf("update root ref: %w", err) + } + + for _, change := range prepared.changes { + sh.changes[change.index].State = shamChangeMerged + sh.changes[change.index].HeadHash = change.headHash + } + return nil +} + +func (sh *ShamHub) prepareMergeRange( + ctx context.Context, + owner string, + repo string, + requested []mergeRangeChange, +) (preparedMergeRange, error) { + byNumber := make(map[int]int, len(requested)) + for i, change := range sh.changes { + if change.Base.Owner == owner && change.Base.Repo == repo { + byNumber[change.Number] = i + } + } + + prepared := preparedMergeRange{ + changes: make([]preparedMergeRangeChange, len(requested)), + } + for i, expected := range requested { + changeIndex, ok := byNumber[expected.Number] + if !ok { + return preparedMergeRange{}, fmt.Errorf( + "change %d (%s/%s) not found", expected.Number, owner, repo, + ) + } + change := sh.changes[changeIndex] + if change.State != shamChangeOpen { + return preparedMergeRange{}, fmt.Errorf( + "change %d is not open", expected.Number, + ) + } + if change.Draft { + return preparedMergeRange{}, fmt.Errorf( + "change %d is a draft", expected.Number, + ) + } + if change.Base.Name != expected.Base { + return preparedMergeRange{}, fmt.Errorf( + "change %d base branch is %q, expected %q", + expected.Number, change.Base.Name, expected.Base, + ) + } + if change.Head.Name != expected.Head { + return preparedMergeRange{}, fmt.Errorf( + "change %d head branch is %q, expected %q", + expected.Number, change.Head.Name, expected.Head, + ) + } + if expected.HeadHash == "" { + return preparedMergeRange{}, fmt.Errorf( + "change %d head hash is required", expected.Number, + ) + } + if i > 0 && expected.Base != requested[i-1].Head { + return preparedMergeRange{}, fmt.Errorf( + "change %d base branch is %q, expected prior head %q", + expected.Number, expected.Base, requested[i-1].Head, + ) + } + + baseHash, err := sh.gitCmd( + ctx, + owner, + repo, + "rev-parse", + "refs/heads/"+change.Base.Name+"^{commit}", + ).OutputChomp() + if err != nil { + return preparedMergeRange{}, fmt.Errorf( + "resolve change %d base: %w", expected.Number, err, + ) + } + headHash, err := sh.gitCmd( + ctx, + change.Head.Owner, + change.Head.Repo, + "rev-parse", + "refs/heads/"+change.Head.Name+"^{commit}", + ).OutputChomp() + if err != nil { + return preparedMergeRange{}, fmt.Errorf( + "resolve change %d head: %w", expected.Number, err, + ) + } + if headHash != expected.HeadHash { + return preparedMergeRange{}, fmt.Errorf( + "change %d head hash mismatch: expected %q, got %q", + expected.Number, expected.HeadHash, headHash, + ) + } + + // Commit construction runs in the receiving repository. + // After validating a fork head in its source repository, + // import its objects into the receiving repository. + if change.Head.Owner != owner || change.Head.Repo != repo { + if err := sh.gitCmd( + ctx, + owner, + repo, + "fetch", + "--no-write-fetch-head", + sh.repoDir(change.Head.Owner, change.Head.Repo), + change.Head.Name, + ).Run(); err != nil { + return preparedMergeRange{}, fmt.Errorf( + "fetch change %d head objects: %w", expected.Number, err, + ) + } + } + + // Branch-name alignment is insufficient for an atomic range merge. + // Each base ref must resolve to the previous validated head commit. + if i > 0 && baseHash != prepared.changes[i-1].headHash { + return preparedMergeRange{}, fmt.Errorf( + "change %d base hash is %q, expected prior head %q", + expected.Number, baseHash, prepared.changes[i-1].headHash, + ) + } + if err := sh.gitCmd( + ctx, + owner, + repo, + "merge-base", + "--is-ancestor", + baseHash, + headHash, + ).Run(); err != nil { + return preparedMergeRange{}, fmt.Errorf( + "change %d head is not based on %q: %w", + expected.Number, expected.Base, err, + ) + } + + if i == 0 { + prepared.rootBaseHash = baseHash + } + prepared.changes[i] = preparedMergeRangeChange{ + index: changeIndex, + change: change, + headHash: headHash, + } + } + return prepared, nil +} + +func (sh *ShamHub) buildMergeRangeCommit( + ctx context.Context, + owner string, + repo string, + method MergeMethod, + prepared preparedMergeRange, +) (string, error) { + switch method { + case MergeMethodMerge: + top := prepared.changes[len(prepared.changes)-1] + tree, err := sh.gitCmd( + ctx, + owner, + repo, + "merge-tree", + "--write-tree", + prepared.rootBaseHash, + top.headHash, + ).OutputChomp() + if err != nil { + return "", fmt.Errorf("merge range trees: %w", err) + } + message := fmt.Sprintf( + "Merge changes #%d through #%d", + prepared.changes[0].change.Number, + top.change.Number, + ) + return sh.commitRangeTree( + ctx, + owner, + repo, + tree, + []string{prepared.rootBaseHash, top.headHash}, + message, + top.headHash, + ) + + case MergeMethodSquash: + // Every stacked head tree contains the changes below it. Re-parenting + // those trees in range order produces one squashed commit per change + // without reconstructing or replaying individual patches. + parent := prepared.rootBaseHash + for _, change := range prepared.changes { + tree, err := sh.gitCmd( + ctx, + owner, + repo, + "rev-parse", + change.headHash+"^{tree}", + ).OutputChomp() + if err != nil { + return "", fmt.Errorf( + "resolve change %d tree: %w", change.change.Number, err, + ) + } + message := fmt.Sprintf( + "%s (#%d)\n\n%s", + change.change.Subject, + change.change.Number, + change.change.Body, + ) + parent, err = sh.commitRangeTree( + ctx, + owner, + repo, + tree, + []string{parent}, + message, + change.headHash, + ) + if err != nil { + return "", err + } + } + return parent, nil + + default: + return "", fmt.Errorf("unsupported merge method %q", method) + } +} + +func (sh *ShamHub) commitRangeTree( + ctx context.Context, + owner string, + repo string, + tree string, + parents []string, + message string, + timeSource string, +) (string, error) { + commitTimeText, err := sh.gitCmd( + ctx, + owner, + repo, + "log", + "-1", + "--format=%cI", + timeSource, + ).OutputChomp() + if err != nil { + return "", fmt.Errorf("read commit time: %w", err) + } + commitTime, err := time.Parse(time.RFC3339, commitTimeText) + if err != nil { + return "", fmt.Errorf("parse commit time: %w", err) + } + + args := []string{"commit-tree"} + for _, parent := range parents { + args = append(args, "-p", parent) + } + args = append(args, "-m", message, tree) + commit, err := sh.gitCmd(ctx, owner, repo, args...). + AppendEnv( + "GIT_COMMITTER_NAME=ShamHub", + "GIT_COMMITTER_EMAIL=shamhub@example.com", + "GIT_AUTHOR_NAME=ShamHub", + "GIT_AUTHOR_EMAIL=shamhub@example.com", + "GIT_COMMITTER_DATE="+commitTime.Format(time.RFC3339), + "GIT_AUTHOR_DATE="+commitTime.Format(time.RFC3339), + ). + OutputChomp() + if err != nil { + return "", fmt.Errorf("create merge commit: %w", err) + } + return commit, nil +} + +// PlanMergeRanges loads ShamHub's stored native-stack relationships and +// returns the disjoint linear ranges it can merge atomically. +func (r *stackRepository) PlanMergeRanges( + ctx context.Context, + changes []forge.StackChange, +) ([]forge.MergeRangePlan, error) { + requestedChanges := make(map[ChangeID]struct{}, len(changes)) + for _, change := range changes { + requestedChanges[change.Change.(ChangeID)] = struct{}{} + } + + req := planMergeRangesRequest{ + Changes: make([]stackChange, len(changes)), + } + for i, change := range changes { + req.Changes[i].Number = int(change.Change.(ChangeID)) + base, ok := change.BaseChange.(ChangeID) + if !ok { + continue + } + if _, selected := requestedChanges[base]; selected { + req.Changes[i].Base = int(base) + } + } + + var res planMergeRangesResponse + if err := r.client.Post( + ctx, + r.apiURL.JoinPath(r.owner, r.repo, "stack", "merge-ranges", "plan").String(), + req, + &res, + ); err != nil { + return nil, fmt.Errorf("plan merge ranges: %w", err) + } + + plans := make([]forge.MergeRangePlan, len(res.Ranges)) + for i, numbers := range res.Ranges { + planned := make([]forge.ChangeID, len(numbers)) + for j, number := range numbers { + planned[j] = ChangeID(number) + } + plans[i] = &shamHubMergeRangePlan{ + repository: r, + changes: planned, + } + } + return plans, nil +} + +type shamHubMergeRangePlan struct { + repository *stackRepository + changes []forge.ChangeID +} + +func (p *shamHubMergeRangePlan) Changes() []forge.ChangeID { + return p.changes +} + +// Merge asks ShamHub to atomically merge the planned aligned range. +func (p *shamHubMergeRangePlan) Merge( + ctx context.Context, + request forge.MergeRangeRequest, +) (forge.MergeOperation, error) { + if len(request.Changes) != len(p.changes) { + return nil, fmt.Errorf( + "merge range request has %d changes, planned %d", + len(request.Changes), + len(p.changes), + ) + } + for i, change := range request.Changes { + if change.Change.String() != p.changes[i].String() { + return nil, fmt.Errorf( + "merge range request change %d is %v, planned %v", + i, + change.Change, + p.changes[i], + ) + } + } + + req := mergeRangeRequest{ + Changes: make([]mergeRangeChange, len(request.Changes)), + } + for i, change := range request.Changes { + req.Changes[i] = mergeRangeChange{ + Number: int(change.Change.(ChangeID)), + Base: change.Base, + Head: change.Head, + HeadHash: change.HeadHash.String(), + } + } + switch request.Method { + case forge.MergeMethodMerge, forge.MergeMethodSquash: + req.MergeMethod = request.Method.String() + case forge.MergeMethodDefault: + default: + p.repository.log.Warn( + "Unsupported merge method; using forge default", + "method", request.Method, + ) + } + + var res mergeRangeResponse + if err := p.repository.client.Post( + ctx, + p.repository.apiURL.JoinPath( + p.repository.owner, + p.repository.repo, + "change", + "merge-range", + ).String(), + req, + &res, + ); err != nil { + return nil, fmt.Errorf("merge range: %w", err) + } + return nil, nil +} diff --git a/internal/forge/shamhub/merge_range_test.go b/internal/forge/shamhub/merge_range_test.go new file mode 100644 index 000000000..b4307ee33 --- /dev/null +++ b/internal/forge/shamhub/merge_range_test.go @@ -0,0 +1,294 @@ +package shamhub + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog/silogtest" +) + +func TestStackRepository_MergeRange_merge(t *testing.T) { + fixture := newMergeRangeFixture(t) + + operation, err := fixture.plan().Merge(t.Context(), fixture.request) + require.NoError(t, err) + assert.Nil(t, operation) + assertMergeRangeCompleted(t, fixture) + + mainHash := resolveTestBranch(t, fixture.sh, "main") + commit, err := fixture.sh.gitCmd( + t.Context(), "alice", "example", "cat-file", "-p", mainHash, + ).OutputChomp() + require.NoError(t, err) + assert.Equal(t, 2, strings.Count(commit, "\nparent ")) + assert.Equal(t, + resolveTestTree(t, fixture.sh, fixture.topHash.String()), + resolveTestTree(t, fixture.sh, mainHash), + ) +} + +func TestStackRepository_MergeRange_squash(t *testing.T) { + fixture := newMergeRangeFixture(t) + fixture.request.Method = forge.MergeMethodSquash + + operation, err := fixture.plan().Merge(t.Context(), fixture.request) + require.NoError(t, err) + assert.Nil(t, operation) + assertMergeRangeCompleted(t, fixture) + + mainHash := resolveTestBranch(t, fixture.sh, "main") + count, err := fixture.sh.gitCmd( + t.Context(), "alice", "example", "rev-list", "--count", + fixture.mainHash+".."+mainHash, + ).OutputChomp() + require.NoError(t, err) + assert.Equal(t, "2", count) + assert.Equal(t, + resolveTestTree(t, fixture.sh, fixture.topHash.String()), + resolveTestTree(t, fixture.sh, mainHash), + ) +} + +func TestStackRepository_MergeRange_validationFailureIsAtomic(t *testing.T) { + fixture := newMergeRangeFixture(t) + fixture.request.Changes[1].HeadHash = git.Hash(strings.Repeat("1", 40)) + + _, err := fixture.plan().Merge(t.Context(), fixture.request) + assert.ErrorContains(t, err, "head hash mismatch") + assertMergeRangeUnchanged(t, fixture) +} + +func TestStackRepository_MergeRange_objectConstructionFailureIsAtomic( + t *testing.T, +) { + fixture := newMergeRangeFixture(t) + fixture.sh.gitExe = rejectGitSubcommand(t, fixture.sh.gitExe, "commit-tree") + + _, err := fixture.plan().Merge(t.Context(), fixture.request) + assert.ErrorContains(t, err, "create merge commit") + assertMergeRangeUnchanged(t, fixture) +} + +func TestStackRepository_MergeRange_refUpdateFailureIsAtomic(t *testing.T) { + fixture := newMergeRangeFixture(t) + fixture.sh.gitExe = rejectGitSubcommand(t, fixture.sh.gitExe, "update-ref") + + _, err := fixture.plan().Merge(t.Context(), fixture.request) + assert.ErrorContains(t, err, "update root ref") + assertMergeRangeUnchanged(t, fixture) +} + +func TestShamHub_mergeRange_canceledRequestIsAtomic(t *testing.T) { + fixture := newMergeRangeFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := fixture.sh.handleMergeRange(ctx, &mergeRangeRequest{ + Owner: "alice", + Repo: "example", + Changes: []mergeRangeChange{ + { + Number: int(fixture.request.Changes[0].Change.(ChangeID)), + Base: fixture.request.Changes[0].Base, + Head: fixture.request.Changes[0].Head, + HeadHash: fixture.request.Changes[0].HeadHash.String(), + }, + { + Number: int(fixture.request.Changes[1].Change.(ChangeID)), + Base: fixture.request.Changes[1].Base, + Head: fixture.request.Changes[1].Head, + HeadHash: fixture.request.Changes[1].HeadHash.String(), + }, + }, + MergeMethod: string(MergeMethodMerge), + }) + require.ErrorIs(t, err, context.Canceled) + assertMergeRangeUnchanged(t, fixture) +} + +type mergeRangeFixture struct { + sh *ShamHub + repo *stackRepository + request forge.MergeRangeRequest + mainHash string + topHash git.Hash +} + +func (f mergeRangeFixture) plan() *shamHubMergeRangePlan { + changes := make([]forge.ChangeID, len(f.request.Changes)) + for i, change := range f.request.Changes { + changes[i] = change.Change + } + return &shamHubMergeRangePlan{ + repository: f.repo, + changes: changes, + } +} + +func newMergeRangeFixture(t *testing.T) mergeRangeFixture { + t.Helper() + + sh, baseRepo := newMergeabilityTestRepository(t) + repo := &stackRepository{forgeRepository: baseRepo} + workDir := t.TempDir() + worktree, err := git.Clone( + t.Context(), + sh.RepoURL("alice", "example"), + workDir, + git.CloneOptions{Log: silogtest.New(t)}, + ) + require.NoError(t, err) + + writeCommitAndPush(t, worktree, workDir, "base.txt", "base\n", "Base", "main") + mainHash, err := worktree.Head(t.Context()) + require.NoError(t, err) + + require.NoError(t, worktree.Repository().CreateBranch( + t.Context(), git.CreateBranchRequest{Name: "bottom", Head: "HEAD"}, + )) + require.NoError(t, worktree.CheckoutBranch(t.Context(), "bottom")) + writeCommitAndPush(t, worktree, workDir, + "bottom.txt", "bottom\n", "Bottom", "bottom") + bottomHash, err := worktree.Head(t.Context()) + require.NoError(t, err) + + require.NoError(t, worktree.Repository().CreateBranch( + t.Context(), git.CreateBranchRequest{Name: "top", Head: "HEAD"}, + )) + require.NoError(t, worktree.CheckoutBranch(t.Context(), "top")) + writeCommitAndPush(t, worktree, workDir, + "top.txt", "top\n", "Top", "top") + topHash, err := worktree.Head(t.Context()) + require.NoError(t, err) + + bottom, err := repo.SubmitChange(t.Context(), forge.SubmitChangeRequest{ + Subject: "Bottom change", + Base: "main", + Head: "bottom", + }) + require.NoError(t, err) + top, err := repo.SubmitChange(t.Context(), forge.SubmitChangeRequest{ + Subject: "Top change", + Base: "bottom", + Head: "top", + }) + require.NoError(t, err) + + return mergeRangeFixture{ + sh: sh, + repo: repo, + mainHash: mainHash.String(), + topHash: topHash, + request: forge.MergeRangeRequest{Changes: []forge.MergeRangeChange{ + { + Change: bottom.ID, + Base: "main", + Head: "bottom", + HeadHash: bottomHash, + }, + { + Change: top.ID, + Base: "bottom", + Head: "top", + HeadHash: topHash, + }, + }}, + } +} + +func writeCommitAndPush( + t *testing.T, + worktree *git.Worktree, + workDir string, + filename string, + contents string, + message string, + branch string, +) { + t.Helper() + + require.NoError(t, os.WriteFile( + filepath.Join(workDir, filename), []byte(contents), 0o644, + )) + gitAdd(t, workDir, filename) + require.NoError(t, worktree.Commit( + t.Context(), git.CommitRequest{Message: message}, + )) + require.NoError(t, worktree.Push(t.Context(), git.PushOptions{ + Remote: "origin", + Refspec: git.Refspec(branch + ":" + branch), + })) +} + +func rejectGitSubcommand(t *testing.T, realGit, rejected string) string { + t.Helper() + + gitWrapper := filepath.Join(t.TempDir(), "git") + require.NoError(t, os.WriteFile( + gitWrapper, + fmt.Appendf(nil, `#!/bin/sh +for arg in "$@"; do + if [ "$arg" = "%s" ]; then + exit 1 + fi +done +exec "%s" "$@" +`, rejected, realGit), + 0o755, + )) + return gitWrapper +} + +func assertMergeRangeCompleted(t *testing.T, fixture mergeRangeFixture) { + t.Helper() + + statuses, err := fixture.repo.ChangeStatuses( + t.Context(), []forge.ChangeID{ChangeID(1), ChangeID(2)}, + ) + require.NoError(t, err) + assert.Equal(t, []forge.ChangeStatus{ + {State: forge.ChangeMerged, HeadHash: fixture.request.Changes[0].HeadHash}, + {State: forge.ChangeMerged, HeadHash: fixture.request.Changes[1].HeadHash}, + }, statuses) + assert.NotEqual(t, fixture.mainHash, resolveTestBranch(t, fixture.sh, "main")) +} + +func assertMergeRangeUnchanged(t *testing.T, fixture mergeRangeFixture) { + t.Helper() + + assert.Equal(t, fixture.mainHash, resolveTestBranch(t, fixture.sh, "main")) + statuses, err := fixture.repo.ChangeStatuses( + t.Context(), []forge.ChangeID{ChangeID(1), ChangeID(2)}, + ) + require.NoError(t, err) + assert.Equal(t, forge.ChangeOpen, statuses[0].State) + assert.Equal(t, forge.ChangeOpen, statuses[1].State) +} + +func resolveTestBranch(t *testing.T, sh *ShamHub, branch string) string { + t.Helper() + + hash, err := sh.gitCmd( + t.Context(), "alice", "example", "rev-parse", "refs/heads/"+branch+"^{commit}", + ).OutputChomp() + require.NoError(t, err) + return hash +} + +func resolveTestTree(t *testing.T, sh *ShamHub, commit string) string { + t.Helper() + + tree, err := sh.gitCmd( + t.Context(), "alice", "example", "rev-parse", commit+"^{tree}", + ).OutputChomp() + require.NoError(t, err) + return tree +} diff --git a/internal/forge/shamhub/repo.go b/internal/forge/shamhub/repo.go index 21284655c..360278fa0 100644 --- a/internal/forge/shamhub/repo.go +++ b/internal/forge/shamhub/repo.go @@ -125,6 +125,14 @@ type forgeRepository struct { client *jsonHTTPClient } +// stackRepository is separate from forgeRepository so ShamHub exposes its +// optional native-stack capabilities only when test configuration enables +// them. Callers therefore exercise the same capability upcasts and fallbacks +// used with production forges. +type stackRepository struct { + *forgeRepository +} + var ( _ forge.Repository = (*forgeRepository)(nil) _ forge.WithComparisonURL = (*forgeRepository)(nil) diff --git a/internal/forge/shamhub/shamhub.go b/internal/forge/shamhub/shamhub.go index a883a873c..6f0c5bbfe 100644 --- a/internal/forge/shamhub/shamhub.go +++ b/internal/forge/shamhub/shamhub.go @@ -48,6 +48,11 @@ type ShamHub struct { feedbackSubmissions []shamFeedbackSubmission // all feedback submissions repos []shamRepo // all repositories + // stackBases stores the immediate base for every change represented in a + // native stack. A zero base number marks a root; an absent change has no + // native-stack relationship. + stackBases map[repoID]map[int]int // repository -> change -> base change + tokens map[string]string // token -> username defaultMergeMethod MergeMethod // used when API merge requests omit a method // changeTemplateErrorDelay makes the change-template endpoint return a @@ -122,6 +127,7 @@ func New(cfg Config) (*ShamHub, error) { keepGitRoot: cfg.KeepGitRoot, adminToken: adminToken, tokens: make(map[string]string), + stackBases: make(map[repoID]map[int]int), defaultMergeMethod: MergeMethodMerge, } var err error diff --git a/internal/forge/shamhub/stacks.go b/internal/forge/shamhub/stacks.go new file mode 100644 index 000000000..f1f8d0c73 --- /dev/null +++ b/internal/forge/shamhub/stacks.go @@ -0,0 +1,189 @@ +package shamhub + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/forge" +) + +type stackChange struct { + Number int `json:"number"` + Base int `json:"base,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` +} + +type updateStackRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` + + Changes []stackChange `json:"changes"` +} + +type updateStackResponse struct{} + +var _ = shamhubRESTHandler( + "POST /{owner}/{repo}/stack/update", + (*ShamHub).handleUpdateStack, +) + +func (sh *ShamHub) handleUpdateStack( + _ context.Context, + req *updateStackRequest, +) (*updateStackResponse, error) { + if err := sh.updateStack(req.Owner, req.Repo, req.Changes); err != nil { + return nil, err + } + return &updateStackResponse{}, nil +} + +// updateStack atomically replaces every stored native-stack component that +// intersects the request. Validation finishes before existing relationships +// are changed. +func (sh *ShamHub) updateStack( + owner string, + repo string, + changes []stackChange, +) error { + sh.mu.Lock() + defer sh.mu.Unlock() + + // Resolve and validate the complete request before altering stack state. + changesByNumber := make(map[int]*shamChange, len(changes)) + for i := range sh.changes { + change := &sh.changes[i] + if change.Base.Owner == owner && change.Base.Repo == repo { + changesByNumber[change.Number] = change + } + } + for _, member := range changes { + change := changesByNumber[member.Number] + if change == nil { + return fmt.Errorf("change %d (%s/%s) not found", member.Number, owner, repo) + } + if change.State != shamChangeOpen { + return fmt.Errorf("change %d is not open", member.Number) + } + if member.Base == 0 { + continue + } + + base := changesByNumber[member.Base] + if base == nil { + return fmt.Errorf("base change %d not found", member.Base) + } + if member.BaseBranch != base.Head.Name { + return fmt.Errorf( + "change %d desired base %q does not match change %d head %q", + member.Number, member.BaseBranch, member.Base, base.Head.Name, + ) + } + } + + repository := repoID{Owner: owner, Name: repo} + baseByChange := sh.stackBases[repository] + if baseByChange == nil { + baseByChange = make(map[int]int) + sh.stackBases[repository] = baseByChange + } + + // Discover each complete stored component touched by the request. Walking + // both downstack and upstack ensures that replacement also removes old + // divergent paths omitted from the new representation. + touched := make(map[int]struct{}, len(changes)) + for _, change := range changes { + touched[change.Number] = struct{}{} + } + for changed := true; changed; { + changed = false + for above, below := range baseByChange { + _, aboveTouched := touched[above] + _, belowTouched := touched[below] + if !aboveTouched && !belowTouched { + continue + } + if !aboveTouched { + touched[above] = struct{}{} + changed = true + } + if below != 0 && !belowTouched { + touched[below] = struct{}{} + changed = true + } + } + } + + // Replace the touched components only after their full extent is known. + for number := range touched { + delete(baseByChange, number) + } + for _, change := range changes { + baseByChange[change.Number] = change.Base + changesByNumber[change.Number].Base.Name = change.BaseBranch + } + return nil +} + +var _ forge.StackRepository = (*stackRepository)(nil) + +// PlanStackUpdate prepares an atomic replacement of ShamHub's native stack +// relationships and provider-facing change bases. +func (r *stackRepository) PlanStackUpdate( + _ context.Context, + changes []forge.StackChange, +) (forge.StackUpdatePlan, error) { + requestedChanges := make(map[ChangeID]struct{}, len(changes)) + for _, change := range changes { + requestedChanges[change.Change.(ChangeID)] = struct{}{} + } + + req := updateStackRequest{ + Changes: make([]stackChange, len(changes)), + } + // The forge contract treats a base outside the request as a root. Preserve + // that distinction at the transport boundary instead of asking the server + // to reconstruct caller intent. + for i, change := range changes { + req.Changes[i].Number = int(change.Change.(ChangeID)) + req.Changes[i].BaseBranch = change.BaseBranch + base, ok := change.BaseChange.(ChangeID) + if !ok { + continue + } + if _, ok := requestedChanges[base]; ok { + req.Changes[i].Base = int(base) + } + } + + return &shamHubStackUpdatePlan{repository: r, request: req}, nil +} + +type shamHubStackUpdatePlan struct { + repository *stackRepository + request updateStackRequest + executed bool +} + +func (p *shamHubStackUpdatePlan) Execute(ctx context.Context) error { + if p.executed { + return errors.New("ShamHub stack update plan was already executed") + } + p.executed = true + + var res updateStackResponse + if err := p.repository.client.Post( + ctx, + p.repository.apiURL.JoinPath( + p.repository.owner, + p.repository.repo, + "stack", + "update", + ).String(), + p.request, + &res, + ); err != nil { + return fmt.Errorf("execute stack update: %w", err) + } + return nil +} diff --git a/internal/forge/shamhub/stacks_test.go b/internal/forge/shamhub/stacks_test.go new file mode 100644 index 000000000..271894653 --- /dev/null +++ b/internal/forge/shamhub/stacks_test.go @@ -0,0 +1,146 @@ +package shamhub + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/forge" +) + +func executeStackUpdate( + ctx context.Context, + repository *stackRepository, + changes []forge.StackChange, +) error { + plan, err := repository.PlanStackUpdate(ctx, changes) + if err != nil { + return err + } + return plan.Execute(ctx) +} + +func TestStackRepository_UpdateStack(t *testing.T) { + sh, repo := newMergeabilityTestRepository(t) + seedStackChanges(sh) + stacks := &stackRepository{forgeRepository: repo} + + require.NoError(t, executeStackUpdate(t.Context(), stacks, []forge.StackChange{ + {Change: ChangeID(3), BaseChange: ChangeID(1), BaseBranch: "bottom"}, + {Change: ChangeID(2), BaseChange: ChangeID(1), BaseBranch: "bottom"}, + {Change: ChangeID(1), BaseBranch: "main"}, + })) + + assert.Equal(t, map[int]int{1: 0, 2: 1, 3: 1}, + sh.stackBases[repoID{Owner: "alice", Name: "example"}]) +} + +func TestStackRepository_PlanStackUpdateIsReadOnly(t *testing.T) { + sh, repo := newMergeabilityTestRepository(t) + seedStackChanges(sh) + stacks := &stackRepository{forgeRepository: repo} + + plan, err := stacks.PlanStackUpdate(t.Context(), []forge.StackChange{ + {Change: ChangeID(2), BaseBranch: "main"}, + }) + require.NoError(t, err) + assert.Equal(t, "bottom", sh.changes[1].Base.Name) + require.NoError(t, plan.Execute(t.Context())) + assert.Equal(t, "main", sh.changes[1].Base.Name) +} + +func TestStackRepository_UpdateStack_replacesTouchedComponent(t *testing.T) { + sh, repo := newMergeabilityTestRepository(t) + seedStackChanges(sh) + stacks := &stackRepository{forgeRepository: repo} + + require.NoError(t, executeStackUpdate(t.Context(), stacks, []forge.StackChange{ + {Change: ChangeID(1), BaseBranch: "main"}, + {Change: ChangeID(2), BaseChange: ChangeID(1), BaseBranch: "bottom"}, + {Change: ChangeID(4), BaseChange: ChangeID(2), BaseBranch: "left"}, + })) + require.NoError(t, executeStackUpdate(t.Context(), stacks, []forge.StackChange{ + {Change: ChangeID(2), BaseBranch: "main"}, + {Change: ChangeID(4), BaseChange: ChangeID(2), BaseBranch: "left"}, + })) + + assert.Equal(t, map[int]int{2: 0, 4: 2}, + sh.stackBases[repoID{Owner: "alice", Name: "example"}]) +} + +func TestStackRepository_PlanMergeRanges_usesStoredDivergence(t *testing.T) { + sh, repo := newMergeabilityTestRepository(t) + seedStackChanges(sh) + stacks := &stackRepository{forgeRepository: repo} + changes := []forge.StackChange{ + {Change: ChangeID(1), BaseBranch: "main"}, + {Change: ChangeID(2), BaseChange: ChangeID(1), BaseBranch: "bottom"}, + {Change: ChangeID(3), BaseChange: ChangeID(1), BaseBranch: "bottom"}, + {Change: ChangeID(4), BaseChange: ChangeID(2), BaseBranch: "left"}, + } + require.NoError(t, executeStackUpdate(t.Context(), stacks, changes)) + + plans, err := stacks.PlanMergeRanges(t.Context(), changes) + require.NoError(t, err) + require.Len(t, plans, 3) + assert.Equal(t, []forge.ChangeID{ChangeID(1)}, plans[0].Changes()) + assert.Equal(t, []forge.ChangeID{ChangeID(2), ChangeID(4)}, plans[1].Changes()) + assert.Equal(t, []forge.ChangeID{ChangeID(3)}, plans[2].Changes()) +} + +func TestShamHub_UpdateStack_repositoryIsolation(t *testing.T) { + sh, _ := newMergeabilityTestRepository(t) + seedStackChanges(sh) + sh.changes = append(sh.changes, + shamChange{ + Number: 1, + Base: &shamBranch{Owner: "bob", Repo: "other", Name: "main"}, + Head: &shamBranch{Owner: "bob", Repo: "other", Name: "bottom"}, + }, + shamChange{ + Number: 2, + Base: &shamBranch{Owner: "bob", Repo: "other", Name: "bottom"}, + Head: &shamBranch{Owner: "bob", Repo: "other", Name: "top"}, + }, + ) + + require.NoError(t, sh.updateStack("alice", "example", []stackChange{ + {Number: 1, BaseBranch: "main"}, + {Number: 2, Base: 1, BaseBranch: "bottom"}, + })) + require.NoError(t, sh.updateStack("bob", "other", []stackChange{ + {Number: 1, BaseBranch: "main"}, + {Number: 2, Base: 1, BaseBranch: "bottom"}, + })) + + assert.Equal(t, map[int]int{1: 0, 2: 1}, + sh.stackBases[repoID{Owner: "alice", Name: "example"}]) + assert.Equal(t, map[int]int{1: 0, 2: 1}, + sh.stackBases[repoID{Owner: "bob", Name: "other"}]) +} + +func seedStackChanges(sh *ShamHub) { + sh.changes = append(sh.changes, + shamChange{ + Number: 1, + Base: &shamBranch{Owner: "alice", Repo: "example", Name: "main"}, + Head: &shamBranch{Owner: "alice", Repo: "example", Name: "bottom"}, + }, + shamChange{ + Number: 2, + Base: &shamBranch{Owner: "alice", Repo: "example", Name: "bottom"}, + Head: &shamBranch{Owner: "alice", Repo: "example", Name: "left"}, + }, + shamChange{ + Number: 3, + Base: &shamBranch{Owner: "alice", Repo: "example", Name: "bottom"}, + Head: &shamBranch{Owner: "alice", Repo: "example", Name: "right"}, + }, + shamChange{ + Number: 4, + Base: &shamBranch{Owner: "alice", Repo: "example", Name: "left"}, + Head: &shamBranch{Owner: "alice", Repo: "example", Name: "leaf"}, + }, + ) +} diff --git a/internal/forge/shamhub/testdata/TestIntegration/Stacks/bottomBranch b/internal/forge/shamhub/testdata/TestIntegration/Stacks/bottomBranch new file mode 100644 index 000000000..d5fdcda28 --- /dev/null +++ b/internal/forge/shamhub/testdata/TestIntegration/Stacks/bottomBranch @@ -0,0 +1 @@ +"stack-bottom-7k16pABA" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/Stacks/middleBranch b/internal/forge/shamhub/testdata/TestIntegration/Stacks/middleBranch new file mode 100644 index 000000000..90b535404 --- /dev/null +++ b/internal/forge/shamhub/testdata/TestIntegration/Stacks/middleBranch @@ -0,0 +1 @@ +"stack-middle-bSbyTEVU" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/Stacks/topBranch b/internal/forge/shamhub/testdata/TestIntegration/Stacks/topBranch new file mode 100644 index 000000000..9cc71ba70 --- /dev/null +++ b/internal/forge/shamhub/testdata/TestIntegration/Stacks/topBranch @@ -0,0 +1 @@ +"stack-top-IkF6KhzO" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/Stacks.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/Stacks.yaml new file mode 100644 index 000000000..3412bdac4 --- /dev/null +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/Stacks.yaml @@ -0,0 +1,162 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 139 + host: 127.0.0.1:56373 + body: '{"subject":"Native stack bottom stack-bottom-7k16pABA","body":"Native stack integration test","base":"main","head":"stack-bottom-7k16pABA"}' + url: http://127.0.0.1:56373/abhinav/test-repo/changes + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 80 + body: | + { + "number": 1, + "url": "http://127.0.0.1:56374/abhinav/test-repo/change/1" + } + headers: + Content-Length: + - "80" + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 76.573542ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 156 + host: 127.0.0.1:56373 + body: '{"subject":"Native stack middle stack-middle-bSbyTEVU","body":"Native stack integration test","base":"stack-bottom-7k16pABA","head":"stack-middle-bSbyTEVU"}' + url: http://127.0.0.1:56373/abhinav/test-repo/changes + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 80 + body: | + { + "number": 2, + "url": "http://127.0.0.1:56374/abhinav/test-repo/change/2" + } + headers: + Content-Length: + - "80" + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 16.689958ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 48 + host: 127.0.0.1:56373 + body: '{"changes":[{"number":1},{"number":2,"base":1}]}' + url: http://127.0.0.1:56373/abhinav/test-repo/stack/update + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 3 + body: | + {} + headers: + Content-Length: + - "3" + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 158.208µs + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 147 + host: 127.0.0.1:56373 + body: '{"subject":"Native stack top stack-top-IkF6KhzO","body":"Native stack integration test","base":"stack-middle-bSbyTEVU","head":"stack-top-IkF6KhzO"}' + url: http://127.0.0.1:56373/abhinav/test-repo/changes + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 80 + body: | + { + "number": 3, + "url": "http://127.0.0.1:56374/abhinav/test-repo/change/3" + } + headers: + Content-Length: + - "80" + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 16.019916ms + - id: 4 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 70 + host: 127.0.0.1:56373 + body: '{"changes":[{"number":1},{"number":2,"base":1},{"number":3,"base":2}]}' + url: http://127.0.0.1:56373/abhinav/test-repo/stack/update + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 3 + body: | + {} + headers: + Content-Length: + - "3" + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 143.5µs + - id: 5 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 70 + host: 127.0.0.1:56373 + body: '{"changes":[{"number":1},{"number":2,"base":1},{"number":3,"base":2}]}' + url: http://127.0.0.1:56373/abhinav/test-repo/stack/update + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 3 + body: | + {} + headers: + Content-Length: + - "3" + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 93µs