Skip to content

Commit edcf03d

Browse files
committed
feat(runway): reject changes that disagree on provider
## Summary ### Why? The merger decides which provider a change came from by its URI scheme, and rejects a scheme it has no parser for. That part works, and it happens before any git command runs. What it does not do is check that the changes in one request agree with each other. `resolveChange` determines the provider per URI and then discards it, so a request whose steps are addressed through different providers is resolved by different parsers and applied as though nothing were unusual. SubmitQueue already refuses that within a single change, but a Runway request carries one step per SubmitQueue request, so nothing covers the request as a whole. ### What? Keeps `Provider` on `changeRef` — the scheme the change was addressed through — rather than parsing it and throwing it away. `resolveAndValidate` now compares every change against the first and rejects a request that mixes providers, naming both and the steps they came from. It already walked every URI to validate it, and it runs before the mutex and before any git command, so an incoherent request costs nothing and leaves the checkout untouched. This cannot refuse a legitimate request: there is no way to address one merge through two providers, and the apply paths would otherwise have to reason about changes resolved by different parsers. Also names the change, not just the commit, in the unavailable-commit error, so the reader is not sent looking for a deleted commit when the likelier cause is a change this remote was never going to serve. Whether a change belongs to the repository this merger serves is deliberately not checked. The merger is already constrained to its checkout and remote by configuration, and a change it cannot fetch is refused on those grounds. ## Test Plan ✅ `bazel test //runway/...` — all targets pass (git suite 70s) ✅ `make lint`, `make check-tidy`, `make check-gazelle`, `make test` New cases: two steps using different providers, one change spanning two providers, and an unsupported provider — each asserted terminal and not a conflict. A multi-step multi-URI request through one provider is asserted to still succeed, guarding against over-rejecting. The rejection cases run against a Merger whose git executable does not exist, so any git invocation would fail as an exec error. Getting `ErrInvalidRequest` back proves the request was refused before the merger reached for git.
1 parent b3cf80c commit edcf03d

5 files changed

Lines changed: 149 additions & 25 deletions

File tree

runway/extension/merger/git/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ Every URI is reduced to three things: the commit to apply, the ref the provider
1919

2020
An unrecognized scheme is a terminal invalid request.
2121

22+
## What a request must agree on
23+
24+
The supported providers are a property of this merger, not of the queue or of the wire contract: the URI scheme selects the parser, and a scheme with no case is a terminal invalid request. Nothing upstream filters on it, so an unsupported provider is first refused here.
25+
26+
Beyond the scheme, every change in one request must come from the same provider. There is no sense in one merge being addressed through two of them, and the check runs before any git command, so an incoherent request costs nothing and leaves the checkout untouched.
27+
28+
Whether a change actually belongs to the repository this merger serves is not checked here — the merger is constrained to its checkout and remote by configuration, and a change it cannot fetch is refused on those grounds.
29+
2230
## Object availability
2331

2432
The default fetch refspec is `+refs/heads/*`, which does not cover a provider's change refs — a pull request head never also pushed as a branch, the normal case for a fork, is simply absent locally. Every referenced commit is therefore fetched and verified before any step is applied, so a request naming an unreachable commit fails without having touched the checkout.

runway/extension/merger/git/changeref.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import (
2727
// form that does not depend on which provider minted it. Adding a provider
2828
// means adding one case to resolveChange, not touching the apply paths.
2929
type changeRef struct {
30+
// Provider is the URI scheme the change was addressed through ("github",
31+
// "git"). Every change in one request must agree on it.
32+
Provider string
3033
// SHA is the full commit hash the URI pins the change to. This is the
3134
// commit that gets fetched and applied.
3235
SHA string
@@ -55,7 +58,8 @@ func resolveChange(uri string) (changeRef, error) {
5558
return changeRef{}, fmt.Errorf("%w: invalid change URI %q: %v", merger.ErrInvalidRequest, uri, err)
5659
}
5760
return changeRef{
58-
SHA: cid.HeadCommitSHA,
61+
Provider: scheme,
62+
SHA: cid.HeadCommitSHA,
5963
// GitHub publishes every PR's head under refs/pull/<n>/head in the
6064
// base repository, including PRs opened from a fork.
6165
Ref: fmt.Sprintf("refs/pull/%d/head", cid.PRNumber),
@@ -70,9 +74,10 @@ func resolveChange(uri string) (changeRef, error) {
7074
// A git:// URI already names its own fully-qualified ref, so the
7175
// staleness check reads exactly the ref the caller pinned.
7276
return changeRef{
73-
SHA: cid.CommitSHA,
74-
Ref: cid.Ref,
75-
Label: fmt.Sprintf("%s@%s", cid.Repo, cid.Ref),
77+
Provider: scheme,
78+
SHA: cid.CommitSHA,
79+
Ref: cid.Ref,
80+
Label: fmt.Sprintf("%s@%s", cid.Repo, cid.Ref),
7681
}, nil
7782

7883
default:

runway/extension/merger/git/git_merger.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,17 @@ func (m *gitMerger) process(ctx context.Context, req *runwaymq.MergeRequest, com
289289
}
290290

291291
// resolveAndValidate normalizes DEFAULT strategies to the configured default,
292-
// validates every change URI parses, and enforces the PROMOTE composition rule.
293-
// All failures here are terminal (merger.ErrInvalidRequest): retrying never
292+
// validates every change URI parses and that the request's changes agree on a
293+
// provider, and enforces the PROMOTE composition rule. All
294+
// failures here are terminal (merger.ErrInvalidRequest): retrying never
294295
// succeeds, so the controller publishes a FAILED result rather than nacking.
295296
func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedStep, error) {
296297
if len(req.GetSteps()) == 0 {
297298
return nil, fmt.Errorf("%w: request has no steps", merger.ErrInvalidRequest)
298299
}
299300

300301
resolved := make([]resolvedStep, 0, len(req.GetSteps()))
302+
var first providerCheck
301303
promoteSeen := false
302304
for _, step := range req.GetSteps() {
303305
strategy := step.GetStrategy()
@@ -316,7 +318,11 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
316318
return nil, fmt.Errorf("%w: step %q has no change URIs", merger.ErrInvalidRequest, step.GetStepId())
317319
}
318320
for _, uri := range ch.GetUris() {
319-
if _, err := resolveChange(uri); err != nil {
321+
ref, err := resolveChange(uri)
322+
if err != nil {
323+
return nil, err
324+
}
325+
if err := first.check(ref, step.GetStepId()); err != nil {
320326
return nil, err
321327
}
322328
}
@@ -337,6 +343,32 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
337343
return resolved, nil
338344
}
339345

346+
// providerCheck holds the provider the first change in a request established,
347+
// and rejects any later change addressed through a different one.
348+
//
349+
// There is no sense in one merge being addressed through two providers, and
350+
// SubmitQueue already refuses it within a single change. Catching it here costs
351+
// nothing and keeps the apply paths from having to reason about a request whose
352+
// changes were resolved by different parsers.
353+
type providerCheck struct {
354+
provider string
355+
stepID string
356+
set bool
357+
}
358+
359+
// check records the first change's provider and compares every later one to it.
360+
func (o *providerCheck) check(ref changeRef, stepID string) error {
361+
if !o.set {
362+
o.provider, o.stepID, o.set = ref.Provider, stepID, true
363+
return nil
364+
}
365+
if ref.Provider != o.provider {
366+
return fmt.Errorf("%w: request mixes change providers: step %q uses %q, step %q uses %q",
367+
merger.ErrInvalidRequest, o.stepID, o.provider, stepID, ref.Provider)
368+
}
369+
return nil
370+
}
371+
340372
// applyTransforming runs the reset/apply/push cycle for the transforming
341373
// strategies (REBASE, SQUASH_REBASE, MERGE), retrying on remote contention when
342374
// committing. For a dry run it applies the steps locally then discards them.

runway/extension/merger/git/git_merger_test.go

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,78 @@ func TestClassifyMergeFailure(t *testing.T) {
630630
}
631631
}
632632

633+
// --- change provider consistency ---
634+
635+
// newUnrunnableMerger builds a Merger whose git executable does not exist, so
636+
// any git invocation fails loudly. A request rejected by this Merger with
637+
// ErrInvalidRequest was therefore rejected before it reached for git — a
638+
// stronger claim than observing that the remote did not move.
639+
func (f gitFixture) newUnrunnableMerger(t *testing.T) merger.Merger {
640+
t.Helper()
641+
return f.newMergerWith(t, func(p *Params) {
642+
p.Runtime.Executable = filepath.Join(t.TempDir(), "no-such-git")
643+
})
644+
}
645+
646+
func TestMerge_RejectsInconsistentProvider(t *testing.T) {
647+
const otherSHA = "89abcdef0123456789abcdef0123456789abcdef"
648+
649+
tests := []struct {
650+
name string
651+
req *runwaymq.MergeRequest
652+
}{
653+
{
654+
name: "two steps using different providers",
655+
req: req("b",
656+
stepOf(mergestrategypb.Strategy_REBASE, "s1", "github://github.example.com/uber/one/pull/1/"+fakeSHA),
657+
stepOf(mergestrategypb.Strategy_REBASE, "s2", "git://git.example.com/uber/one/refs%2Fheads%2Fmain/"+otherSHA),
658+
),
659+
},
660+
{
661+
name: "one change spanning two providers",
662+
req: req("b", stepOf(mergestrategypb.Strategy_REBASE, "s1",
663+
"github://github.example.com/uber/one/pull/1/"+fakeSHA,
664+
"git://git.example.com/uber/one/refs%2Fheads%2Fmain/"+otherSHA,
665+
)),
666+
},
667+
{
668+
name: "unsupported provider",
669+
req: req("b", stepOf(mergestrategypb.Strategy_REBASE, "s1", "phab://phab.example.com/D123/456")),
670+
},
671+
}
672+
673+
for _, tt := range tests {
674+
t.Run(tt.name, func(t *testing.T) {
675+
f := setupGitFixture(t)
676+
m := f.newUnrunnableMerger(t)
677+
678+
_, err := m.Merge(context.Background(), tt.req)
679+
require.Error(t, err)
680+
assert.True(t, errors.Is(err, merger.ErrInvalidRequest),
681+
"want ErrInvalidRequest before any git runs, got %v", err)
682+
assert.False(t, errors.Is(err, merger.ErrConflict))
683+
})
684+
}
685+
}
686+
687+
func TestMerge_AcceptsMultipleChangesFromOneProvider(t *testing.T) {
688+
// Guard against over-rejecting: several steps and several URIs are normal,
689+
// so long as they are all addressed through one provider.
690+
f := setupGitFixture(t)
691+
a := f.pushPRCommit(t, "feature/a", "a.txt", "a\n", "add a")
692+
b := f.pushPRCommit(t, "feature/b", "b.txt", "b\n", "add b")
693+
c := f.pushPRCommit(t, "feature/c", "c.txt", "c\n", "add c")
694+
695+
m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
696+
res, err := m.Merge(context.Background(), req("b",
697+
stepOf(mergestrategypb.Strategy_REBASE, "s1", uri(a), uri(b)),
698+
stepOf(mergestrategypb.Strategy_REBASE, "s2", uri(c)),
699+
))
700+
require.NoError(t, err)
701+
assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome())
702+
assert.Len(t, f.remoteCommitsSinceSeed(t), 3)
703+
}
704+
633705
// --- repo migration (unrelated histories) ---
634706
//
635707
// A repository migration reaches Runway as an ordinary change in the target
@@ -991,26 +1063,29 @@ func TestMerge_StalenessCheckOffByDefault(t *testing.T) {
9911063

9921064
func TestResolveChange(t *testing.T) {
9931065
tests := []struct {
994-
name string
995-
uri string
996-
wantSHA string
997-
wantRef string
998-
wantLabel string
999-
wantErr bool
1066+
name string
1067+
uri string
1068+
wantProvider string
1069+
wantSHA string
1070+
wantRef string
1071+
wantLabel string
1072+
wantErr bool
10001073
}{
10011074
{
1002-
name: "github pull request",
1003-
uri: "github://github.example.com/uber/submitqueue/pull/42/" + fakeSHA,
1004-
wantSHA: fakeSHA,
1005-
wantRef: "refs/pull/42/head",
1006-
wantLabel: "uber/submitqueue#42",
1075+
name: "github pull request",
1076+
uri: "github://github.example.com/uber/submitqueue/pull/42/" + fakeSHA,
1077+
wantProvider: "github",
1078+
wantSHA: fakeSHA,
1079+
wantRef: "refs/pull/42/head",
1080+
wantLabel: "uber/submitqueue#42",
10071081
},
10081082
{
1009-
name: "git ref",
1010-
uri: "git://git.example.com/uber/monorepo/refs%2Fheads%2Fmain/" + fakeSHA,
1011-
wantSHA: fakeSHA,
1012-
wantRef: "refs/heads/main",
1013-
wantLabel: "uber/monorepo@refs/heads/main",
1083+
name: "git ref",
1084+
uri: "git://git.example.com/uber/monorepo/refs%2Fheads%2Fmain/" + fakeSHA,
1085+
wantProvider: "git",
1086+
wantSHA: fakeSHA,
1087+
wantRef: "refs/heads/main",
1088+
wantLabel: "uber/monorepo@refs/heads/main",
10141089
},
10151090
{name: "unsupported scheme", uri: "phab://phab.example.com/D123/456", wantErr: true},
10161091
{name: "no scheme", uri: "not-a-uri", wantErr: true},
@@ -1026,6 +1101,7 @@ func TestResolveChange(t *testing.T) {
10261101
return
10271102
}
10281103
require.NoError(t, err)
1104+
assert.Equal(t, tt.wantProvider, got.Provider)
10291105
assert.Equal(t, tt.wantSHA, got.SHA)
10301106
assert.Equal(t, tt.wantRef, got.Ref)
10311107
assert.Equal(t, tt.wantLabel, got.Label)

runway/extension/merger/git/objects.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@ func (m *gitMerger) ensureObject(ctx context.Context, ref changeRef) error {
7070
}
7171

7272
coremetrics.NamedCounter(m.metricsScope, "merge", "object_unavailable", 1)
73-
return fmt.Errorf("%w: commit %s is not available from remote %s (tried by SHA and via %q)",
74-
merger.ErrInvalidRequest, ref.SHA, m.remote, ref.Ref)
73+
// Name the change, not just the commit. A bare "commit not available"
74+
// sends the reader looking for a deleted commit, when the likelier cause is
75+
// a change this remote was never going to be able to serve.
76+
return fmt.Errorf("%w: commit %s of %s is not available from remote %s (tried by SHA and via %q)",
77+
merger.ErrInvalidRequest, ref.SHA, ref.Label, m.remote, ref.Ref)
7578
}
7679

7780
// hasCommit reports whether sha names a commit object in the local checkout.

0 commit comments

Comments
 (0)