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