Skip to content

Commit 3f6b3bc

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 4926886 commit 3f6b3bc

5 files changed

Lines changed: 149 additions & 26 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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,16 +294,18 @@ func (m *gitMerger) process(ctx context.Context, req *runwaymq.MergeRequest, com
294294
}
295295

296296
// resolveAndValidate normalizes DEFAULT strategies to the configured default,
297-
// resolves every change URI once for the apply paths to work from, and enforces
298-
// the PROMOTE composition rule. All failures here are terminal
299-
// (merger.ErrInvalidRequest): retrying never succeeds, so the controller
300-
// publishes a FAILED result rather than nacking.
297+
// resolves every change URI once for the apply paths to work from, checks that
298+
// the request's changes agree on a provider, and enforces the PROMOTE
299+
// composition rule. All failures here are terminal (merger.ErrInvalidRequest):
300+
// retrying never succeeds, so the controller publishes a FAILED result rather
301+
// than nacking.
301302
func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedStep, error) {
302303
if len(req.GetSteps()) == 0 {
303304
return nil, fmt.Errorf("%w: request has no steps", merger.ErrInvalidRequest)
304305
}
305306

306307
resolved := make([]resolvedStep, 0, len(req.GetSteps()))
308+
var first providerCheck
307309
promoteSeen := false
308310
for _, step := range req.GetSteps() {
309311
strategy := step.GetStrategy()
@@ -327,6 +329,9 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
327329
if err != nil {
328330
return nil, err
329331
}
332+
if err := first.check(ref, step.GetStepId()); err != nil {
333+
return nil, err
334+
}
330335
refs = append(refs, ref)
331336
}
332337
resolved = append(resolved, resolvedStep{step: step, strategy: strategy, refs: refs})
@@ -346,6 +351,32 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
346351
return resolved, nil
347352
}
348353

354+
// providerCheck holds the provider the first change in a request established,
355+
// and rejects any later change addressed through a different one.
356+
//
357+
// There is no sense in one merge being addressed through two providers, and
358+
// SubmitQueue already refuses it within a single change. Catching it here costs
359+
// nothing and keeps the apply paths from having to reason about a request whose
360+
// changes were resolved by different parsers.
361+
type providerCheck struct {
362+
provider string
363+
stepID string
364+
set bool
365+
}
366+
367+
// check records the first change's provider and compares every later one to it.
368+
func (o *providerCheck) check(ref changeRef, stepID string) error {
369+
if !o.set {
370+
o.provider, o.stepID, o.set = ref.Provider, stepID, true
371+
return nil
372+
}
373+
if ref.Provider != o.provider {
374+
return fmt.Errorf("%w: request mixes change providers: step %q uses %q, step %q uses %q",
375+
merger.ErrInvalidRequest, o.stepID, o.provider, stepID, ref.Provider)
376+
}
377+
return nil
378+
}
379+
349380
// applyTransforming runs the reset/apply/push cycle for the transforming
350381
// strategies (REBASE, SQUASH_REBASE, MERGE), retrying on remote contention when
351382
// 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)