diff --git a/runway/extension/BUILD.bazel b/runway/extension/BUILD.bazel new file mode 100644 index 00000000..3832145f --- /dev/null +++ b/runway/extension/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "extension", + srcs = ["extension.go"], + importpath = "github.com/uber/submitqueue/runway/extension", + visibility = ["//visibility:public"], +) diff --git a/runway/extension/extension.go b/runway/extension/extension.go new file mode 100644 index 00000000..310bab4e --- /dev/null +++ b/runway/extension/extension.go @@ -0,0 +1,16 @@ +// 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 extension holds Runway-specific extension implementations. +package extension diff --git a/runway/extension/merger/BUILD.bazel b/runway/extension/merger/BUILD.bazel new file mode 100644 index 00000000..c5d62c09 --- /dev/null +++ b/runway/extension/merger/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "merger", + srcs = ["merger.go"], + importpath = "github.com/uber/submitqueue/runway/extension/merger", + visibility = ["//visibility:public"], + deps = ["//api/runway/messagequeue"], +) diff --git a/runway/extension/merger/merger.go b/runway/extension/merger/merger.go new file mode 100644 index 00000000..cbade471 --- /dev/null +++ b/runway/extension/merger/merger.go @@ -0,0 +1,58 @@ +// 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 merger defines the pluggable interface for version-control merge +// operations that Runway performs on behalf of its callers. Implementations +// resolve change URIs, apply changes to a target branch, and (for a committing +// merge) push the result and finalize the change lifecycle (e.g. close PRs). +package merger + +//go:generate mockgen -source=merger.go -destination=mock/merger_mock.go -package=mock + +import ( + "context" + "errors" + + runwaymq "github.com/uber/submitqueue/api/runway/messagequeue" +) + +// ErrConflict signals that the ordered steps could not be applied cleanly. +// Controllers treat this as an expected outcome (ack + publish a failure +// result), not an infrastructure error. +var ErrConflict = errors.New("merge conflict") + +// Merger performs version-control operations against a single landing target. +// Both methods accept the same MergeRequest payload; the behavioral difference +// is whether the result is committed to the remote. +type Merger interface { + // CheckMergeability performs a dry-run merge without committing. The + // returned MergeResult reports per-step mergeability; Outputs are empty. + CheckMergeability(ctx context.Context, req *runwaymq.MergeRequest) (*runwaymq.MergeResult, error) + // Merge applies the ordered steps, commits the result to the remote, and + // reports per-step Outputs (the VCS-neutral revision identifiers produced). + Merge(ctx context.Context, req *runwaymq.MergeRequest) (*runwaymq.MergeResult, error) +} + +// Config identifies the landing target a Merger instance operates on. The factory +// resolves deployment-specific details (remote URL, credentials) from this. +type Config struct { + // QueueName is the caller-provided queue name from the MergeRequest. + QueueName string +} + +// Factory creates Merger instances bound to a landing target. +type Factory interface { + // For returns a Merger instance configured for the given landing target. + For(cfg Config) (Merger, error) +} diff --git a/runway/extension/merger/mock/BUILD.bazel b/runway/extension/merger/mock/BUILD.bazel new file mode 100644 index 00000000..7e702a6d --- /dev/null +++ b/runway/extension/merger/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "mock", + srcs = ["merger_mock.go"], + importpath = "github.com/uber/submitqueue/runway/extension/merger/mock", + visibility = ["//visibility:public"], + deps = [ + "//api/runway/messagequeue", + "//runway/extension/merger", + "@org_uber_go_mock//gomock", + ], +) diff --git a/runway/extension/merger/mock/merger_mock.go b/runway/extension/merger/mock/merger_mock.go new file mode 100644 index 00000000..0fdc6393 --- /dev/null +++ b/runway/extension/merger/mock/merger_mock.go @@ -0,0 +1,112 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: merger.go +// +// Generated by this command: +// +// mockgen -source=merger.go -destination=mock/merger_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + messagequeue "github.com/uber/submitqueue/api/runway/messagequeue" + merger "github.com/uber/submitqueue/runway/extension/merger" + gomock "go.uber.org/mock/gomock" +) + +// MockMerger is a mock of Merger interface. +type MockMerger struct { + ctrl *gomock.Controller + recorder *MockMergerMockRecorder + isgomock struct{} +} + +// MockMergerMockRecorder is the mock recorder for MockMerger. +type MockMergerMockRecorder struct { + mock *MockMerger +} + +// NewMockMerger creates a new mock instance. +func NewMockMerger(ctrl *gomock.Controller) *MockMerger { + mock := &MockMerger{ctrl: ctrl} + mock.recorder = &MockMergerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMerger) EXPECT() *MockMergerMockRecorder { + return m.recorder +} + +// CheckMergeability mocks base method. +func (m *MockMerger) CheckMergeability(ctx context.Context, req *messagequeue.MergeRequest) (*messagequeue.MergeResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckMergeability", ctx, req) + ret0, _ := ret[0].(*messagequeue.MergeResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CheckMergeability indicates an expected call of CheckMergeability. +func (mr *MockMergerMockRecorder) CheckMergeability(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckMergeability", reflect.TypeOf((*MockMerger)(nil).CheckMergeability), ctx, req) +} + +// Merge mocks base method. +func (m *MockMerger) Merge(ctx context.Context, req *messagequeue.MergeRequest) (*messagequeue.MergeResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Merge", ctx, req) + ret0, _ := ret[0].(*messagequeue.MergeResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Merge indicates an expected call of Merge. +func (mr *MockMergerMockRecorder) Merge(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Merge", reflect.TypeOf((*MockMerger)(nil).Merge), ctx, req) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg merger.Config) (merger.Merger, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(merger.Merger) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/runway/extension/merger/noop/BUILD.bazel b/runway/extension/merger/noop/BUILD.bazel new file mode 100644 index 00000000..1ec71480 --- /dev/null +++ b/runway/extension/merger/noop/BUILD.bazel @@ -0,0 +1,27 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "noop", + srcs = ["noop.go"], + importpath = "github.com/uber/submitqueue/runway/extension/merger/noop", + visibility = ["//visibility:public"], + deps = [ + "//api/runway/messagequeue", + "//api/runway/messagequeue/protopb", + "//runway/extension/merger", + ], +) + +go_test( + name = "noop_test", + srcs = ["noop_test.go"], + embed = [":noop"], + deps = [ + "//api/base/change/protopb", + "//api/base/mergestrategy/protopb", + "//api/runway/messagequeue", + "//api/runway/messagequeue/protopb", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/runway/extension/merger/noop/noop.go b/runway/extension/merger/noop/noop.go new file mode 100644 index 00000000..74194efc --- /dev/null +++ b/runway/extension/merger/noop/noop.go @@ -0,0 +1,68 @@ +// 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 noop provides a no-op Merger implementation for local development and +// testing. CheckMergeability always reports success; Merge produces synthetic +// output IDs from an atomic counter. +package noop + +import ( + "context" + "fmt" + "sync/atomic" + + runwaymq "github.com/uber/submitqueue/api/runway/messagequeue" + runwaypb "github.com/uber/submitqueue/api/runway/messagequeue/protopb" + "github.com/uber/submitqueue/runway/extension/merger" +) + +var _ merger.Merger = (*Merger)(nil) + +// Merger is a no-op implementation that always succeeds. +type Merger struct { + seq atomic.Uint64 +} + +// New returns a new no-op Merger instance. +func New() *Merger { return &Merger{} } + +func (v *Merger) CheckMergeability(_ context.Context, req *runwaymq.MergeRequest) (*runwaymq.MergeResult, error) { + steps := make([]*runwaymq.StepResult, len(req.GetSteps())) + for i, s := range req.GetSteps() { + steps[i] = &runwaymq.StepResult{StepId: s.GetStepId()} + } + return &runwaymq.MergeResult{ + Id: req.GetId(), + Outcome: runwaypb.Outcome_SUCCEEDED, + Steps: steps, + }, nil +} + +func (v *Merger) Merge(_ context.Context, req *runwaymq.MergeRequest) (*runwaymq.MergeResult, error) { + steps := make([]*runwaymq.StepResult, len(req.GetSteps())) + for i, s := range req.GetSteps() { + n := v.seq.Add(1) + steps[i] = &runwaymq.StepResult{ + StepId: s.GetStepId(), + Outputs: []*runwaymq.StepOutput{ + {Id: fmt.Sprintf("%040x", n)}, + }, + } + } + return &runwaymq.MergeResult{ + Id: req.GetId(), + Outcome: runwaypb.Outcome_SUCCEEDED, + Steps: steps, + }, nil +} diff --git a/runway/extension/merger/noop/noop_test.go b/runway/extension/merger/noop/noop_test.go new file mode 100644 index 00000000..e3f54cf3 --- /dev/null +++ b/runway/extension/merger/noop/noop_test.go @@ -0,0 +1,92 @@ +// 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 noop + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + changepb "github.com/uber/submitqueue/api/base/change/protopb" + strategypb "github.com/uber/submitqueue/api/base/mergestrategy/protopb" + runwaymq "github.com/uber/submitqueue/api/runway/messagequeue" + runwaypb "github.com/uber/submitqueue/api/runway/messagequeue/protopb" +) + +func testRequest() *runwaymq.MergeRequest { + return &runwaymq.MergeRequest{ + Id: "queue-a/42", + QueueName: "queue-a", + Steps: []*runwaymq.MergeStep{ + { + StepId: "queue-a/1", + Changes: []*changepb.Change{{Uris: []string{"github://uber/repo/pull/1/abcdef0123456789abcdef0123456789abcdef01"}}}, + Strategy: strategypb.Strategy_REBASE, + }, + { + StepId: "queue-a/2", + Changes: []*changepb.Change{{Uris: []string{"github://uber/repo/pull/2/89abcdef0123456789abcdef0123456789abcdef"}}}, + Strategy: strategypb.Strategy_MERGE, + }, + }, + } +} + +func TestCheckMergeability(t *testing.T) { + v := New() + req := testRequest() + + res, err := v.CheckMergeability(context.Background(), req) + require.NoError(t, err) + + assert.Equal(t, req.GetId(), res.GetId()) + assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome()) + require.Len(t, res.GetSteps(), 2) + assert.Equal(t, "queue-a/1", res.GetSteps()[0].GetStepId()) + assert.Empty(t, res.GetSteps()[0].GetOutputs()) + assert.Equal(t, "queue-a/2", res.GetSteps()[1].GetStepId()) + assert.Empty(t, res.GetSteps()[1].GetOutputs()) +} + +func TestMerge(t *testing.T) { + v := New() + req := testRequest() + + res, err := v.Merge(context.Background(), req) + require.NoError(t, err) + + assert.Equal(t, req.GetId(), res.GetId()) + assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome()) + require.Len(t, res.GetSteps(), 2) + assert.Equal(t, "queue-a/1", res.GetSteps()[0].GetStepId()) + require.Len(t, res.GetSteps()[0].GetOutputs(), 1) + assert.NotEmpty(t, res.GetSteps()[0].GetOutputs()[0].GetId()) + assert.Equal(t, "queue-a/2", res.GetSteps()[1].GetStepId()) + require.Len(t, res.GetSteps()[1].GetOutputs(), 1) + assert.NotEmpty(t, res.GetSteps()[1].GetOutputs()[0].GetId()) +} + +func TestMerge_UniqueOutputIDs(t *testing.T) { + v := New() + req := testRequest() + + res1, err := v.Merge(context.Background(), req) + require.NoError(t, err) + res2, err := v.Merge(context.Background(), req) + require.NoError(t, err) + + assert.NotEqual(t, res1.GetSteps()[0].GetOutputs()[0].GetId(), res2.GetSteps()[0].GetOutputs()[0].GetId()) +}