From edf7ab9c55c52b2f4c61eb8bbdbed7fad12804b8 Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Mon, 15 Jun 2026 15:41:14 -0700 Subject: [PATCH] feat(runway): add merge-conflict check wire contract and topic keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ### Why? The merge-conflict check is moving out of SubmitQueue's `validate` stage into an asynchronous round-trip with a separate service, runway. Runway owns the two cross-service queues, their topic keys, and the wire contract — SubmitQueue cannot read runway's storage and vice versa, so the payloads must carry full data, not entity IDs. This change adds only those runway-owned definitions; runway's gateway/orchestrator/controllers are out of scope. ### What? Adds a new `runway/` domain folder holding contract-only definitions: `runway/entity` — `MergeConflictCheckRequest` (client-owned `ID`, `QueueName`, ordered `[]MergeStep`), `MergeStep` (`StepID`, `[]change.Change`, `mergestrategy.MergeStrategy`), and `MergeConflictCheckResult` (`ID`, `Mergeable`, `Reason`, `[]StepConflict`), with JSON `ToBytes`/`FromBytes`. The ordered step list encodes base-layering so one check expresses both "candidate vs target branch" (one step) and "candidate + in-flight vs target" (N steps). It imports only the shared `entity/change` and `entity/mergestrategy` — never `submitqueue/entity`. `runway/core/topickey` — `TopicKeyMergeConflictCheck` (`merge-conflict-checker`) and `TopicKeyMergeConflictCheckSignal` (`merge-conflict-checker-signal`). ## Test Plan ✅ `bazel test //runway/...` (round-trip serialization tests pass) ✅ `bazel build //...` --- runway/core/topickey/BUILD.bazel | 9 ++ runway/core/topickey/topickey.go | 34 +++++++ runway/entity/BUILD.bazel | 24 +++++ runway/entity/merge_conflict_check.go | 109 +++++++++++++++++++++ runway/entity/merge_conflict_check_test.go | 66 +++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 runway/core/topickey/BUILD.bazel create mode 100644 runway/core/topickey/topickey.go create mode 100644 runway/entity/BUILD.bazel create mode 100644 runway/entity/merge_conflict_check.go create mode 100644 runway/entity/merge_conflict_check_test.go diff --git a/runway/core/topickey/BUILD.bazel b/runway/core/topickey/BUILD.bazel new file mode 100644 index 00000000..9aa38b1a --- /dev/null +++ b/runway/core/topickey/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "topickey", + srcs = ["topickey.go"], + importpath = "github.com/uber/submitqueue/runway/core/topickey", + visibility = ["//visibility:public"], + deps = ["//core/consumer"], +) diff --git a/runway/core/topickey/topickey.go b/runway/core/topickey/topickey.go new file mode 100644 index 00000000..c0c2a2d7 --- /dev/null +++ b/runway/core/topickey/topickey.go @@ -0,0 +1,34 @@ +// 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 topickey defines the Runway-owned queue identifiers. Runway owns the +// merge-conflict check queues; other services (e.g. SubmitQueue) import these +// keys to publish onto / consume from them. +package topickey + +import "github.com/uber/submitqueue/core/consumer" + +// TopicKey is the shared pipeline stage identifier type. +type TopicKey = consumer.TopicKey + +const ( + // TopicKeyMergeConflictCheck is the runway-owned queue that carries + // merge-conflict check requests. A client publishes a full + // MergeConflictCheckRequest here; runway consumes it. + TopicKeyMergeConflictCheck TopicKey = "merge-conflict-checker" + // TopicKeyMergeConflictCheckSignal is the runway-owned queue that carries + // merge-conflict check results. Runway publishes a full + // MergeConflictCheckResult here; the requesting client consumes it. + TopicKeyMergeConflictCheckSignal TopicKey = "merge-conflict-checker-signal" +) diff --git a/runway/entity/BUILD.bazel b/runway/entity/BUILD.bazel new file mode 100644 index 00000000..bd276721 --- /dev/null +++ b/runway/entity/BUILD.bazel @@ -0,0 +1,24 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "entity", + srcs = ["merge_conflict_check.go"], + importpath = "github.com/uber/submitqueue/runway/entity", + visibility = ["//visibility:public"], + deps = [ + "//entity/change", + "//entity/mergestrategy", + ], +) + +go_test( + name = "entity_test", + srcs = ["merge_conflict_check_test.go"], + embed = [":entity"], + deps = [ + "//entity/change", + "//entity/mergestrategy", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/runway/entity/merge_conflict_check.go b/runway/entity/merge_conflict_check.go new file mode 100644 index 00000000..cc194c00 --- /dev/null +++ b/runway/entity/merge_conflict_check.go @@ -0,0 +1,109 @@ +// 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 holds Runway's domain entities, including the wire contract for +// the merge-conflict check queues that Runway owns. The contract crosses a +// service boundary (a calling service cannot read Runway's storage and vice +// versa), so these payloads carry the full data needed to perform a merge +// attempt rather than opaque entity IDs. +package entity + +import ( + "encoding/json" + + "github.com/uber/submitqueue/entity/change" + "github.com/uber/submitqueue/entity/mergestrategy" +) + +// MergeStep is one step of an ordered merge-conflict check: a single set of +// change(s) applied with a strategy. Runway applies the steps of a request in +// order on top of the target branch; the ordering encodes the base-layering +// (earlier steps are the in-flight base, the last step is the candidate). +type MergeStep struct { + // StepID is an opaque, caller-assigned identifier for this step. Runway + // treats it as an attribution token only — it echoes it back per-step in + // StepConflict so a multi-step result is attributable — and never interprets + // its contents. (A caller might use, for example, its own request id here.) + StepID string `json:"step_id"` + // Changes are the code change(s) to apply for this step (provider URIs with + // head commit SHAs; see entity/change.Change). + Changes []change.Change `json:"changes"` + // Strategy is how this step's changes are integrated into the target branch. + Strategy mergestrategy.MergeStrategy `json:"strategy"` +} + +// MergeConflictCheckRequest is the payload a client publishes to the +// TopicKeyMergeConflictCheck queue. The ID is owned by the client so it can +// record the in-flight check before publishing and correlate the asynchronous +// result; runway echoes it back unchanged. +type MergeConflictCheckRequest struct { + // ID is the client-owned correlation id for this check request (one per + // request). Runway echoes it back on the result unchanged. + ID string `json:"id"` + // QueueName is the caller-provided queue name the check belongs to. Runway + // resolves the target branch and provider config per-queue from this name; + // no target ref is passed. + QueueName string `json:"queue_name"` + // Steps is the ordered application sequence: in-flight steps first, the + // candidate last. A single-element slice expresses "candidate vs target + // branch". + Steps []MergeStep `json:"steps"` +} + +// ToBytes serializes the MergeConflictCheckRequest to JSON bytes for the queue payload. +func (r MergeConflictCheckRequest) ToBytes() ([]byte, error) { + return json.Marshal(r) +} + +// MergeConflictCheckRequestFromBytes deserializes a MergeConflictCheckRequest from JSON bytes. +func MergeConflictCheckRequestFromBytes(data []byte) (MergeConflictCheckRequest, error) { + var req MergeConflictCheckRequest + err := json.Unmarshal(data, &req) + return req, err +} + +// StepConflict identifies a single step that failed to apply cleanly during a +// merge-conflict check, so a multi-step result attributes the conflict. +type StepConflict struct { + // StepID echoes the StepID of the step that conflicted (see MergeStep.StepID). + StepID string `json:"step_id"` + // Reason is a human-readable explanation of the conflict. + Reason string `json:"reason"` +} + +// MergeConflictCheckResult is the payload runway publishes to the +// TopicKeyMergeConflictCheckSignal queue once a check completes. +type MergeConflictCheckResult struct { + // ID echoes the client-owned correlation id from the request. + ID string `json:"id"` + // Mergeable is true if the whole ordered step sequence applied cleanly. + Mergeable bool `json:"mergeable"` + // Reason is a human-readable explanation when Mergeable is false. Empty when mergeable. + Reason string `json:"reason"` + // Conflicts optionally attributes the failure to specific steps. May be + // empty even when Mergeable is false. + Conflicts []StepConflict `json:"conflicts,omitempty"` +} + +// ToBytes serializes the MergeConflictCheckResult to JSON bytes for the queue payload. +func (r MergeConflictCheckResult) ToBytes() ([]byte, error) { + return json.Marshal(r) +} + +// MergeConflictCheckResultFromBytes deserializes a MergeConflictCheckResult from JSON bytes. +func MergeConflictCheckResultFromBytes(data []byte) (MergeConflictCheckResult, error) { + var res MergeConflictCheckResult + err := json.Unmarshal(data, &res) + return res, err +} diff --git a/runway/entity/merge_conflict_check_test.go b/runway/entity/merge_conflict_check_test.go new file mode 100644 index 00000000..4ff78ae4 --- /dev/null +++ b/runway/entity/merge_conflict_check_test.go @@ -0,0 +1,66 @@ +// 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 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/entity/change" + "github.com/uber/submitqueue/entity/mergestrategy" +) + +func TestMergeConflictCheckRequestRoundTrip(t *testing.T) { + req := MergeConflictCheckRequest{ + ID: "queue-a/42/check", + QueueName: "queue-a", + Steps: []MergeStep{ + { + StepID: "queue-a/1", + Changes: []change.Change{{URIs: []string{"github://uber/repo/pull/1/" + "0123456789abcdef0123456789abcdef01234567"}}}, + Strategy: mergestrategy.MergeStrategyRebase, + }, + { + StepID: "queue-a/2", + Changes: []change.Change{{URIs: []string{"github://uber/repo/pull/2/" + "89abcdef0123456789abcdef0123456789abcdef"}}}, + Strategy: mergestrategy.MergeStrategyMerge, + }, + }, + } + + data, err := req.ToBytes() + require.NoError(t, err) + + got, err := MergeConflictCheckRequestFromBytes(data) + require.NoError(t, err) + assert.Equal(t, req, got) +} + +func TestMergeConflictCheckResultRoundTrip(t *testing.T) { + res := MergeConflictCheckResult{ + ID: "queue-a/42/check", + Mergeable: false, + Reason: "conflict in foo.go", + Conflicts: []StepConflict{{StepID: "queue-a/2", Reason: "conflict in foo.go"}}, + } + + data, err := res.ToBytes() + require.NoError(t, err) + + got, err := MergeConflictCheckResultFromBytes(data) + require.NoError(t, err) + assert.Equal(t, res, got) +}