From 9367db5b8310e494d84224d7688a36a4d77f1f2d Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Tue, 9 Jun 2026 12:29:29 -0700 Subject: [PATCH] refactor(entity): relocate analyzer/checker/pusher result types to entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the domain-fact data types produced by the conflict, mergechecker, and pusher extensions into entity/submitqueue, leaving each extension with only its behavioral contract (interface, Config, Factory) and sentinels: - conflict.Conflict / ConflictType -> entity.Conflict / entity.ConflictType - mergechecker.Result -> entity.MergeResult - pusher.{Result,BatchOutcome,ChangeOutcome,OutcomeStatus} -> entity.{PushResult,BatchOutcome,ChangeOutcome,OutcomeStatus} These are domain facts (a dependency-graph reason, a mergeability verdict, the commits a change produced) that the orchestrator may persist or surface. Placing them in entity/ — the universal dependency sink that storage, controllers, and extensions all already import — keeps the contract robust to future persistence without a layering inversion (storage importing a decision extension) or an import migration later. This matches what buildrunner and changeprovider already do with entity.BuildStatus / entity.ChangeInfo. The data shapes are unchanged; only their home is. pusher.ErrConflict stays in the extension (a sentinel error is part of the behavioral contract). The controllers were already type-inference-clean, so only their tests change. --- submitqueue/entity/BUILD.bazel | 3 + submitqueue/entity/conflict.go | 45 +++++++++++++ submitqueue/entity/merge_result.go | 24 +++++++ submitqueue/entity/push_result.go | 65 +++++++++++++++++++ .../extension/conflict/all/BUILD.bazel | 1 - submitqueue/extension/conflict/all/all.go | 8 +-- .../extension/conflict/all/all_test.go | 11 ++-- submitqueue/extension/conflict/conflict.go | 32 +-------- submitqueue/extension/conflict/fake/fake.go | 2 +- .../conflict/fileoverlap/BUILD.bazel | 1 - .../extension/conflict/fileoverlap/README.md | 2 - .../conflict/fileoverlap/fileoverlap.go | 10 +-- .../conflict/fileoverlap/fileoverlap_test.go | 3 +- .../extension/conflict/mock/conflict_mock.go | 4 +- submitqueue/extension/conflict/none/none.go | 2 +- .../extension/mergechecker/fake/fake.go | 8 +-- .../extension/mergechecker/github/checker.go | 2 +- .../extension/mergechecker/mergechecker.go | 11 +--- .../mergechecker/mock/mergechecker_mock.go | 4 +- submitqueue/extension/pusher/README.md | 31 +++++---- submitqueue/extension/pusher/fake/fake.go | 20 +++--- .../extension/pusher/fake/fake_test.go | 2 +- .../extension/pusher/git/git_pusher.go | 34 +++++----- .../extension/pusher/git/git_pusher_test.go | 12 ++-- .../extension/pusher/mock/pusher_mock.go | 4 +- submitqueue/extension/pusher/pusher.go | 64 ++---------------- .../controller/batch/batch_test.go | 6 +- .../controller/merge/merge_test.go | 22 +++---- .../controller/validate/validate_test.go | 6 +- 29 files changed, 245 insertions(+), 194 deletions(-) create mode 100644 submitqueue/entity/conflict.go create mode 100644 submitqueue/entity/merge_result.go create mode 100644 submitqueue/entity/push_result.go diff --git a/submitqueue/entity/BUILD.bazel b/submitqueue/entity/BUILD.bazel index 21a57602..ed9bd5d3 100644 --- a/submitqueue/entity/BUILD.bazel +++ b/submitqueue/entity/BUILD.bazel @@ -10,7 +10,10 @@ go_library( "cancel_request.go", "change_provider.go", "change_record.go", + "conflict.go", "land_request.go", + "merge_result.go", + "push_result.go", "queue_config.go", "request.go", "request_log.go", diff --git a/submitqueue/entity/conflict.go b/submitqueue/entity/conflict.go new file mode 100644 index 00000000..d8be8ace --- /dev/null +++ b/submitqueue/entity/conflict.go @@ -0,0 +1,45 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entity + +// ConflictType classifies why two batches are considered to conflict. +// New values may be added as more sophisticated analyzers are introduced. +type ConflictType string + +const ( + // ConflictTypeUnknown is the unreachable zero value, set by default when + // the structure is initialized. It should never be seen in the system. + ConflictTypeUnknown ConflictType = "" + // ConflictTypeConservative means the analyzer treated the batches as + // conflicting because it could not prove otherwise, without identifying a + // specific reason. Used by conservative analyzers that serialize + // everything by default. + ConflictTypeConservative ConflictType = "conservative" + // ConflictTypeTargetOverlap means the two batches modify one or more of + // the same build targets and may therefore interfere with each other. + ConflictTypeTargetOverlap ConflictType = "target_overlap" +) + +// Conflict reports a single conflict between an analyzed batch and one of the +// in-flight batches. +type Conflict struct { + // BatchID is the ID of the in-flight batch that conflicts with the + // analyzed batch. + BatchID string + // Type classifies the conflict. A single (analyzed, in-flight) pair may + // be reported with multiple Conflict entries when different conflict + // types apply. + Type ConflictType +} diff --git a/submitqueue/entity/merge_result.go b/submitqueue/entity/merge_result.go new file mode 100644 index 00000000..ad507820 --- /dev/null +++ b/submitqueue/entity/merge_result.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entity + +// MergeResult holds the outcome of a mergeability check. +type MergeResult struct { + // Mergeable is true if the request's changes are expected to merge cleanly. + Mergeable bool + // Reason is a human-readable explanation when Mergeable is false. + // Empty when Mergeable is true. + Reason string +} diff --git a/submitqueue/entity/push_result.go b/submitqueue/entity/push_result.go new file mode 100644 index 00000000..f78ce8a8 --- /dev/null +++ b/submitqueue/entity/push_result.go @@ -0,0 +1,65 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entity + +// OutcomeStatus describes what happened to a single Change during a push. +type OutcomeStatus string + +const ( + // OutcomeStatusUnknown is the unreachable zero value, set by default + // when the structure is initialized. It should never be seen in the system. + OutcomeStatusUnknown OutcomeStatus = "" + // OutcomeStatusCommitted means the change produced one or more commits + // on the target branch. CommitSHAs lists those commits in apply order. + OutcomeStatusCommitted OutcomeStatus = "committed" + // OutcomeStatusAlreadyExisted means the change produced no commits + // because every part of it is already present in the target branch + // (e.g. it previously landed via another path, or a prior change in + // the same push subsumed it). CommitSHAs is empty for this status. + // In git terms this is what a `cherry-pick` surfaces as "rebased out". + OutcomeStatusAlreadyExisted OutcomeStatus = "already_existed" +) + +// ChangeOutcome describes what happened to a single Change inside a push. +type ChangeOutcome struct { + // Change is the input change this outcome corresponds to. + Change Change + // Status describes whether the change produced commits or was already + // present on the target branch. + Status OutcomeStatus + // CommitSHAs lists the commits this change produced on the target + // branch, in apply order. A single Change may produce multiple commits + // (e.g. a stack of PRs). Empty when Status is OutcomeStatusAlreadyExisted. + CommitSHAs []string +} + +// BatchOutcome groups the per-change outcomes for a single pushed batch, so a +// merge-train push (several batches in one call) stays correlatable back to the +// batch each change belonged to. There is no per-batch status: a push is +// all-or-nothing across the whole call, so a per-batch pass/fail would be +// uniformly redundant. +type BatchOutcome struct { + // BatchID is the input batch this outcome corresponds to. + BatchID string + // Outcomes is one entry per change in the batch, in apply order. + Outcomes []ChangeOutcome +} + +// PushResult is the outcome of a successful push. +type PushResult struct { + // Batches is one entry per pushed batch, in the same order as the batches + // passed to the push. The slice length equals the input length. + Batches []BatchOutcome +} diff --git a/submitqueue/extension/conflict/all/BUILD.bazel b/submitqueue/extension/conflict/all/BUILD.bazel index 0195701f..6872b4ee 100644 --- a/submitqueue/extension/conflict/all/BUILD.bazel +++ b/submitqueue/extension/conflict/all/BUILD.bazel @@ -17,7 +17,6 @@ go_test( embed = [":all"], deps = [ "//submitqueue/entity", - "//submitqueue/extension/conflict", "@com_github_stretchr_testify//assert", "@com_github_stretchr_testify//require", ], diff --git a/submitqueue/extension/conflict/all/all.go b/submitqueue/extension/conflict/all/all.go index d9e91743..baef51f2 100644 --- a/submitqueue/extension/conflict/all/all.go +++ b/submitqueue/extension/conflict/all/all.go @@ -36,15 +36,15 @@ func New() conflict.Analyzer { // Analyze returns one ConflictTypeConservative Conflict per in-flight batch, // preserving the input order. Returns an empty slice when inFlight is empty. -func (analyzer) Analyze(_ context.Context, _ entity.Batch, inFlight []entity.Batch) ([]conflict.Conflict, error) { +func (analyzer) Analyze(_ context.Context, _ entity.Batch, inFlight []entity.Batch) ([]entity.Conflict, error) { if len(inFlight) == 0 { return nil, nil } - conflicts := make([]conflict.Conflict, len(inFlight)) + conflicts := make([]entity.Conflict, len(inFlight)) for i, b := range inFlight { - conflicts[i] = conflict.Conflict{ + conflicts[i] = entity.Conflict{ BatchID: b.ID, - Type: conflict.ConflictTypeConservative, + Type: entity.ConflictTypeConservative, } } return conflicts, nil diff --git a/submitqueue/extension/conflict/all/all_test.go b/submitqueue/extension/conflict/all/all_test.go index df32419c..d034c01b 100644 --- a/submitqueue/extension/conflict/all/all_test.go +++ b/submitqueue/extension/conflict/all/all_test.go @@ -21,7 +21,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/conflict" ) func TestAnalyze(t *testing.T) { @@ -30,7 +29,7 @@ func TestAnalyze(t *testing.T) { tests := []struct { name string inFlight []entity.Batch - want []conflict.Conflict + want []entity.Conflict }{ { name: "no in-flight batches yields no conflicts", @@ -49,10 +48,10 @@ func TestAnalyze(t *testing.T) { {ID: "queueA/batch/2"}, {ID: "queueA/batch/3"}, }, - want: []conflict.Conflict{ - {BatchID: "queueA/batch/1", Type: conflict.ConflictTypeConservative}, - {BatchID: "queueA/batch/2", Type: conflict.ConflictTypeConservative}, - {BatchID: "queueA/batch/3", Type: conflict.ConflictTypeConservative}, + want: []entity.Conflict{ + {BatchID: "queueA/batch/1", Type: entity.ConflictTypeConservative}, + {BatchID: "queueA/batch/2", Type: entity.ConflictTypeConservative}, + {BatchID: "queueA/batch/3", Type: entity.ConflictTypeConservative}, }, }, } diff --git a/submitqueue/extension/conflict/conflict.go b/submitqueue/extension/conflict/conflict.go index 3c1d1e5c..1c1a1bf7 100644 --- a/submitqueue/extension/conflict/conflict.go +++ b/submitqueue/extension/conflict/conflict.go @@ -22,36 +22,6 @@ import ( "github.com/uber/submitqueue/submitqueue/entity" ) -// ConflictType classifies why two batches are considered to conflict. -// New values may be added as more sophisticated analyzers are introduced. -type ConflictType string - -const ( - // ConflictTypeUnknown is the unreachable zero value, set by default when - // the structure is initialized. It should never be seen in the system. - ConflictTypeUnknown ConflictType = "" - // ConflictTypeConservative means the analyzer treated the batches as - // conflicting because it could not prove otherwise, without identifying a - // specific reason. Used by conservative analyzers that serialize - // everything by default. - ConflictTypeConservative ConflictType = "conservative" - // ConflictTypeTargetOverlap means the two batches modify one or more of - // the same build targets and may therefore interfere with each other. - ConflictTypeTargetOverlap ConflictType = "target_overlap" -) - -// Conflict reports a single conflict between the analyzed batch and one of -// the in-flight batches. -type Conflict struct { - // BatchID is the ID of the in-flight batch that conflicts with the - // analyzed batch. - BatchID string - // Type classifies the conflict. A single (analyzed, in-flight) pair may - // be reported with multiple Conflict entries when different conflict - // types apply. - Type ConflictType -} - // Analyzer detects conflicts between a candidate batch and the batches // already in flight, so the speculation layer can decide which batches can // safely advance in parallel. @@ -64,7 +34,7 @@ type Analyzer interface { // should be filtered out before calling. A non-nil error indicates the // analysis itself failed (infrastructure issue) and should be treated as // retryable by the caller. - Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]Conflict, error) + Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]entity.Conflict, error) } // Config carries the per-queue identity handed to a Factory. The system knows diff --git a/submitqueue/extension/conflict/fake/fake.go b/submitqueue/extension/conflict/fake/fake.go index 0f5c0398..dbd3a27a 100644 --- a/submitqueue/extension/conflict/fake/fake.go +++ b/submitqueue/extension/conflict/fake/fake.go @@ -54,7 +54,7 @@ func New(delegate conflict.Analyzer, failOn FailOn) conflict.Analyzer { // Analyze returns an error when failOn reports true; otherwise it delegates to // the wrapped analyzer. -func (a analyzerFake) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]conflict.Conflict, error) { +func (a analyzerFake) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]entity.Conflict, error) { if a.failOn != nil && a.failOn(batch, inFlight) { return nil, fmt.Errorf("fake: injected analyze error for batch %q", batch.ID) } diff --git a/submitqueue/extension/conflict/fileoverlap/BUILD.bazel b/submitqueue/extension/conflict/fileoverlap/BUILD.bazel index 0071e466..81a75a8b 100644 --- a/submitqueue/extension/conflict/fileoverlap/BUILD.bazel +++ b/submitqueue/extension/conflict/fileoverlap/BUILD.bazel @@ -19,7 +19,6 @@ go_test( deps = [ "//submitqueue/core/changeset/fake", "//submitqueue/entity", - "//submitqueue/extension/conflict", "@com_github_stretchr_testify//assert", "@com_github_stretchr_testify//require", ], diff --git a/submitqueue/extension/conflict/fileoverlap/README.md b/submitqueue/extension/conflict/fileoverlap/README.md index 14b12090..d63cf6d3 100644 --- a/submitqueue/extension/conflict/fileoverlap/README.md +++ b/submitqueue/extension/conflict/fileoverlap/README.md @@ -2,8 +2,6 @@ `fileoverlap` is a `conflict.Analyzer` that reports a conflict between two batches when they change one or more of the same files. -It is the first analyzer to exercise the capability the [extension contract](../../../../doc/rfc/submitqueue/extension-contract.md) unblocks: it is handed only batch identity (the candidate batch and the in-flight batches) and resolves each batch's changed files itself through an injected `changeset.Resolver`, rather than depending on a controller to pre-compute them. This is why the `conflict.Analyzer` contract takes identity and resolves internally — a file-overlap analyzer could not be written against a controller-resolved, identity-only batch. - ## Behavior The files a batch changes are drawn from each change's provider-supplied details. The candidate batch conflicts with an in-flight batch when their changed-file sets intersect; each such in-flight batch is reported once, preserving the in-flight order. A shared file is the concrete notion of *target overlap*, so conflicts are classified as `ConflictTypeTargetOverlap`. A batch that changes no files conflicts with nothing, and an empty in-flight list yields no conflicts. A failure to resolve a batch's changes is returned as a (retryable) error. diff --git a/submitqueue/extension/conflict/fileoverlap/fileoverlap.go b/submitqueue/extension/conflict/fileoverlap/fileoverlap.go index 3cebb93e..8c247e36 100644 --- a/submitqueue/extension/conflict/fileoverlap/fileoverlap.go +++ b/submitqueue/extension/conflict/fileoverlap/fileoverlap.go @@ -18,7 +18,7 @@ // only batch identity and resolves each batch's changed files itself through an // injected changeset resolver, rather than depending on the controller to // pre-compute them. A shared file is the concrete notion of target overlap, so -// it reports conflict.ConflictTypeTargetOverlap. +// it reports entity.ConflictTypeTargetOverlap. package fileoverlap import ( @@ -46,7 +46,7 @@ func New(resolver changeset.Resolver) conflict.Analyzer { // Analyze returns one ConflictTypeTargetOverlap Conflict per in-flight batch // that shares a changed file with batch, preserving the in-flight order. A batch // that changes no files conflicts with nothing. -func (a analyzer) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]conflict.Conflict, error) { +func (a analyzer) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]entity.Conflict, error) { if len(inFlight) == 0 { return nil, nil } @@ -59,16 +59,16 @@ func (a analyzer) Analyze(ctx context.Context, batch entity.Batch, inFlight []en return nil, nil } - var conflicts []conflict.Conflict + var conflicts []entity.Conflict for _, other := range inFlight { files, err := a.files(ctx, other) if err != nil { return nil, fmt.Errorf("failed to resolve files for batch %s: %w", other.ID, err) } if intersects(candidate, files) { - conflicts = append(conflicts, conflict.Conflict{ + conflicts = append(conflicts, entity.Conflict{ BatchID: other.ID, - Type: conflict.ConflictTypeTargetOverlap, + Type: entity.ConflictTypeTargetOverlap, }) } } diff --git a/submitqueue/extension/conflict/fileoverlap/fileoverlap_test.go b/submitqueue/extension/conflict/fileoverlap/fileoverlap_test.go index 60bafff4..86b43a4f 100644 --- a/submitqueue/extension/conflict/fileoverlap/fileoverlap_test.go +++ b/submitqueue/extension/conflict/fileoverlap/fileoverlap_test.go @@ -24,7 +24,6 @@ import ( changesetfake "github.com/uber/submitqueue/submitqueue/core/changeset/fake" "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/conflict" ) // detailed builds a BatchChanges whose single change touches the given files. @@ -101,7 +100,7 @@ func TestAnalyze(t *testing.T) { var ids []string for _, c := range got { - assert.Equal(t, conflict.ConflictTypeTargetOverlap, c.Type) + assert.Equal(t, entity.ConflictTypeTargetOverlap, c.Type) ids = append(ids, c.BatchID) } assert.Equal(t, tt.wantBatches, ids) diff --git a/submitqueue/extension/conflict/mock/conflict_mock.go b/submitqueue/extension/conflict/mock/conflict_mock.go index e0a795ca..9c03518e 100644 --- a/submitqueue/extension/conflict/mock/conflict_mock.go +++ b/submitqueue/extension/conflict/mock/conflict_mock.go @@ -43,10 +43,10 @@ func (m *MockAnalyzer) EXPECT() *MockAnalyzerMockRecorder { } // Analyze mocks base method. -func (m *MockAnalyzer) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]conflict.Conflict, error) { +func (m *MockAnalyzer) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]entity.Conflict, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Analyze", ctx, batch, inFlight) - ret0, _ := ret[0].([]conflict.Conflict) + ret0, _ := ret[0].([]entity.Conflict) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/submitqueue/extension/conflict/none/none.go b/submitqueue/extension/conflict/none/none.go index ec48d362..babb2099 100644 --- a/submitqueue/extension/conflict/none/none.go +++ b/submitqueue/extension/conflict/none/none.go @@ -33,6 +33,6 @@ func New() conflict.Analyzer { } // Analyze always returns a nil conflict slice, regardless of inputs. -func (analyzer) Analyze(_ context.Context, _ entity.Batch, _ []entity.Batch) ([]conflict.Conflict, error) { +func (analyzer) Analyze(_ context.Context, _ entity.Batch, _ []entity.Batch) ([]entity.Conflict, error) { return nil, nil } diff --git a/submitqueue/extension/mergechecker/fake/fake.go b/submitqueue/extension/mergechecker/fake/fake.go index c66165ab..7d497074 100644 --- a/submitqueue/extension/mergechecker/fake/fake.go +++ b/submitqueue/extension/mergechecker/fake/fake.go @@ -53,13 +53,13 @@ func New() mergechecker.MergeChecker { // Check reports the change as mergeable unless a recognized marker token is // present in one of the request's change URIs. -func (checker) Check(_ context.Context, request entity.Request) (mergechecker.Result, error) { +func (checker) Check(_ context.Context, request entity.Request) (entity.MergeResult, error) { switch fakemarker.Token(request.Change.URIs) { case tokenUnmergeable: - return mergechecker.Result{Mergeable: false, Reason: "fake: marked unmergeable"}, nil + return entity.MergeResult{Mergeable: false, Reason: "fake: marked unmergeable"}, nil case tokenError: - return mergechecker.Result{}, fmt.Errorf("fake: marked merge-check error") + return entity.MergeResult{}, fmt.Errorf("fake: marked merge-check error") default: - return mergechecker.Result{Mergeable: true}, nil + return entity.MergeResult{Mergeable: true}, nil } } diff --git a/submitqueue/extension/mergechecker/github/checker.go b/submitqueue/extension/mergechecker/github/checker.go index 4a5134aa..7d2665cc 100644 --- a/submitqueue/extension/mergechecker/github/checker.go +++ b/submitqueue/extension/mergechecker/github/checker.go @@ -61,7 +61,7 @@ func NewMergeChecker(params Params) mergechecker.MergeChecker { } // Check assesses whether a request's change can merge cleanly using the GitHub GraphQL API. -func (c *mergeChecker) Check(ctx context.Context, request entity.Request) (result mergechecker.Result, retErr error) { +func (c *mergeChecker) Check(ctx context.Context, request entity.Request) (result entity.MergeResult, retErr error) { const opName = "check" op := metrics.Begin(c.metricsScope, opName) diff --git a/submitqueue/extension/mergechecker/mergechecker.go b/submitqueue/extension/mergechecker/mergechecker.go index 28c95d8e..7ad8c41c 100644 --- a/submitqueue/extension/mergechecker/mergechecker.go +++ b/submitqueue/extension/mergechecker/mergechecker.go @@ -28,16 +28,7 @@ type MergeChecker interface { // whether the request's changes can be merged. It is handed the request // identity and reads request.Change itself. A positive result does not // guarantee that the changes will apply cleanly at merge time. - Check(ctx context.Context, request entity.Request) (Result, error) -} - -// Result holds the outcome of a mergeability check. -type Result struct { - // Mergeable is true if the request's changes are expected to merge cleanly. - Mergeable bool - // Reason is a human-readable explanation when Mergeable is false. - // Empty when Mergeable is true. - Reason string + Check(ctx context.Context, request entity.Request) (entity.MergeResult, error) } // Config carries the per-queue identity handed to a Factory. The system knows diff --git a/submitqueue/extension/mergechecker/mock/mergechecker_mock.go b/submitqueue/extension/mergechecker/mock/mergechecker_mock.go index 08f28f8c..48b1237c 100644 --- a/submitqueue/extension/mergechecker/mock/mergechecker_mock.go +++ b/submitqueue/extension/mergechecker/mock/mergechecker_mock.go @@ -43,10 +43,10 @@ func (m *MockMergeChecker) EXPECT() *MockMergeCheckerMockRecorder { } // Check mocks base method. -func (m *MockMergeChecker) Check(ctx context.Context, request entity.Request) (mergechecker.Result, error) { +func (m *MockMergeChecker) Check(ctx context.Context, request entity.Request) (entity.MergeResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Check", ctx, request) - ret0, _ := ret[0].(mergechecker.Result) + ret0, _ := ret[0].(entity.MergeResult) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/submitqueue/extension/pusher/README.md b/submitqueue/extension/pusher/README.md index bb16ac84..f1a666db 100644 --- a/submitqueue/extension/pusher/README.md +++ b/submitqueue/extension/pusher/README.md @@ -1,14 +1,17 @@ # Pusher -Pluggable abstraction for landing a list of `entity.Change` values onto a +Pluggable abstraction for landing the changes of one or more batches onto a target branch and pushing the result to a source-control remote. ## Interface -`Pusher` exposes a single `Push` method that accepts a list of changes. -Implementations are bound to a specific `(checkout, remote, target)` tuple -at construction time, so the interface itself stays vendor- and -configuration-agnostic. +`Pusher` exposes a single `Push` method that accepts an ordered list of +batches and resolves each batch's changes itself through an injected changeset +resolver. Implementations are bound to a specific `(checkout, remote, target)` +tuple at construction time, so the interface itself stays vendor- and +configuration-agnostic. The batch list designs for a merge-train (landing +several ready batches in one atomic push); today the merge stage passes a +single batch. The interface enforces an **all-or-nothing atomicity contract**: when `Push` returns an error, no change has reached the remote — neither partially nor @@ -16,17 +19,21 @@ fully. Callers can treat a non-nil error as "the remote is exactly as it was before the call". The `ErrConflict` sentinel marks user-caused failures so callers can route them to a non-retry path. -A successful `Push` returns one `ChangeOutcome` per input change in input -order. Each outcome reports either: +A successful `Push` returns one `entity.BatchOutcome` per input batch, in input +order, each carrying one `entity.ChangeOutcome` per change in that batch. Each +change outcome reports either: -- `OutcomeStatusCommitted` with the list of `CommitSHAs` produced on the +- `entity.OutcomeStatusCommitted` with the list of `CommitSHAs` produced on the target branch (one change can land as multiple commits, e.g. a stack of PRs); or -- `OutcomeStatusAlreadyExisted` with no commits, when the change is already - present on the target branch (previously landed via another path, or +- `entity.OutcomeStatusAlreadyExisted` with no commits, when the change is + already present on the target branch (previously landed via another path, or subsumed by an earlier change in the same push). Git surfaces this as "rebased out" during a cherry-pick. +There is no per-batch status: the push is all-or-nothing across the whole call, +so a per-batch pass/fail would be uniformly redundant. + ## Implementations - [`git/`](git/) — applies changes against a local checkout via `git @@ -42,7 +49,9 @@ order. Each outcome reports either: 1. Create `extension/pusher/{backend}/` with a `Pusher` implementation. 2. Bind the implementation to its checkout/remote/target at construction. -3. Map each `entity.Change` to the backend's commit/push primitives. +3. Resolve each batch's changes via the injected resolver, then map each + `entity.Change` to the backend's commit/push primitives. 4. Honour the atomicity contract: never publish partial state. Return `ErrConflict` (wrapped) for user-caused apply failures and a plain error for transient infra failures. + diff --git a/submitqueue/extension/pusher/fake/fake.go b/submitqueue/extension/pusher/fake/fake.go index ea6e50e8..50f5b5e7 100644 --- a/submitqueue/extension/pusher/fake/fake.go +++ b/submitqueue/extension/pusher/fake/fake.go @@ -63,13 +63,13 @@ func New(resolver changeset.Resolver) pusher.Pusher { // Push resolves each batch's changes and reports every change as committed with // a synthetic commit SHA, grouped per batch, unless a recognized marker token in // one of the changes requests a failure. -func (p *fakePusher) Push(ctx context.Context, batches []entity.Batch) (pusher.Result, error) { +func (p *fakePusher) Push(ctx context.Context, batches []entity.Batch) (entity.PushResult, error) { perBatch := make([][]entity.Change, len(batches)) var all []entity.Change for i, b := range batches { cs, err := p.resolver.ChangesForBatch(ctx, b) if err != nil { - return pusher.Result{}, fmt.Errorf("fake: resolve batch %s: %w", b.ID, err) + return entity.PushResult{}, fmt.Errorf("fake: resolve batch %s: %w", b.ID, err) } perBatch[i] = cs all = append(all, cs...) @@ -77,23 +77,23 @@ func (p *fakePusher) Push(ctx context.Context, batches []entity.Batch) (pusher.R switch fakemarker.TokenInChanges(all) { case tokenConflict: - return pusher.Result{}, pusher.ErrConflict + return entity.PushResult{}, pusher.ErrConflict case tokenError: - return pusher.Result{}, fmt.Errorf("fake: marked push error") + return entity.PushResult{}, fmt.Errorf("fake: marked push error") } - result := make([]pusher.BatchOutcome, len(batches)) + result := make([]entity.BatchOutcome, len(batches)) for i, b := range batches { - outcomes := make([]pusher.ChangeOutcome, 0, len(perBatch[i])) + outcomes := make([]entity.ChangeOutcome, 0, len(perBatch[i])) for _, change := range perBatch[i] { sha := fmt.Sprintf("fake-%d", p.counter.Add(1)) - outcomes = append(outcomes, pusher.ChangeOutcome{ + outcomes = append(outcomes, entity.ChangeOutcome{ Change: change, - Status: pusher.OutcomeStatusCommitted, + Status: entity.OutcomeStatusCommitted, CommitSHAs: []string{sha}, }) } - result[i] = pusher.BatchOutcome{BatchID: b.ID, Outcomes: outcomes} + result[i] = entity.BatchOutcome{BatchID: b.ID, Outcomes: outcomes} } - return pusher.Result{Batches: result}, nil + return entity.PushResult{Batches: result}, nil } diff --git a/submitqueue/extension/pusher/fake/fake_test.go b/submitqueue/extension/pusher/fake/fake_test.go index 55249d55..645ede69 100644 --- a/submitqueue/extension/pusher/fake/fake_test.go +++ b/submitqueue/extension/pusher/fake/fake_test.go @@ -45,7 +45,7 @@ func TestPusher_Push_Committed(t *testing.T) { seen := map[string]bool{} for i, out := range res.Batches[0].Outcomes { assert.Equal(t, changes[i], out.Change) - assert.Equal(t, pusher.OutcomeStatusCommitted, out.Status) + assert.Equal(t, entity.OutcomeStatusCommitted, out.Status) require.Len(t, out.CommitSHAs, 1) assert.False(t, seen[out.CommitSHAs[0]], "commit SHAs must be unique") seen[out.CommitSHAs[0]] = true diff --git a/submitqueue/extension/pusher/git/git_pusher.go b/submitqueue/extension/pusher/git/git_pusher.go index 9508fb9d..f2c643b5 100644 --- a/submitqueue/extension/pusher/git/git_pusher.go +++ b/submitqueue/extension/pusher/git/git_pusher.go @@ -133,7 +133,7 @@ func NewPusher(params Params) pusher.Pusher { } // Push fulfils the pusher.Pusher contract. -func (p *gitPusher) Push(ctx context.Context, batches []entity.Batch) (ret pusher.Result, retErr error) { +func (p *gitPusher) Push(ctx context.Context, batches []entity.Batch) (ret entity.PushResult, retErr error) { op := coremetrics.Begin(p.metricsScope, "push") defer func() { op.Complete(retErr) }() @@ -144,7 +144,7 @@ func (p *gitPusher) Push(ctx context.Context, batches []entity.Batch) (ret pushe for i, b := range batches { cs, err := p.resolver.ChangesForBatch(ctx, b) if err != nil { - return pusher.Result{}, fmt.Errorf("resolve batch %s: %w", b.ID, err) + return entity.PushResult{}, fmt.Errorf("resolve batch %s: %w", b.ID, err) } perBatch[i] = cs changes = append(changes, cs...) @@ -155,7 +155,7 @@ func (p *gitPusher) Push(ctx context.Context, batches []entity.Batch) (ret pushe if len(changes) == 0 { coremetrics.NamedCounter(p.metricsScope, "push", "empty_changes", 1) - return pusher.Result{}, fmt.Errorf("push called with no changes") + return entity.PushResult{}, fmt.Errorf("push called with no changes") } p.logger.Debugw("starting push", @@ -172,7 +172,7 @@ func (p *gitPusher) Push(ctx context.Context, batches []entity.Batch) (ret pushe "target", p.target, "outcomes", outcomes, ) - return pusher.Result{Batches: groupByBatch(batches, perBatch, outcomes)}, nil + return entity.PushResult{Batches: groupByBatch(batches, perBatch, outcomes)}, nil } // Was the failure caused by the remote tip moving under us between @@ -183,14 +183,14 @@ func (p *gitPusher) Push(ctx context.Context, batches []entity.Batch) (ret pushe // git error message formats. baseSHA is empty when the failure // happened before reset captured a base; treat those as fatal too. if baseSHA == "" { - return pusher.Result{}, err + return entity.PushResult{}, err } currentSHA, refetchErr := p.refetchTipSHA(ctx) if refetchErr != nil { - return pusher.Result{}, fmt.Errorf("refetch after push failure failed: %v (original push error: %w)", refetchErr, err) + return entity.PushResult{}, fmt.Errorf("refetch after push failure failed: %v (original push error: %w)", refetchErr, err) } if currentSHA == baseSHA { - return pusher.Result{}, err + return entity.PushResult{}, err } coremetrics.NamedCounter(p.metricsScope, "push", "stale_base_retries", 1) @@ -205,17 +205,17 @@ func (p *gitPusher) Push(ctx context.Context, batches []entity.Batch) (ret pushe } coremetrics.NamedCounter(p.metricsScope, "push", "stale_base_giveup", 1) - return pusher.Result{}, fmt.Errorf("exceeded %d push attempts due to remote contention: %w", p.maxPushAttempts, lastErr) + return entity.PushResult{}, fmt.Errorf("exceeded %d push attempts due to remote contention: %w", p.maxPushAttempts, lastErr) } // groupByBatch splits the flat, apply-ordered outcomes back into one // BatchOutcome per input batch, using each batch's resolved change count. -func groupByBatch(batches []entity.Batch, perBatch [][]entity.Change, outcomes []pusher.ChangeOutcome) []pusher.BatchOutcome { - result := make([]pusher.BatchOutcome, len(batches)) +func groupByBatch(batches []entity.Batch, perBatch [][]entity.Change, outcomes []entity.ChangeOutcome) []entity.BatchOutcome { + result := make([]entity.BatchOutcome, len(batches)) pos := 0 for i, b := range batches { n := len(perBatch[i]) - result[i] = pusher.BatchOutcome{BatchID: b.ID, Outcomes: outcomes[pos : pos+n]} + result[i] = entity.BatchOutcome{BatchID: b.ID, Outcomes: outcomes[pos : pos+n]} pos += n } return result @@ -226,7 +226,7 @@ func groupByBatch(batches []entity.Batch, perBatch [][]entity.Change, outcomes [ // so the caller can distinguish concurrent-push contention from other push // failures. baseSHA is empty when the failure happened before reset // produced a base. -func (p *gitPusher) tryPush(ctx context.Context, changes []entity.Change) (string, []pusher.ChangeOutcome, error) { +func (p *gitPusher) tryPush(ctx context.Context, changes []entity.Change) (string, []entity.ChangeOutcome, error) { if err := p.resetToRemote(ctx); err != nil { coremetrics.NamedCounter(p.metricsScope, "push", "reset_errors", 1) return "", nil, err @@ -295,18 +295,18 @@ func (p *gitPusher) resetToRemote(ctx context.Context) error { // cherryPickAll walks the changes in order, cherry-picking every URI's head // SHA, and returns one ChangeOutcome per Change in the same order. -func (p *gitPusher) cherryPickAll(ctx context.Context, changes []entity.Change) ([]pusher.ChangeOutcome, error) { - outcomes := make([]pusher.ChangeOutcome, 0, len(changes)) +func (p *gitPusher) cherryPickAll(ctx context.Context, changes []entity.Change) ([]entity.ChangeOutcome, error) { + outcomes := make([]entity.ChangeOutcome, 0, len(changes)) for _, change := range changes { commits, err := p.cherryPickChange(ctx, change) if err != nil { return nil, err } - status := pusher.OutcomeStatusCommitted + status := entity.OutcomeStatusCommitted if len(commits) == 0 { - status = pusher.OutcomeStatusAlreadyExisted + status = entity.OutcomeStatusAlreadyExisted } - outcomes = append(outcomes, pusher.ChangeOutcome{ + outcomes = append(outcomes, entity.ChangeOutcome{ Change: change, Status: status, CommitSHAs: commits, diff --git a/submitqueue/extension/pusher/git/git_pusher_test.go b/submitqueue/extension/pusher/git/git_pusher_test.go index 57e4a9e8..890e4466 100644 --- a/submitqueue/extension/pusher/git/git_pusher_test.go +++ b/submitqueue/extension/pusher/git/git_pusher_test.go @@ -261,7 +261,7 @@ func TestPusher_Push_SingleChangeSingleURIProducesOneCommit(t *testing.T) { require.Len(t, res.Batches[0].Outcomes, 1) out := res.Batches[0].Outcomes[0] - assert.Equal(t, pusher.OutcomeStatusCommitted, out.Status) + assert.Equal(t, entity.OutcomeStatusCommitted, out.Status) require.Len(t, out.CommitSHAs, 1) assert.Equal(t, []string{out.CommitSHAs[0]}, f.remoteCommitsSinceSeed(t)) assert.Equal(t, "hello\nearth\n", f.remoteFile(t, "hello.txt")) @@ -293,7 +293,7 @@ func TestPusher_Push_StackedURIsProduceMultipleCommitsForOneChange(t *testing.T) require.Len(t, res.Batches[0].Outcomes, 1) out := res.Batches[0].Outcomes[0] - assert.Equal(t, pusher.OutcomeStatusCommitted, out.Status) + assert.Equal(t, entity.OutcomeStatusCommitted, out.Status) require.Len(t, out.CommitSHAs, 2) assert.Equal(t, out.CommitSHAs, f.remoteCommitsSinceSeed(t)) assert.Equal(t, "hello\nearth\ngoodbye\n", f.remoteFile(t, "hello.txt")) @@ -316,7 +316,7 @@ func TestPusher_Push_AlreadyLandedChangeIsRebasedOut(t *testing.T) { require.Len(t, res.Batches[0].Outcomes, 1) out := res.Batches[0].Outcomes[0] - assert.Equal(t, pusher.OutcomeStatusAlreadyExisted, out.Status) + assert.Equal(t, entity.OutcomeStatusAlreadyExisted, out.Status) assert.Empty(t, out.CommitSHAs) assert.Equal(t, mainBeforePush, f.remoteHEAD(t), "rebased-out push should not advance the remote tip") @@ -336,10 +336,10 @@ func TestPusher_Push_MixedChangesPartiallyRebasedOut(t *testing.T) { require.NoError(t, err) require.Len(t, res.Batches[0].Outcomes, 2) - assert.Equal(t, pusher.OutcomeStatusAlreadyExisted, res.Batches[0].Outcomes[0].Status) + assert.Equal(t, entity.OutcomeStatusAlreadyExisted, res.Batches[0].Outcomes[0].Status) assert.Empty(t, res.Batches[0].Outcomes[0].CommitSHAs) - assert.Equal(t, pusher.OutcomeStatusCommitted, res.Batches[0].Outcomes[1].Status) + assert.Equal(t, entity.OutcomeStatusCommitted, res.Batches[0].Outcomes[1].Status) require.Len(t, res.Batches[0].Outcomes[1].CommitSHAs, 1) assert.Equal(t, "extra\n", f.remoteFile(t, "extra.txt")) @@ -410,7 +410,7 @@ func TestPusher_Push_RecoversAfterPriorConflict(t *testing.T) { f.batchFor("b2", entity.Change{URIs: []string{uri(freshSHA)}}), }) require.NoError(t, err) - assert.Equal(t, pusher.OutcomeStatusCommitted, res.Batches[0].Outcomes[0].Status) + assert.Equal(t, entity.OutcomeStatusCommitted, res.Batches[0].Outcomes[0].Status) assert.Equal(t, "extra\n", f.remoteFile(t, "extra.txt")) } diff --git a/submitqueue/extension/pusher/mock/pusher_mock.go b/submitqueue/extension/pusher/mock/pusher_mock.go index 27345b16..0101e0c7 100644 --- a/submitqueue/extension/pusher/mock/pusher_mock.go +++ b/submitqueue/extension/pusher/mock/pusher_mock.go @@ -43,10 +43,10 @@ func (m *MockPusher) EXPECT() *MockPusherMockRecorder { } // Push mocks base method. -func (m *MockPusher) Push(ctx context.Context, batches []entity.Batch) (pusher.Result, error) { +func (m *MockPusher) Push(ctx context.Context, batches []entity.Batch) (entity.PushResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Push", ctx, batches) - ret0, _ := ret[0].(pusher.Result) + ret0, _ := ret[0].(entity.PushResult) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/submitqueue/extension/pusher/pusher.go b/submitqueue/extension/pusher/pusher.go index 633258e2..b26b74f0 100644 --- a/submitqueue/extension/pusher/pusher.go +++ b/submitqueue/extension/pusher/pusher.go @@ -28,56 +28,6 @@ import ( // treat conflicts as user-caused and non-retryable. var ErrConflict = errors.New("change conflict") -// OutcomeStatus describes what happened to a single Change during a Push. -type OutcomeStatus string - -const ( - // OutcomeStatusUnknown is the unreachable zero value, set by default - // when the structure is initialized. It should never be seen in the system. - OutcomeStatusUnknown OutcomeStatus = "" - // OutcomeStatusCommitted means the change produced one or more commits - // on the target branch. CommitSHAs lists those commits in apply order. - OutcomeStatusCommitted OutcomeStatus = "committed" - // OutcomeStatusAlreadyExisted means the change produced no commits - // because every part of it is already present in the target branch - // (e.g. it previously landed via another path, or a prior change in - // the same push subsumed it). CommitSHAs is empty for this status. - // In git terms this is what a `cherry-pick` surfaces as "rebased out". - OutcomeStatusAlreadyExisted OutcomeStatus = "already_existed" -) - -// ChangeOutcome describes what happened to a single Change inside a Push. -type ChangeOutcome struct { - // Change is the input change this outcome corresponds to. - Change entity.Change - // Status describes whether the change produced commits or was already - // present on the target branch. - Status OutcomeStatus - // CommitSHAs lists the commits this change produced on the target - // branch, in apply order. A single Change may produce multiple commits - // (e.g. a stack of PRs). Empty when Status is OutcomeStatusAlreadyExisted. - CommitSHAs []string -} - -// BatchOutcome groups the per-change outcomes for a single input batch, so a -// merge-train push (several batches in one call) stays correlatable back to the -// batch each change belonged to. There is no per-batch status: Push is -// all-or-nothing across the whole call (see the atomicity contract), so a -// per-batch pass/fail would be uniformly redundant. -type BatchOutcome struct { - // BatchID is the input batch this outcome corresponds to. - BatchID string - // Outcomes is one entry per change in the batch, in apply order. - Outcomes []ChangeOutcome -} - -// Result is the outcome of a successful Push call. -type Result struct { - // Batches is one entry per input batch, in the same order as the batches - // passed to Push. The slice length equals the input length. - Batches []BatchOutcome -} - // Pusher applies the changes of one or more batches on top of a target branch // and pushes the result to the source-control remote. Each implementation is // bound to a specific (checkout, remote, target) at construction time and @@ -89,19 +39,19 @@ type Result struct { // when any change fails to apply. Callers can treat a non-nil error as // "the remote is exactly as it was before the call". // -// On success, len(Result.Batches) == len(batches) and Batches[i] describes what -// happened to batches[i], with one ChangeOutcome per change in that batch in -// apply order. A change can produce multiple commits (OutcomeStatusCommitted, -// CommitSHAs populated in apply order) or none at all (OutcomeStatusAlreadyExisted, -// CommitSHAs empty) — the latter happens when the change's content is already -// present on the target branch. +// On success, len(entity.PushResult.Batches) == len(batches) and Batches[i] +// describes what happened to batches[i], with one entity.ChangeOutcome per +// change in that batch in apply order. A change can produce multiple commits +// (entity.OutcomeStatusCommitted, CommitSHAs populated in apply order) or none +// at all (entity.OutcomeStatusAlreadyExisted, CommitSHAs empty) — the latter +// happens when the change's content is already present on the target branch. type Pusher interface { // Push resolves and applies the changes of the given batches, in order, // onto the target branch and pushes the resulting commits. The batch list // designs for a merge-train (land several ready batches in one atomic push); // today merge passes a single batch. See the type-level docs for the // atomicity contract. - Push(ctx context.Context, batches []entity.Batch) (Result, error) + Push(ctx context.Context, batches []entity.Batch) (entity.PushResult, error) } // Config carries the per-queue identity handed to a Factory. The system knows diff --git a/submitqueue/orchestrator/controller/batch/batch_test.go b/submitqueue/orchestrator/controller/batch/batch_test.go index 6e5c07a6..e3987893 100644 --- a/submitqueue/orchestrator/controller/batch/batch_test.go +++ b/submitqueue/orchestrator/controller/batch/batch_test.go @@ -292,9 +292,9 @@ func TestController_Process_AnalyzerSelectsSubset(t *testing.T) { // Analyzer returns duplicate Conflict entries for the same batch (different // conflict types) to prove the controller dedupes by BatchID. analyzer := conflictmock.NewMockAnalyzer(ctrl) - analyzer.EXPECT().Analyze(gomock.Any(), gomock.Any(), gomock.Any()).Return([]conflict.Conflict{ - {BatchID: "test-queue/batch/2", Type: conflict.ConflictTypeConservative}, - {BatchID: "test-queue/batch/2", Type: conflict.ConflictTypeTargetOverlap}, + analyzer.EXPECT().Analyze(gomock.Any(), gomock.Any(), gomock.Any()).Return([]entity.Conflict{ + {BatchID: "test-queue/batch/2", Type: entity.ConflictTypeConservative}, + {BatchID: "test-queue/batch/2", Type: entity.ConflictTypeTargetOverlap}, }, nil) controller := newTestController(t, ctrl, newSequentialCounter(ctrl), mockStorage, analyzer, nil) diff --git a/submitqueue/orchestrator/controller/merge/merge_test.go b/submitqueue/orchestrator/controller/merge/merge_test.go index 13f1d123..6806df73 100644 --- a/submitqueue/orchestrator/controller/merge/merge_test.go +++ b/submitqueue/orchestrator/controller/merge/merge_test.go @@ -118,14 +118,14 @@ func TestController_Process_SuccessfulMerge(t *testing.T) { mockPusher := pushermock.NewMockPusher(ctrl) mockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, batches []entity.Batch) (pusher.Result, error) { + func(_ context.Context, batches []entity.Batch) (entity.PushResult, error) { require.Len(t, batches, 1) assert.Equal(t, batch.ID, batches[0].ID) - return pusher.Result{Batches: []pusher.BatchOutcome{{ + return entity.PushResult{Batches: []entity.BatchOutcome{{ BatchID: batch.ID, - Outcomes: []pusher.ChangeOutcome{{ + Outcomes: []entity.ChangeOutcome{{ Change: change, - Status: pusher.OutcomeStatusCommitted, + Status: entity.OutcomeStatusCommitted, CommitSHAs: []string{"deadbeef"}, }}, }}}, nil @@ -173,10 +173,10 @@ func TestController_Process_ForwardsBatchToPusher(t *testing.T) { mockPusher := pushermock.NewMockPusher(ctrl) mockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, batches []entity.Batch) (pusher.Result, error) { + func(_ context.Context, batches []entity.Batch) (entity.PushResult, error) { require.Len(t, batches, 1) assert.Equal(t, requestIDs, batches[0].Contains, "batch forwarded with Contains in order") - return pusher.Result{Batches: []pusher.BatchOutcome{{BatchID: batchID}}}, nil + return entity.PushResult{Batches: []entity.BatchOutcome{{BatchID: batchID}}}, nil }, ) @@ -217,7 +217,7 @@ func TestController_Process_PushConflictMarksBatchFailed(t *testing.T) { mockPusher := pushermock.NewMockPusher(ctrl) mockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).Return( - pusher.Result{}, + entity.PushResult{}, fmt.Errorf("apply: %w", pusher.ErrConflict), ) @@ -257,7 +257,7 @@ func TestController_Process_PushInfraFailureReturnsError(t *testing.T) { mockPusher := pushermock.NewMockPusher(ctrl) mockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).Return( - pusher.Result{}, + entity.PushResult{}, fmt.Errorf("ssh: connection refused"), ) @@ -415,10 +415,10 @@ func TestController_Process_PublishFailureSurfaces(t *testing.T) { mockPusher := pushermock.NewMockPusher(ctrl) mockPusher.EXPECT().Push(gomock.Any(), gomock.Any()).Return( - pusher.Result{Batches: []pusher.BatchOutcome{{ + entity.PushResult{Batches: []entity.BatchOutcome{{ BatchID: batchID, - Outcomes: []pusher.ChangeOutcome{{ - Status: pusher.OutcomeStatusCommitted, CommitSHAs: []string{"abc"}, + Outcomes: []entity.ChangeOutcome{{ + Status: entity.OutcomeStatusCommitted, CommitSHAs: []string{"abc"}, }}, }}}, nil, ) diff --git a/submitqueue/orchestrator/controller/validate/validate_test.go b/submitqueue/orchestrator/controller/validate/validate_test.go index 90e98939..f12e2094 100644 --- a/submitqueue/orchestrator/controller/validate/validate_test.go +++ b/submitqueue/orchestrator/controller/validate/validate_test.go @@ -66,7 +66,7 @@ func (m *mockChangeProvider) Get(ctx context.Context, request entity.Request) ([ // newMergeableMock returns a mock MergeChecker that always returns mergeable. func newMergeableMock(ctrl *gomock.Controller) *mergecheckermock.MockMergeChecker { mc := mergecheckermock.NewMockMergeChecker(ctrl) - mc.EXPECT().Check(gomock.Any(), gomock.Any()).Return(mergechecker.Result{Mergeable: true}, nil).AnyTimes() + mc.EXPECT().Check(gomock.Any(), gomock.Any()).Return(entity.MergeResult{Mergeable: true}, nil).AnyTimes() return mc } @@ -280,7 +280,7 @@ func TestController_Process_NotMergeable(t *testing.T) { ctrl := gomock.NewController(t) mc := mergecheckermock.NewMockMergeChecker(ctrl) - mc.EXPECT().Check(gomock.Any(), gomock.Any()).Return(mergechecker.Result{Mergeable: false}, nil) + mc.EXPECT().Check(gomock.Any(), gomock.Any()).Return(entity.MergeResult{Mergeable: false}, nil) request := entity.Request{ ID: "test-queue/123", @@ -307,7 +307,7 @@ func TestController_Process_MergeCheckError(t *testing.T) { ctrl := gomock.NewController(t) mc := mergecheckermock.NewMockMergeChecker(ctrl) - mc.EXPECT().Check(gomock.Any(), gomock.Any()).Return(mergechecker.Result{}, fmt.Errorf("merge check failed")) + mc.EXPECT().Check(gomock.Any(), gomock.Any()).Return(entity.MergeResult{}, fmt.Errorf("merge check failed")) request := entity.Request{ ID: "test-queue/123",