diff --git a/submitqueue/extension/conflict/tango/BUILD.bazel b/submitqueue/extension/conflict/tango/BUILD.bazel index a48a755c..ae13cbc1 100644 --- a/submitqueue/extension/conflict/tango/BUILD.bazel +++ b/submitqueue/extension/conflict/tango/BUILD.bazel @@ -4,7 +4,10 @@ go_library( name = "go_default_library", srcs = [ "cache.go", + "intersect.go", + "mock_request.go", "mock_store.go", + "request.go", "signature.go", "store.go", "target_label.go", @@ -12,17 +15,23 @@ go_library( ], importpath = "github.com/uber/submitqueue/submitqueue/extension/conflict/tango", visibility = ["//visibility:public"], - deps = ["@org_uber_go_mock//gomock:go_default_library"], + deps = [ + "//platform/base/change:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], ) go_test( name = "go_default_test", srcs = [ "cache_test.go", + "intersect_test.go", + "request_test.go", "signature_test.go", ], embed = [":go_default_library"], deps = [ + "//platform/base/change:go_default_library", "@com_github_stretchr_testify//assert:go_default_library", "@com_github_stretchr_testify//require:go_default_library", ], diff --git a/submitqueue/extension/conflict/tango/intersect.go b/submitqueue/extension/conflict/tango/intersect.go new file mode 100644 index 00000000..2da8c1df --- /dev/null +++ b/submitqueue/extension/conflict/tango/intersect.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 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 tango + +// sharesTarget reports whether two signatures have a target ID in common. Both +// must be sorted ascending by ID, so the check is one pass over each. +func sharesTarget(a, b []SignatureTarget) bool { + i, j := 0, 0 + for i < len(a) && j < len(b) { + switch { + case a[i].ID < b[j].ID: + i++ + case a[i].ID > b[j].ID: + j++ + default: + return true + } + } + return false +} diff --git a/submitqueue/extension/conflict/tango/intersect_test.go b/submitqueue/extension/conflict/tango/intersect_test.go new file mode 100644 index 00000000..2ee4948b --- /dev/null +++ b/submitqueue/extension/conflict/tango/intersect_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 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 tango + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func targetsWithIDs(ids ...uint64) []SignatureTarget { + targets := make([]SignatureTarget, len(ids)) + for i, id := range ids { + targets[i] = SignatureTarget{ID: id} + } + return targets +} + +func TestSharesTarget(t *testing.T) { + const highBit = uint64(1) << 63 + + tests := []struct { + name string + a, b []SignatureTarget + want bool + }{ + {name: "both empty"}, + {name: "one empty", a: targetsWithIDs(1, 2)}, + {name: "disjoint", a: targetsWithIDs(1, 3, 5), b: targetsWithIDs(2, 4, 6)}, + {name: "same first ID", a: targetsWithIDs(1, 3), b: targetsWithIDs(1, 9), want: true}, + {name: "same last ID", a: targetsWithIDs(1, 7), b: targetsWithIDs(2, 7), want: true}, + {name: "same middle ID", a: targetsWithIDs(1, 4, 9), b: targetsWithIDs(2, 4, 8), want: true}, + {name: "different lengths", a: targetsWithIDs(5), b: targetsWithIDs(1, 2, 3, 4, 5, 6), want: true}, + {name: "IDs above the signed range order as unsigned", a: targetsWithIDs(1, highBit), b: targetsWithIDs(2, highBit+1)}, + {name: "same ID above the signed range", a: targetsWithIDs(1, highBit), b: targetsWithIDs(highBit), want: true}, + { + name: "distance does not affect the result", + a: []SignatureTarget{{ID: 4, Distance: 0}}, + b: []SignatureTarget{{ID: 4, Distance: 9}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, sharesTarget(tt.a, tt.b)) + assert.Equal(t, tt.want, sharesTarget(tt.b, tt.a)) + }) + } +} diff --git a/submitqueue/extension/conflict/tango/mock_request.go b/submitqueue/extension/conflict/tango/mock_request.go new file mode 100644 index 00000000..7aa4199b --- /dev/null +++ b/submitqueue/extension/conflict/tango/mock_request.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: request.go +// +// Generated by this command: +// +// mockgen -source=request.go -destination=mock_request.go -package=tango +// + +// Package tango is a generated GoMock package. +package tango + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockChangedTargetsClient is a mock of ChangedTargetsClient interface. +type MockChangedTargetsClient struct { + ctrl *gomock.Controller + recorder *MockChangedTargetsClientMockRecorder + isgomock struct{} +} + +// MockChangedTargetsClientMockRecorder is the mock recorder for MockChangedTargetsClient. +type MockChangedTargetsClientMockRecorder struct { + mock *MockChangedTargetsClient +} + +// NewMockChangedTargetsClient creates a new mock instance. +func NewMockChangedTargetsClient(ctrl *gomock.Controller) *MockChangedTargetsClient { + mock := &MockChangedTargetsClient{ctrl: ctrl} + mock.recorder = &MockChangedTargetsClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockChangedTargetsClient) EXPECT() *MockChangedTargetsClientMockRecorder { + return m.recorder +} + +// ChangedTargets mocks base method. +func (m *MockChangedTargetsClient) ChangedTargets(ctx context.Context, request ChangedTargetsRequest) ([]LabelDistance, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangedTargets", ctx, request) + ret0, _ := ret[0].([]LabelDistance) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangedTargets indicates an expected call of ChangedTargets. +func (mr *MockChangedTargetsClientMockRecorder) ChangedTargets(ctx, request any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangedTargets", reflect.TypeOf((*MockChangedTargetsClient)(nil).ChangedTargets), ctx, request) +} diff --git a/submitqueue/extension/conflict/tango/request.go b/submitqueue/extension/conflict/tango/request.go new file mode 100644 index 00000000..e038a4a1 --- /dev/null +++ b/submitqueue/extension/conflict/tango/request.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 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 tango + +//go:generate mockgen -source=request.go -destination=mock_request.go -package=tango + +import ( + "context" + + "github.com/uber/submitqueue/platform/base/change" +) + +// ChangedTargetsRequest asks which targets change when ChangeURIs are applied +// on top of a base revision. +type ChangedTargetsRequest struct { + // BaseRevision is the full commit SHA the changes are applied on top of. + BaseRevision string + + // ChangeURIs lists every change to apply, in batch order. + ChangeURIs []string +} + +// ChangedTargetsClient returns the targets a request affects. An adapter owns +// the Tango client and its stream, collects the response, and resolves each +// target's label; any failure to produce a complete answer is an error. It also +// owns the Tango repository remote and computation strategy, which the request +// does not carry. ChangeURIs are passed through as given, so a URI scheme Tango +// does not support is an error. +type ChangedTargetsClient interface { + // ChangedTargets returns the affected targets in no particular order, and a + // label may repeat. An empty result is valid and means nothing is affected. + ChangedTargets(ctx context.Context, request ChangedTargetsRequest) ([]LabelDistance, error) +} + +func changedTargetsRequestFor(baseRevision string, changes []change.Change) ChangedTargetsRequest { + var uris []string + for _, c := range changes { + uris = append(uris, c.URIs...) + } + return ChangedTargetsRequest{BaseRevision: baseRevision, ChangeURIs: uris} +} diff --git a/submitqueue/extension/conflict/tango/request_test.go b/submitqueue/extension/conflict/tango/request_test.go new file mode 100644 index 00000000..7f4efe75 --- /dev/null +++ b/submitqueue/extension/conflict/tango/request_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 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 tango + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/uber/submitqueue/platform/base/change" +) + +func TestChangedTargetsRequestFor(t *testing.T) { + baseRevision := "9f2c1d0e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d" + + tests := []struct { + name string + changes []change.Change + want []string + }{ + {name: "no changes", want: nil}, + {name: "one change", changes: []change.Change{{URIs: []string{"a"}}}, want: []string{"a"}}, + { + name: "URIs are flattened in batch order", + changes: []change.Change{{URIs: []string{"a", "b"}}, {URIs: []string{"c"}}, {URIs: []string{"d", "e"}}}, + want: []string{"a", "b", "c", "d", "e"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := changedTargetsRequestFor(baseRevision, tt.changes) + + assert.Equal(t, ChangedTargetsRequest{ + BaseRevision: baseRevision, + ChangeURIs: tt.want, + }, got) + }) + } +}