Skip to content

Commit b8f3e22

Browse files
committed
feat(runway): add merge-conflict check wire contract and topic keys
## 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 //...`
1 parent 6491c8a commit b8f3e22

5 files changed

Lines changed: 243 additions & 0 deletions

File tree

runway/core/topickey/BUILD.bazel

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "topickey",
5+
srcs = ["topickey.go"],
6+
importpath = "github.com/uber/submitqueue/runway/core/topickey",
7+
visibility = ["//visibility:public"],
8+
deps = ["//core/consumer"],
9+
)

runway/core/topickey/topickey.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package topickey defines the Runway-owned queue identifiers. Runway owns the
16+
// merge-conflict check queues; other services (e.g. SubmitQueue) import these
17+
// keys to publish onto / consume from them.
18+
package topickey
19+
20+
import "github.com/uber/submitqueue/core/consumer"
21+
22+
// TopicKey is the shared pipeline stage identifier type.
23+
type TopicKey = consumer.TopicKey
24+
25+
const (
26+
// TopicKeyMergeConflictCheck is the runway-owned queue that carries
27+
// merge-conflict check requests. SubmitQueue's mergeconflict stage
28+
// publishes a full MergeConflictCheckRequest here; runway consumes it.
29+
TopicKeyMergeConflictCheck TopicKey = "merge-conflict-checker"
30+
// TopicKeyMergeConflictCheckSignal is the runway-owned queue that carries
31+
// merge-conflict check results. Runway publishes a full
32+
// MergeConflictCheckResult here; SubmitQueue's mergeconflictsignal stage
33+
// consumes it.
34+
TopicKeyMergeConflictCheckSignal TopicKey = "merge-conflict-checker-signal"
35+
)

runway/entity/BUILD.bazel

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "entity",
5+
srcs = ["merge_conflict_check.go"],
6+
importpath = "github.com/uber/submitqueue/runway/entity",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//entity/change",
10+
"//entity/mergestrategy",
11+
],
12+
)
13+
14+
go_test(
15+
name = "entity_test",
16+
srcs = ["merge_conflict_check_test.go"],
17+
embed = [":entity"],
18+
deps = [
19+
"//entity/change",
20+
"//entity/mergestrategy",
21+
"@com_github_stretchr_testify//assert",
22+
"@com_github_stretchr_testify//require",
23+
],
24+
)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package entity holds Runway's domain entities, including the wire contract for
16+
// the merge-conflict check queues that Runway owns. The contract crosses a
17+
// service boundary (SubmitQueue cannot read Runway's storage and vice versa), so
18+
// these payloads carry the full data needed to perform a merge attempt rather
19+
// than opaque entity IDs.
20+
package entity
21+
22+
import (
23+
"encoding/json"
24+
25+
"github.com/uber/submitqueue/entity/change"
26+
"github.com/uber/submitqueue/entity/mergestrategy"
27+
)
28+
29+
// MergeStep is one step of an ordered merge-conflict check: a single set of
30+
// change(s) applied with a strategy. Runway applies the steps of a request in
31+
// order on top of the target branch; the ordering encodes the base-layering
32+
// (earlier steps are the in-flight base, the last step is the candidate).
33+
type MergeStep struct {
34+
// StepID is an opaque, caller-assigned identifier for this step. Runway
35+
// treats it as an attribution token only — it echoes it back per-step in
36+
// StepConflict so a multi-step result is attributable — and never interprets
37+
// its contents. (SubmitQueue happens to use its land-request id here.)
38+
StepID string `json:"step_id"`
39+
// Changes are the code change(s) to apply for this step (provider URIs with
40+
// head commit SHAs; see entity/change.Change).
41+
Changes []change.Change `json:"changes"`
42+
// Strategy is how this step's changes are integrated into the target branch.
43+
Strategy mergestrategy.MergeStrategy `json:"strategy"`
44+
}
45+
46+
// MergeConflictCheckRequest is the payload SubmitQueue publishes to the
47+
// TopicKeyMergeConflictCheck queue. The ID is owned by the client (SubmitQueue)
48+
// so it can record the in-flight check before publishing and correlate the
49+
// asynchronous result; runway echoes it back unchanged.
50+
type MergeConflictCheckRequest struct {
51+
// ID is the client-owned correlation id for this check request (one per
52+
// request). Runway echoes it back on the result unchanged.
53+
ID string `json:"id"`
54+
// QueueName is the SubmitQueue queue the check belongs to. Runway resolves
55+
// the target branch and provider config per-queue from this name; no target
56+
// ref is passed.
57+
QueueName string `json:"queue_name"`
58+
// Steps is the ordered application sequence: in-flight steps first, the
59+
// candidate last. A single-element slice expresses "candidate vs target
60+
// branch".
61+
Steps []MergeStep `json:"steps"`
62+
}
63+
64+
// ToBytes serializes the MergeConflictCheckRequest to JSON bytes for the queue payload.
65+
func (r MergeConflictCheckRequest) ToBytes() ([]byte, error) {
66+
return json.Marshal(r)
67+
}
68+
69+
// MergeConflictCheckRequestFromBytes deserializes a MergeConflictCheckRequest from JSON bytes.
70+
func MergeConflictCheckRequestFromBytes(data []byte) (MergeConflictCheckRequest, error) {
71+
var req MergeConflictCheckRequest
72+
err := json.Unmarshal(data, &req)
73+
return req, err
74+
}
75+
76+
// StepConflict identifies a single step that failed to apply cleanly during a
77+
// merge-conflict check, so a multi-step result attributes the conflict.
78+
type StepConflict struct {
79+
// StepID echoes the StepID of the step that conflicted (see MergeStep.StepID).
80+
StepID string `json:"step_id"`
81+
// Reason is a human-readable explanation of the conflict.
82+
Reason string `json:"reason"`
83+
}
84+
85+
// MergeConflictCheckResult is the payload runway publishes to the
86+
// TopicKeyMergeConflictCheckSignal queue once a check completes.
87+
type MergeConflictCheckResult struct {
88+
// ID echoes the client-owned correlation id from the request.
89+
ID string `json:"id"`
90+
// Mergeable is true if the whole ordered step sequence applied cleanly.
91+
Mergeable bool `json:"mergeable"`
92+
// Reason is a human-readable explanation when Mergeable is false. Empty when mergeable.
93+
Reason string `json:"reason"`
94+
// Conflicts optionally attributes the failure to specific steps. May be
95+
// empty even when Mergeable is false.
96+
Conflicts []StepConflict `json:"conflicts,omitempty"`
97+
}
98+
99+
// ToBytes serializes the MergeConflictCheckResult to JSON bytes for the queue payload.
100+
func (r MergeConflictCheckResult) ToBytes() ([]byte, error) {
101+
return json.Marshal(r)
102+
}
103+
104+
// MergeConflictCheckResultFromBytes deserializes a MergeConflictCheckResult from JSON bytes.
105+
func MergeConflictCheckResultFromBytes(data []byte) (MergeConflictCheckResult, error) {
106+
var res MergeConflictCheckResult
107+
err := json.Unmarshal(data, &res)
108+
return res, err
109+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package entity
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
"github.com/uber/submitqueue/entity/change"
23+
"github.com/uber/submitqueue/entity/mergestrategy"
24+
)
25+
26+
func TestMergeConflictCheckRequestRoundTrip(t *testing.T) {
27+
req := MergeConflictCheckRequest{
28+
ID: "queue-a/42/check",
29+
QueueName: "queue-a",
30+
Steps: []MergeStep{
31+
{
32+
StepID: "queue-a/1",
33+
Changes: []change.Change{{URIs: []string{"github://uber/repo/pull/1/" + "0123456789abcdef0123456789abcdef01234567"}}},
34+
Strategy: mergestrategy.MergeStrategyRebase,
35+
},
36+
{
37+
StepID: "queue-a/2",
38+
Changes: []change.Change{{URIs: []string{"github://uber/repo/pull/2/" + "89abcdef0123456789abcdef0123456789abcdef"}}},
39+
Strategy: mergestrategy.MergeStrategyMerge,
40+
},
41+
},
42+
}
43+
44+
data, err := req.ToBytes()
45+
require.NoError(t, err)
46+
47+
got, err := MergeConflictCheckRequestFromBytes(data)
48+
require.NoError(t, err)
49+
assert.Equal(t, req, got)
50+
}
51+
52+
func TestMergeConflictCheckResultRoundTrip(t *testing.T) {
53+
res := MergeConflictCheckResult{
54+
ID: "queue-a/42/check",
55+
Mergeable: false,
56+
Reason: "conflict in foo.go",
57+
Conflicts: []StepConflict{{StepID: "queue-a/2", Reason: "conflict in foo.go"}},
58+
}
59+
60+
data, err := res.ToBytes()
61+
require.NoError(t, err)
62+
63+
got, err := MergeConflictCheckResultFromBytes(data)
64+
require.NoError(t, err)
65+
assert.Equal(t, res, got)
66+
}

0 commit comments

Comments
 (0)