diff --git a/doc/rfc/index.md b/doc/rfc/index.md index 6fec9e6d..f72f2eec 100644 --- a/doc/rfc/index.md +++ b/doc/rfc/index.md @@ -16,3 +16,7 @@ Design documents and technical proposals, grouped by scope. Shared/cross-cutting ## Stovepipe - [Stovepipe Workflow](stovepipe/workflow.md) - Post-merge trunk-validation pipeline: ingest trunk push events (webhook + fallback poll), batch since last green, build to validate, record per-commit health, bisect to the offending commit, hand off to a remediation extension + +## Runway + +- [Runway Workflow](runway/workflow.md) - Landing service: check request mergeability, land scored batches, publish results back to SubmitQueue diff --git a/doc/rfc/runway/workflow.md b/doc/rfc/runway/workflow.md new file mode 100644 index 00000000..61b960ac --- /dev/null +++ b/doc/rfc/runway/workflow.md @@ -0,0 +1,73 @@ +# Runway Workflow + +Runway is the landing service: it owns VCS operations — mergeability checking and landing — on behalf of SubmitQueue. The orchestrator subscribes to two inbound topics (`runway-check`, `runway-land`) and publishes results to two outbound topics (`sq-check-result`, `sq-land-result`). It is a consumer-only service with no gateway; work arrives via topic queues and results leave via topic queues. + +## Check and land + +The two queues operate at different granularities: + +- **check** is request-level. A check message carries a request's changes and merge strategy. Runway performs a read-only trial merge and publishes per-change mergeability results back. + +- **land** is batch-level. A job message carries the resolved content for each request in the batch (runway has no access to SubmitQueue's request store, so the message is self-contained). Runway pre-validates, lands, and publishes a result with per-item outcomes back. + +These are independent input-output flows. Check can run without land ever running, and land does not depend on a prior check. + +## Branch serialization + +The partition key `repo/target` on both inbound topics serializes all VCS operations for a given branch. The message queue delivers messages with the same partition key to the same consumer in order, so at most one check or land operation is in flight for any given branch at any time. + +The outbound topics partition by SubmitQueue queue name, matching SubmitQueue's fan-out model where state updates for the same queue are serialized. + +## Workflow + +``` + ┌─────────────────────────────────────────────┐ + │ submitqueue orchestrator │ + └───────┬───────────────────────┬─────────────┘ + │ │ + Check (per request) Job (per batch) + │ │ + ▼ ▼ + [runway-check] [runway-land] + │ │ + check ctrl land ctrl + (read-only) (pre-validate + push) + │ │ + CheckResult Result + │ │ + ▼ ▼ + [sq-check-result] [sq-land-result] + │ │ + ▼ ▼ + ┌───────┬───────────────────────┬─────────────┐ + │ check-result ctrl land-result ctrl │ + │ (update request (update batch state, │ + │ mergeability) fan out to conclude) │ + │ submitqueue orchestrator │ + └─────────────────────────────────────────────┘ +``` + +## Per-controller summary + +| Controller | In | Out | One-line role | +|---|---|---|---| +| **check** | Check | CheckResult -> sq-check-result | Check mergeability of a request's changes against the target branch (read-only) | +| **land** | Job | Result -> sq-land-result | Pre-validate, land, and finalize a batch's changes on the target branch | + +The check controller always publishes a result — even when all changes are mergeable — so SubmitQueue receives a definitive answer. On infrastructure error it nacks for retry. + +The land controller publishes a conflict result (and acks) when pre-validation detects a conflict; SubmitQueue handles rebatching. On infrastructure error it nacks for retry. On success it publishes per-item outcomes (commit SHAs, whether new commits were produced) so SubmitQueue can update its request state. + +## Idempotency + +Runway has no persistent state — no request store, no job store, no database. Idempotency is achieved through the VCS contract: land detects already-pushed changes (commit SHAs reachable from HEAD) and treats them as already-landed; closing an already-closed PR is a no-op. Check is read-only and naturally idempotent. + +## Ownership by service + +### Orchestrator + +The orchestrator is the only service. It subscribes to two inbound topics (`runway-check`, `runway-land`), performs VCS operations through a pluggable extension, and publishes results to two outbound topics (`sq-check-result`, `sq-land-result`). It owns no persistent data. + +### Shared: the messaging queue + +Runway communicates with SubmitQueue only through the messaging queue. The inbound topics are owned by runway; the outbound topics are owned by SubmitQueue. diff --git a/runway/core/BUILD.bazel b/runway/core/BUILD.bazel new file mode 100644 index 00000000..8968ec3f --- /dev/null +++ b/runway/core/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "core", + srcs = ["core.go"], + importpath = "github.com/uber/submitqueue/runway/core", + visibility = ["//visibility:public"], +) diff --git a/runway/core/core.go b/runway/core/core.go new file mode 100644 index 00000000..f3b872dc --- /dev/null +++ b/runway/core/core.go @@ -0,0 +1,20 @@ +// 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 core groups infrastructure shared across Runway's own services — +// the Runway-scoped analogue of the repo-level core/. Cross-domain +// infrastructure lives in the top-level core/; this package is for plumbing +// private to Runway. Subpackages are added here as shared needs emerge, +// mirroring submitqueue/core. +package core 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..6c177596 --- /dev/null +++ b/runway/core/topickey/topickey.go @@ -0,0 +1,32 @@ +// 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 Runway pipeline stage identifiers. +package topickey + +import "github.com/uber/submitqueue/core/consumer" + +// TopicKey is the shared pipeline stage identifier type. +type TopicKey = consumer.TopicKey + +const ( + // TopicKeyCheck is the inbound topic where mergeability check requests arrive from SubmitQueue. + TopicKeyCheck TopicKey = "check" + // TopicKeyLand is the inbound topic where batch land jobs arrive from SubmitQueue. + TopicKeyLand TopicKey = "land" + // TopicKeyCheckResult is the outbound topic where check results are published back to SubmitQueue. + TopicKeyCheckResult TopicKey = "checkresult" + // TopicKeyLandResult is the outbound topic where land results are published back to SubmitQueue. + TopicKeyLandResult TopicKey = "landresult" +) diff --git a/runway/entity/BUILD.bazel b/runway/entity/BUILD.bazel new file mode 100644 index 00000000..c13b0eec --- /dev/null +++ b/runway/entity/BUILD.bazel @@ -0,0 +1,31 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "entity", + srcs = [ + "check.go", + "entity.go", + "job.go", + ], + importpath = "github.com/uber/submitqueue/runway/entity", + visibility = ["//visibility:public"], + deps = [ + "//entity/change", + "//entity/mergestrategy", + ], +) + +go_test( + name = "entity_test", + srcs = [ + "check_test.go", + "job_test.go", + ], + embed = [":entity"], + deps = [ + "//entity/change", + "//entity/mergestrategy", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/runway/entity/check.go b/runway/entity/check.go new file mode 100644 index 00000000..e800b1c3 --- /dev/null +++ b/runway/entity/check.go @@ -0,0 +1,87 @@ +// 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 ( + "encoding/json" + + "github.com/uber/submitqueue/entity/change" + "github.com/uber/submitqueue/entity/mergestrategy" +) + +// Check is the inbound message on the runway-check topic. SubmitQueue publishes +// one Check per request to determine whether the request's changes can merge +// cleanly against the target branch. The check is read-only — it does not +// mutate the target branch or any external state. +type Check struct { + // Queue is the SubmitQueue queue name. + Queue string `json:"queue"` + // RequestID is the SubmitQueue request ID. Serves as the idempotency key. + RequestID string `json:"request_id"` + // Repo identifies the repository (e.g., "uber/submitqueue"). + Repo string `json:"repo"` + // TargetBranch is the destination branch (e.g., "main"). + TargetBranch string `json:"target_branch"` + // Changes is the set of code changes to check for mergeability. + Changes []change.Change `json:"changes"` + // Strategy is the landing strategy that would be used to land these changes. + Strategy mergestrategy.MergeStrategy `json:"strategy"` +} + +// ToBytes serializes the Check to JSON bytes for queue message payload. +func (c Check) ToBytes() ([]byte, error) { + return json.Marshal(c) +} + +// CheckFromBytes deserializes a Check from JSON bytes. +func CheckFromBytes(data []byte) (Check, error) { + var c Check + err := json.Unmarshal(data, &c) + return c, err +} + +// MergeabilityResult describes whether a single change can be applied cleanly +// to the target branch. +type MergeabilityResult struct { + // Change is the input change this result corresponds to. + Change change.Change `json:"change"` + // Mergeable is true if the change can be applied cleanly. + Mergeable bool `json:"mergeable"` + // Reason is a human-readable explanation when Mergeable is false; empty when true. + Reason string `json:"reason,omitempty"` +} + +// CheckResult is the outbound message published to the sq-check-result topic. +// It carries per-change mergeability detail back to SubmitQueue. +type CheckResult struct { + // Queue is the SubmitQueue queue name (partition key for the outbound topic). + Queue string `json:"queue"` + // RequestID correlates to Check.RequestID. + RequestID string `json:"request_id"` + // Results is one entry per change in the input Check.Changes. + Results []MergeabilityResult `json:"results"` +} + +// ToBytes serializes the CheckResult to JSON bytes for queue message payload. +func (r CheckResult) ToBytes() ([]byte, error) { + return json.Marshal(r) +} + +// CheckResultFromBytes deserializes a CheckResult from JSON bytes. +func CheckResultFromBytes(data []byte) (CheckResult, error) { + var r CheckResult + err := json.Unmarshal(data, &r) + return r, err +} diff --git a/runway/entity/check_test.go b/runway/entity/check_test.go new file mode 100644 index 00000000..2c23130f --- /dev/null +++ b/runway/entity/check_test.go @@ -0,0 +1,152 @@ +// 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 TestCheck_SerializationRoundTrip(t *testing.T) { + tests := []struct { + name string + check Check + }{ + { + name: "single change single URI", + check: Check{ + Queue: "go-code-main", + RequestID: "go-code-main/42", + Repo: "uber/submitqueue", + TargetBranch: "main", + Changes: []change.Change{{URIs: []string{"github://uber/submitqueue/pull/123/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}, + Strategy: mergestrategy.MergeStrategyRebase, + }, + }, + { + name: "multiple changes", + check: Check{ + Queue: "queue1", + RequestID: "queue1/100", + Repo: "uber/repo-a", + TargetBranch: "main", + Changes: []change.Change{ + {URIs: []string{"github://uber/repo-a/pull/101/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + {URIs: []string{"github://uber/repo-a/pull/102/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}, + }, + Strategy: mergestrategy.MergeStrategySquashRebase, + }, + }, + { + name: "stacked diff with multiple URIs", + check: Check{ + Queue: "queue2", + RequestID: "queue2/200", + Repo: "uber/submitqueue", + TargetBranch: "release", + Changes: []change.Change{{URIs: []string{ + "github://uber/submitqueue/pull/10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "github://uber/submitqueue/pull/11/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }}}, + Strategy: mergestrategy.MergeStrategyMerge, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := tt.check.ToBytes() + require.NoError(t, err) + + deserialized, err := CheckFromBytes(data) + require.NoError(t, err) + + assert.Equal(t, tt.check, deserialized) + }) + } +} + +func TestCheckFromBytes_InvalidJSON(t *testing.T) { + _, err := CheckFromBytes([]byte(`{"invalid": json"}`)) + assert.Error(t, err) +} + +func TestCheckFromBytes_EmptyData(t *testing.T) { + c, err := CheckFromBytes([]byte(`{}`)) + require.NoError(t, err) + + assert.Empty(t, c.Queue) + assert.Empty(t, c.RequestID) + assert.Equal(t, mergestrategy.MergeStrategyUnknown, c.Strategy) +} + +func TestCheckResult_SerializationRoundTrip(t *testing.T) { + tests := []struct { + name string + result CheckResult + }{ + { + name: "all mergeable", + result: CheckResult{ + Queue: "go-code-main", + RequestID: "go-code-main/42", + Results: []MergeabilityResult{ + {Change: change.Change{URIs: []string{"github://uber/submitqueue/pull/1/aaaa"}}, Mergeable: true}, + {Change: change.Change{URIs: []string{"github://uber/submitqueue/pull/2/bbbb"}}, Mergeable: true}, + }, + }, + }, + { + name: "some unmergeable", + result: CheckResult{ + Queue: "queue1", + RequestID: "queue1/100", + Results: []MergeabilityResult{ + {Change: change.Change{URIs: []string{"github://uber/repo/pull/1/aaaa"}}, Mergeable: true}, + {Change: change.Change{URIs: []string{"github://uber/repo/pull/2/bbbb"}}, Mergeable: false, Reason: "conflicts with src/main.go"}, + }, + }, + }, + { + name: "empty results", + result: CheckResult{ + Queue: "queue2", + RequestID: "queue2/200", + Results: []MergeabilityResult{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := tt.result.ToBytes() + require.NoError(t, err) + + deserialized, err := CheckResultFromBytes(data) + require.NoError(t, err) + + assert.Equal(t, tt.result, deserialized) + }) + } +} + +func TestCheckResultFromBytes_InvalidJSON(t *testing.T) { + _, err := CheckResultFromBytes([]byte(`not json`)) + assert.Error(t, err) +} diff --git a/runway/entity/entity.go b/runway/entity/entity.go new file mode 100644 index 00000000..3f8abad2 --- /dev/null +++ b/runway/entity/entity.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 entity holds Runway-specific domain types (distinct from shared repo entity/). +package entity diff --git a/runway/entity/job.go b/runway/entity/job.go new file mode 100644 index 00000000..8f194baf --- /dev/null +++ b/runway/entity/job.go @@ -0,0 +1,123 @@ +// 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 ( + "encoding/json" + + "github.com/uber/submitqueue/entity/change" + "github.com/uber/submitqueue/entity/mergestrategy" +) + +// ResultStatus defines the possible outcomes of a landing job. +type ResultStatus string + +const ( + // ResultStatusUnknown is the unreachable zero value, set by default when + // the structure is initialized. It should never be seen in the system. + ResultStatusUnknown ResultStatus = "" + // ResultStatusSucceeded means all items in the job were landed successfully. + ResultStatusSucceeded ResultStatus = "succeeded" + // ResultStatusConflict means pre-validation detected a merge conflict. + ResultStatusConflict ResultStatus = "conflict" + // ResultStatusError means an infrastructure failure prevented landing. + ResultStatusError ResultStatus = "error" +) + +// Job is the inbound message on the runway-land topic. SubmitQueue publishes +// one Job per batch to land a scored batch. The job carries the resolved content +// for each request in the batch — runway has no access to SubmitQueue's request +// store, so the message must be self-contained. +type Job struct { + // ID is the unique job identifier (idempotency key). + ID string `json:"id"` + // BatchID is the SubmitQueue batch ID for correlation. + BatchID string `json:"batch_id"` + // Queue is the SubmitQueue queue name. + Queue string `json:"queue"` + // Repo identifies the repository (e.g., "uber/submitqueue"). + Repo string `json:"repo"` + // TargetBranch is the destination branch (e.g., "main"). + TargetBranch string `json:"target_branch"` + // Items is the per-request content, in landing order. + Items []JobItem `json:"items"` +} + +// ToBytes serializes the Job to JSON bytes for queue message payload. +func (j Job) ToBytes() ([]byte, error) { + return json.Marshal(j) +} + +// JobFromBytes deserializes a Job from JSON bytes. +func JobFromBytes(data []byte) (Job, error) { + var j Job + err := json.Unmarshal(data, &j) + return j, err +} + +// JobItem carries one request's resolved content within a Job. +type JobItem struct { + // RequestID is the SubmitQueue request ID for correlation. + RequestID string `json:"request_id"` + // Change is the code change to land. + Change change.Change `json:"change"` + // Strategy is the per-request landing strategy. + Strategy mergestrategy.MergeStrategy `json:"strategy"` +} + +// Outcome describes what happened to a single item within a successful landing. +type Outcome struct { + // RequestID is the SubmitQueue request ID for correlation. + RequestID string `json:"request_id"` + // Change is the input change this outcome corresponds to. + Change change.Change `json:"change"` + // CommitSHAs lists the commits produced on the target branch, in apply order. + // Empty when AlreadyExisted is true. + CommitSHAs []string `json:"commit_shas"` + // AlreadyExisted is true if the change was already present on the target + // branch (no new commits were produced). + AlreadyExisted bool `json:"already_existed"` +} + +// Result is the outbound message published to the sq-land-result topic. +// It carries the landing outcome back to SubmitQueue. +type Result struct { + // JobID correlates to Job.ID. + JobID string `json:"job_id"` + // BatchID is the SubmitQueue batch ID. + BatchID string `json:"batch_id"` + // Queue is the SubmitQueue queue name (partition key for the outbound topic). + Queue string `json:"queue"` + // Status is the overall landing outcome. + Status ResultStatus `json:"status"` + // Outcomes is one entry per item, in landing order. Populated when Status + // is ResultStatusSucceeded. + Outcomes []Outcome `json:"outcomes,omitempty"` + // Error is a human-readable description of the failure. Populated when + // Status is ResultStatusConflict or ResultStatusError. + Error string `json:"error,omitempty"` +} + +// ToBytes serializes the Result to JSON bytes for queue message payload. +func (r Result) ToBytes() ([]byte, error) { + return json.Marshal(r) +} + +// ResultFromBytes deserializes a Result from JSON bytes. +func ResultFromBytes(data []byte) (Result, error) { + var r Result + err := json.Unmarshal(data, &r) + return r, err +} diff --git a/runway/entity/job_test.go b/runway/entity/job_test.go new file mode 100644 index 00000000..dca72c93 --- /dev/null +++ b/runway/entity/job_test.go @@ -0,0 +1,176 @@ +// 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 TestJob_SerializationRoundTrip(t *testing.T) { + tests := []struct { + name string + job Job + }{ + { + name: "single item rebase", + job: Job{ + ID: "job-001", + BatchID: "go-code-main/batch/1", + Queue: "go-code-main", + Repo: "uber/submitqueue", + TargetBranch: "main", + Items: []JobItem{ + { + RequestID: "go-code-main/42", + Change: change.Change{URIs: []string{"github://uber/submitqueue/pull/123/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + Strategy: mergestrategy.MergeStrategyRebase, + }, + }, + }, + }, + { + name: "multiple items mixed strategies", + job: Job{ + ID: "job-002", + BatchID: "queue1/batch/5", + Queue: "queue1", + Repo: "uber/repo-a", + TargetBranch: "main", + Items: []JobItem{ + { + RequestID: "queue1/10", + Change: change.Change{URIs: []string{"github://uber/repo-a/pull/10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + Strategy: mergestrategy.MergeStrategyRebase, + }, + { + RequestID: "queue1/11", + Change: change.Change{URIs: []string{"github://uber/repo-a/pull/11/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}, + Strategy: mergestrategy.MergeStrategySquashRebase, + }, + { + RequestID: "queue1/12", + Change: change.Change{URIs: []string{"github://uber/repo-a/pull/12/cccccccccccccccccccccccccccccccccccccccc"}}, + Strategy: mergestrategy.MergeStrategyMerge, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := tt.job.ToBytes() + require.NoError(t, err) + + deserialized, err := JobFromBytes(data) + require.NoError(t, err) + + assert.Equal(t, tt.job, deserialized) + }) + } +} + +func TestJobFromBytes_InvalidJSON(t *testing.T) { + _, err := JobFromBytes([]byte(`{"invalid": json"}`)) + assert.Error(t, err) +} + +func TestJobFromBytes_EmptyData(t *testing.T) { + j, err := JobFromBytes([]byte(`{}`)) + require.NoError(t, err) + + assert.Empty(t, j.ID) + assert.Empty(t, j.BatchID) + assert.Empty(t, j.Queue) + assert.Nil(t, j.Items) +} + +func TestResult_SerializationRoundTrip(t *testing.T) { + tests := []struct { + name string + result Result + }{ + { + name: "succeeded with outcomes", + result: Result{ + JobID: "job-001", + BatchID: "go-code-main/batch/1", + Queue: "go-code-main", + Status: ResultStatusSucceeded, + Outcomes: []Outcome{ + { + RequestID: "go-code-main/42", + Change: change.Change{URIs: []string{"github://uber/submitqueue/pull/123/aaaa"}}, + CommitSHAs: []string{"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}, + }, + { + RequestID: "go-code-main/43", + Change: change.Change{URIs: []string{"github://uber/submitqueue/pull/124/bbbb"}}, + AlreadyExisted: true, + }, + }, + }, + }, + { + name: "conflict", + result: Result{ + JobID: "job-002", + BatchID: "queue1/batch/5", + Queue: "queue1", + Status: ResultStatusConflict, + Error: "item queue1/11 conflicts with src/main.go", + }, + }, + { + name: "error", + result: Result{ + JobID: "job-003", + BatchID: "queue2/batch/10", + Queue: "queue2", + Status: ResultStatusError, + Error: "git push failed: remote rejected", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := tt.result.ToBytes() + require.NoError(t, err) + + deserialized, err := ResultFromBytes(data) + require.NoError(t, err) + + assert.Equal(t, tt.result, deserialized) + }) + } +} + +func TestResultFromBytes_InvalidJSON(t *testing.T) { + _, err := ResultFromBytes([]byte(`not json`)) + assert.Error(t, err) +} + +func TestResultStatus_Values(t *testing.T) { + assert.Equal(t, ResultStatus(""), ResultStatusUnknown) + assert.Equal(t, ResultStatus("succeeded"), ResultStatusSucceeded) + assert.Equal(t, ResultStatus("conflict"), ResultStatusConflict) + assert.Equal(t, ResultStatus("error"), ResultStatusError) +}