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/vcs/BUILD.bazel b/runway/extension/vcs/BUILD.bazel new file mode 100644 index 00000000..e8e37e16 --- /dev/null +++ b/runway/extension/vcs/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "vcs", + srcs = ["vcs.go"], + importpath = "github.com/uber/submitqueue/runway/extension/vcs", + visibility = ["//visibility:public"], + deps = ["//runway/entity"], +) diff --git a/runway/extension/vcs/mock/BUILD.bazel b/runway/extension/vcs/mock/BUILD.bazel new file mode 100644 index 00000000..e04292b8 --- /dev/null +++ b/runway/extension/vcs/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "mock", + srcs = ["vcs_mock.go"], + importpath = "github.com/uber/submitqueue/runway/extension/vcs/mock", + visibility = ["//visibility:public"], + deps = [ + "//runway/entity", + "//runway/extension/vcs", + "@org_uber_go_mock//gomock", + ], +) diff --git a/runway/extension/vcs/mock/vcs_mock.go b/runway/extension/vcs/mock/vcs_mock.go new file mode 100644 index 00000000..c4e33cc5 --- /dev/null +++ b/runway/extension/vcs/mock/vcs_mock.go @@ -0,0 +1,112 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: vcs.go +// +// Generated by this command: +// +// mockgen -source=vcs.go -destination=mock/vcs_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/runway/entity" + vcs "github.com/uber/submitqueue/runway/extension/vcs" + gomock "go.uber.org/mock/gomock" +) + +// MockVCS is a mock of VCS interface. +type MockVCS struct { + ctrl *gomock.Controller + recorder *MockVCSMockRecorder + isgomock struct{} +} + +// MockVCSMockRecorder is the mock recorder for MockVCS. +type MockVCSMockRecorder struct { + mock *MockVCS +} + +// NewMockVCS creates a new mock instance. +func NewMockVCS(ctrl *gomock.Controller) *MockVCS { + mock := &MockVCS{ctrl: ctrl} + mock.recorder = &MockVCSMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockVCS) EXPECT() *MockVCSMockRecorder { + return m.recorder +} + +// CheckMergeability mocks base method. +func (m *MockVCS) CheckMergeability(ctx context.Context, req entity.MergeRequest) (entity.MergeResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckMergeability", ctx, req) + ret0, _ := ret[0].(entity.MergeResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CheckMergeability indicates an expected call of CheckMergeability. +func (mr *MockVCSMockRecorder) CheckMergeability(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckMergeability", reflect.TypeOf((*MockVCS)(nil).CheckMergeability), ctx, req) +} + +// Land mocks base method. +func (m *MockVCS) Land(ctx context.Context, req entity.MergeRequest) (entity.MergeResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Land", ctx, req) + ret0, _ := ret[0].(entity.MergeResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Land indicates an expected call of Land. +func (mr *MockVCSMockRecorder) Land(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Land", reflect.TypeOf((*MockVCS)(nil).Land), 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 vcs.Config) (vcs.VCS, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(vcs.VCS) + 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/vcs/noop/BUILD.bazel b/runway/extension/vcs/noop/BUILD.bazel new file mode 100644 index 00000000..b9755204 --- /dev/null +++ b/runway/extension/vcs/noop/BUILD.bazel @@ -0,0 +1,25 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "noop", + srcs = ["noop.go"], + importpath = "github.com/uber/submitqueue/runway/extension/vcs/noop", + visibility = ["//visibility:public"], + deps = [ + "//runway/entity", + "//runway/extension/vcs", + ], +) + +go_test( + name = "noop_test", + srcs = ["noop_test.go"], + embed = [":noop"], + deps = [ + "//platform/base/change", + "//platform/base/mergestrategy", + "//runway/entity", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/runway/extension/vcs/noop/noop.go b/runway/extension/vcs/noop/noop.go new file mode 100644 index 00000000..cab7dd9b --- /dev/null +++ b/runway/extension/vcs/noop/noop.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 noop provides a no-op VCS implementation for local development and +// testing. CheckMergeability always reports success; Land produces synthetic +// output IDs from an atomic counter. +package noop + +import ( + "context" + "fmt" + "sync/atomic" + + "github.com/uber/submitqueue/runway/entity" + "github.com/uber/submitqueue/runway/extension/vcs" +) + +var _ vcs.VCS = (*VCS)(nil) + +// VCS is a no-op implementation that always succeeds. +type VCS struct { + seq atomic.Uint64 +} + +// New returns a new no-op VCS instance. +func New() *VCS { return &VCS{} } + +func (v *VCS) CheckMergeability(_ context.Context, req entity.MergeRequest) (entity.MergeResult, error) { + steps := make([]entity.StepResult, len(req.Steps)) + for i, s := range req.Steps { + steps[i] = entity.StepResult{StepID: s.StepID} + } + return entity.MergeResult{ + ID: req.ID, + Success: true, + Steps: steps, + }, nil +} + +func (v *VCS) Land(_ context.Context, req entity.MergeRequest) (entity.MergeResult, error) { + steps := make([]entity.StepResult, len(req.Steps)) + for i, s := range req.Steps { + n := v.seq.Add(1) + steps[i] = entity.StepResult{ + StepID: s.StepID, + OutputIDs: []string{fmt.Sprintf("%040x", n)}, + } + } + return entity.MergeResult{ + ID: req.ID, + Success: true, + Steps: steps, + }, nil +} diff --git a/runway/extension/vcs/noop/noop_test.go b/runway/extension/vcs/noop/noop_test.go new file mode 100644 index 00000000..0137d82e --- /dev/null +++ b/runway/extension/vcs/noop/noop_test.go @@ -0,0 +1,91 @@ +// 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" + "github.com/uber/submitqueue/platform/base/change" + "github.com/uber/submitqueue/platform/base/mergestrategy" + "github.com/uber/submitqueue/runway/entity" +) + +func testRequest() entity.MergeRequest { + return entity.MergeRequest{ + ID: "queue-a/42", + QueueName: "queue-a", + Steps: []entity.MergeStep{ + { + StepID: "queue-a/1", + Changes: []change.Change{{URIs: []string{"github://uber/repo/pull/1/abcdef0123456789abcdef0123456789abcdef01"}}}, + Strategy: mergestrategy.MergeStrategyRebase, + }, + { + StepID: "queue-a/2", + Changes: []change.Change{{URIs: []string{"github://uber/repo/pull/2/89abcdef0123456789abcdef0123456789abcdef"}}}, + Strategy: mergestrategy.MergeStrategyMerge, + }, + }, + } +} + +func TestCheckMergeability(t *testing.T) { + v := New() + req := testRequest() + + res, err := v.CheckMergeability(context.Background(), req) + require.NoError(t, err) + + assert.Equal(t, req.ID, res.ID) + assert.True(t, res.Success) + require.Len(t, res.Steps, 2) + assert.Equal(t, "queue-a/1", res.Steps[0].StepID) + assert.Empty(t, res.Steps[0].OutputIDs) + assert.Equal(t, "queue-a/2", res.Steps[1].StepID) + assert.Empty(t, res.Steps[1].OutputIDs) +} + +func TestLand(t *testing.T) { + v := New() + req := testRequest() + + res, err := v.Land(context.Background(), req) + require.NoError(t, err) + + assert.Equal(t, req.ID, res.ID) + assert.True(t, res.Success) + require.Len(t, res.Steps, 2) + assert.Equal(t, "queue-a/1", res.Steps[0].StepID) + require.Len(t, res.Steps[0].OutputIDs, 1) + assert.NotEmpty(t, res.Steps[0].OutputIDs[0]) + assert.Equal(t, "queue-a/2", res.Steps[1].StepID) + require.Len(t, res.Steps[1].OutputIDs, 1) + assert.NotEmpty(t, res.Steps[1].OutputIDs[0]) +} + +func TestLand_UniqueOutputIDs(t *testing.T) { + v := New() + req := testRequest() + + res1, err := v.Land(context.Background(), req) + require.NoError(t, err) + res2, err := v.Land(context.Background(), req) + require.NoError(t, err) + + assert.NotEqual(t, res1.Steps[0].OutputIDs[0], res2.Steps[0].OutputIDs[0]) +} diff --git a/runway/extension/vcs/vcs.go b/runway/extension/vcs/vcs.go new file mode 100644 index 00000000..061ada28 --- /dev/null +++ b/runway/extension/vcs/vcs.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 vcs defines the pluggable interface for version-control 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 vcs + +//go:generate mockgen -source=vcs.go -destination=mock/vcs_mock.go -package=mock + +import ( + "context" + "errors" + + "github.com/uber/submitqueue/runway/entity" +) + +// 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") + +// VCS 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 VCS interface { + // CheckMergeability performs a dry-run merge without committing. The + // returned MergeResult reports per-step mergeability; OutputIDs are empty. + CheckMergeability(ctx context.Context, req entity.MergeRequest) (entity.MergeResult, error) + // Land applies the ordered steps, commits the result to the remote, and + // reports per-step OutputIDs (the VCS-neutral revision identifiers produced). + Land(ctx context.Context, req entity.MergeRequest) (entity.MergeResult, error) +} + +// Config identifies the landing target a VCS 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 VCS instances bound to a landing target. +type Factory interface { + // For returns a VCS instance configured for the given landing target. + For(cfg Config) (VCS, error) +}