diff --git a/Makefile b/Makefile index 2e55bb54..70bed4fc 100644 --- a/Makefile +++ b/Makefile @@ -327,7 +327,7 @@ local-stovepipe-stop: ## Stop the Stovepipe service mocks: ## Generate mock files using mockgen @echo "Generating mocks..." - @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./platform/consumer/... ./stovepipe/extension/storage/... + @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/... @echo "Mocks generated successfully!" proto: ## Generate protobuf files from .proto definitions diff --git a/platform/base/page/BUILD.bazel b/platform/base/page/BUILD.bazel new file mode 100644 index 00000000..cb52fd27 --- /dev/null +++ b/platform/base/page/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "page", + srcs = ["page.go"], + importpath = "github.com/uber/submitqueue/platform/base/page", + visibility = ["//visibility:public"], +) diff --git a/platform/base/page/page.go b/platform/base/page/page.go new file mode 100644 index 00000000..54b7e3f7 --- /dev/null +++ b/platform/base/page/page.go @@ -0,0 +1,30 @@ +// 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 page defines a generic, cursor-paginated result envelope shared across +// domains. Producers return one bounded Page at a time; callers walk further by +// passing the page's NextCursor back to the producing call until it is empty. +package page + +// Page is one bounded slice of a larger sequence, plus an opaque cursor for +// fetching the next page. The element type T is the domain value being paged +// (e.g. a commit URI string, or an entity). +type Page[T any] struct { + // Items are the elements in this page, in the producer's defined order. + Items []T + // NextCursor is an opaque token for fetching the next page, passed back to + // the producing call. It is empty when this page is the last one. Its + // encoding is defined and interpreted solely by the producer. + NextCursor string +} diff --git a/stovepipe/extension/sourcecontrol/BUILD.bazel b/stovepipe/extension/sourcecontrol/BUILD.bazel new file mode 100644 index 00000000..64fe9b70 --- /dev/null +++ b/stovepipe/extension/sourcecontrol/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "sourcecontrol", + srcs = ["sourcecontrol.go"], + importpath = "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol", + visibility = ["//visibility:public"], + deps = ["//platform/base/page"], +) diff --git a/stovepipe/extension/sourcecontrol/README.md b/stovepipe/extension/sourcecontrol/README.md new file mode 100644 index 00000000..da7a8212 --- /dev/null +++ b/stovepipe/extension/sourcecontrol/README.md @@ -0,0 +1,21 @@ +# SourceControl + +Vendor-agnostic interface through which Stovepipe talks to a version control system. It is the **sole owner of URI semantics**: a URI is an opaque, VCS-agnostic locator of a commit. The `git://` scheme used by the reference backend is just one encoding — a Mercurial or Perforce backend mints its own behind the same contract. Nothing outside an implementation parses a URI; it is a token you hand back to ask questions about a ref. + +A `SourceControl` is **bound to a single queue** (a repo+ref) when its `Factory` constructs it from a `Config`, so the behavioral methods take no queue argument. Per the repository's extension rules, this package holds the `SourceControl` interface, its `Config`, and the `Factory` *interface* only — concrete `Factory` implementations and the per-queue routing that picks a backend for a `Config.QueueName` live in the wiring layer. + +## Behavior + +- **Latest** resolves the queue's ref to the URI of its latest commit — the commit a new validation `Request` is minted against during `ingest`. +- **IsAncestor** answers whether one URI is an ancestor of another. The `process` stage uses it to choose a build strategy: if the queue's last-green URI is no longer an ancestor of the latest commit, history was rewritten and a full build is required rather than an incremental one. +- **History** returns a bounded, newest-first page of commit URIs on the ref, using the shared generic `page.Page[string]` (`platform/base/page`). It is paginated with an opaque cursor: callers pass an empty cursor for the newest page and the page's `NextCursor` to walk further back, stopping when it is empty. Pagination keeps a remote backend cheap; callers join the URIs against the request store to render the greenness of each commit. + +## Errors + +Implementations return plain errors and use the package sentinel `ErrNotFound` (with the `IsNotFound` / `WrapNotFound` helpers) when a queue, ref, or URI cannot be resolved. They do not classify errors as user- or infra-caused — the calling controller does that. + +## Implementations + +- **fake** — an in-memory backend seeded with a queue's ref history (newest first), for examples and tests. + +To add a backend, create `sourcecontrol/{backend}/`, implement the `SourceControl` interface, and return it from a `New(...)` constructor. diff --git a/stovepipe/extension/sourcecontrol/fake/BUILD.bazel b/stovepipe/extension/sourcecontrol/fake/BUILD.bazel new file mode 100644 index 00000000..4fefcd2d --- /dev/null +++ b/stovepipe/extension/sourcecontrol/fake/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "fake", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/fake", + visibility = ["//visibility:public"], + deps = [ + "//platform/base/page", + "//stovepipe/extension/sourcecontrol", + ], +) + +go_test( + name = "fake_test", + srcs = ["fake_test.go"], + embed = [":fake"], + deps = [ + "//stovepipe/extension/sourcecontrol", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/stovepipe/extension/sourcecontrol/fake/fake.go b/stovepipe/extension/sourcecontrol/fake/fake.go new file mode 100644 index 00000000..97b15211 --- /dev/null +++ b/stovepipe/extension/sourcecontrol/fake/fake.go @@ -0,0 +1,102 @@ +// 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 fake provides an in-memory sourcecontrol.SourceControl seeded with a +// single queue's linear ref history, ordered newest-first. It is intended for +// examples and tests only, never production. Ancestry is decided by position in +// the seeded slice: an earlier commit (larger index) is an ancestor of a later +// one (smaller index). +package fake + +import ( + "context" + + "github.com/uber/submitqueue/platform/base/page" + "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" +) + +// sourceControlFake serves a single queue's linear history. history[0] is the +// latest commit; higher indices are progressively older ancestors. +type sourceControlFake struct { + history []string +} + +// New returns a sourcecontrol.SourceControl backed by the given ref history, +// ordered newest-first (history[0] is the latest commit). The slice is copied so +// later mutation by the caller does not affect the fake. +func New(history []string) sourcecontrol.SourceControl { + cp := make([]string, len(history)) + copy(cp, history) + return sourceControlFake{history: cp} +} + +// Latest returns the newest commit URI, or ErrNotFound when the history is empty. +func (s sourceControlFake) Latest(_ context.Context) (string, error) { + if len(s.history) == 0 { + return "", sourcecontrol.ErrNotFound + } + return s.history[0], nil +} + +// IsAncestor reports whether ancestor is an ancestor of descendant. Both URIs +// must be on the ref; an unknown URI yields ErrNotFound. Since the history is +// newest-first, ancestor is an ancestor of descendant when its index is greater +// than or equal to descendant's (older-or-equal commit). +func (s sourceControlFake) IsAncestor(_ context.Context, ancestor, descendant string) (bool, error) { + ai := s.indexOf(ancestor) + di := s.indexOf(descendant) + if ai < 0 || di < 0 { + return false, sourcecontrol.ErrNotFound + } + return ai >= di, nil +} + +// History returns one page of commit URIs, newest first. The cursor is the URI +// of the first commit of the page to return; an empty cursor starts at the latest +// commit. A limit of zero or less returns the rest of the history from the cursor +// in a single page. The returned NextCursor is the URI of the next, older commit, +// or empty when the page reaches the end of the history. +func (s sourceControlFake) History(_ context.Context, cursor string, limit int) (page.Page[string], error) { + start := 0 + if cursor != "" { + start = s.indexOf(cursor) + if start < 0 { + return page.Page[string]{}, sourcecontrol.ErrNotFound + } + } + + end := len(s.history) + if limit > 0 && start+limit < end { + end = start + limit + } + + uris := make([]string, end-start) + copy(uris, s.history[start:end]) + + next := "" + if end < len(s.history) { + next = s.history[end] + } + return page.Page[string]{Items: uris, NextCursor: next}, nil +} + +// indexOf returns the index of uri in the history, or -1 if absent. +func (s sourceControlFake) indexOf(uri string) int { + for i, u := range s.history { + if u == uri { + return i + } + } + return -1 +} diff --git a/stovepipe/extension/sourcecontrol/fake/fake_test.go b/stovepipe/extension/sourcecontrol/fake/fake_test.go new file mode 100644 index 00000000..1cfe1ca8 --- /dev/null +++ b/stovepipe/extension/sourcecontrol/fake/fake_test.go @@ -0,0 +1,139 @@ +// 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 fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" +) + +func TestNew_ImplementsInterface(t *testing.T) { + var _ sourcecontrol.SourceControl = New(nil) +} + +// history is ordered newest-first: c is the latest, a is the oldest ancestor. +var history = []string{"git://repo/ref/c", "git://repo/ref/b", "git://repo/ref/a"} + +func TestLatest(t *testing.T) { + tests := []struct { + name string + history []string + want string + wantErr bool + }{ + {name: "newest first", history: history, want: "git://repo/ref/c"}, + {name: "empty history", history: nil, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := New(tt.history).Latest(context.Background()) + if tt.wantErr { + require.ErrorIs(t, err, sourcecontrol.ErrNotFound) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsAncestor(t *testing.T) { + tests := []struct { + name string + ancestor string + descendant string + want bool + wantErr bool + }{ + {name: "older is ancestor of newer", ancestor: "git://repo/ref/a", descendant: "git://repo/ref/c", want: true}, + {name: "newer is not ancestor of older", ancestor: "git://repo/ref/c", descendant: "git://repo/ref/a", want: false}, + {name: "equal is ancestor of itself", ancestor: "git://repo/ref/b", descendant: "git://repo/ref/b", want: true}, + {name: "unknown ancestor", ancestor: "git://repo/ref/x", descendant: "git://repo/ref/a", wantErr: true}, + {name: "unknown descendant", ancestor: "git://repo/ref/a", descendant: "git://repo/ref/x", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := New(history).IsAncestor(context.Background(), tt.ancestor, tt.descendant) + if tt.wantErr { + require.ErrorIs(t, err, sourcecontrol.ErrNotFound) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHistory(t *testing.T) { + tests := []struct { + name string + cursor string + limit int + wantItems []string + wantCursor string + wantErr bool + }{ + { + name: "first page with next cursor", + cursor: "", + limit: 2, + wantItems: []string{"git://repo/ref/c", "git://repo/ref/b"}, + wantCursor: "git://repo/ref/a", + }, + { + name: "second page reaches end", + cursor: "git://repo/ref/a", + limit: 2, + wantItems: []string{"git://repo/ref/a"}, + wantCursor: "", + }, + { + name: "limit larger than remaining returns rest", + cursor: "", + limit: 10, + wantItems: history, + wantCursor: "", + }, + { + name: "zero limit returns rest in one page", + cursor: "", + limit: 0, + wantItems: history, + wantCursor: "", + }, + { + name: "unknown cursor", + cursor: "git://repo/ref/x", + limit: 2, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := New(history).History(context.Background(), tt.cursor, tt.limit) + if tt.wantErr { + require.ErrorIs(t, err, sourcecontrol.ErrNotFound) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantItems, got.Items) + assert.Equal(t, tt.wantCursor, got.NextCursor) + }) + } +} diff --git a/stovepipe/extension/sourcecontrol/mock/BUILD.bazel b/stovepipe/extension/sourcecontrol/mock/BUILD.bazel new file mode 100644 index 00000000..0b0d842a --- /dev/null +++ b/stovepipe/extension/sourcecontrol/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "mock", + srcs = ["sourcecontrol_mock.go"], + importpath = "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/mock", + visibility = ["//visibility:public"], + deps = [ + "//platform/base/page", + "//stovepipe/extension/sourcecontrol", + "@org_uber_go_mock//gomock", + ], +) diff --git a/stovepipe/extension/sourcecontrol/mock/README.md b/stovepipe/extension/sourcecontrol/mock/README.md new file mode 100644 index 00000000..48af6235 --- /dev/null +++ b/stovepipe/extension/sourcecontrol/mock/README.md @@ -0,0 +1,5 @@ +# sourcecontrol mocks + +Generated gomock mock for the `sourcecontrol.SourceControl` interface, used by controller and pipeline tests. + +Mocks are **checked in** and produced by [mockgen](https://github.com/uber-go/mock) from the `//go:generate` directive on `sourcecontrol.go`. After changing the interface, run `make mocks` to regenerate, then `make gazelle` to update `BUILD.bazel`, and commit the result. diff --git a/stovepipe/extension/sourcecontrol/mock/sourcecontrol_mock.go b/stovepipe/extension/sourcecontrol/mock/sourcecontrol_mock.go new file mode 100644 index 00000000..92d52526 --- /dev/null +++ b/stovepipe/extension/sourcecontrol/mock/sourcecontrol_mock.go @@ -0,0 +1,127 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: sourcecontrol.go +// +// Generated by this command: +// +// mockgen -source=sourcecontrol.go -destination=mock/sourcecontrol_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + page "github.com/uber/submitqueue/platform/base/page" + sourcecontrol "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" + gomock "go.uber.org/mock/gomock" +) + +// MockSourceControl is a mock of SourceControl interface. +type MockSourceControl struct { + ctrl *gomock.Controller + recorder *MockSourceControlMockRecorder + isgomock struct{} +} + +// MockSourceControlMockRecorder is the mock recorder for MockSourceControl. +type MockSourceControlMockRecorder struct { + mock *MockSourceControl +} + +// NewMockSourceControl creates a new mock instance. +func NewMockSourceControl(ctrl *gomock.Controller) *MockSourceControl { + mock := &MockSourceControl{ctrl: ctrl} + mock.recorder = &MockSourceControlMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSourceControl) EXPECT() *MockSourceControlMockRecorder { + return m.recorder +} + +// History mocks base method. +func (m *MockSourceControl) History(ctx context.Context, cursor string, limit int) (page.Page[string], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "History", ctx, cursor, limit) + ret0, _ := ret[0].(page.Page[string]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// History indicates an expected call of History. +func (mr *MockSourceControlMockRecorder) History(ctx, cursor, limit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "History", reflect.TypeOf((*MockSourceControl)(nil).History), ctx, cursor, limit) +} + +// IsAncestor mocks base method. +func (m *MockSourceControl) IsAncestor(ctx context.Context, ancestor, descendant string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsAncestor", ctx, ancestor, descendant) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsAncestor indicates an expected call of IsAncestor. +func (mr *MockSourceControlMockRecorder) IsAncestor(ctx, ancestor, descendant any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsAncestor", reflect.TypeOf((*MockSourceControl)(nil).IsAncestor), ctx, ancestor, descendant) +} + +// Latest mocks base method. +func (m *MockSourceControl) Latest(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Latest", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Latest indicates an expected call of Latest. +func (mr *MockSourceControlMockRecorder) Latest(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Latest", reflect.TypeOf((*MockSourceControl)(nil).Latest), ctx) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg sourcecontrol.Config) (sourcecontrol.SourceControl, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(sourcecontrol.SourceControl) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/stovepipe/extension/sourcecontrol/sourcecontrol.go b/stovepipe/extension/sourcecontrol/sourcecontrol.go new file mode 100644 index 00000000..d61a1013 --- /dev/null +++ b/stovepipe/extension/sourcecontrol/sourcecontrol.go @@ -0,0 +1,90 @@ +// 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 sourcecontrol defines the contract through which Stovepipe talks to a +// version control system. It is the sole owner of URI semantics: a URI is an +// opaque, VCS-agnostic locator of a commit (e.g. "git://remote/repo/ref/.../" +// for the reference git backend, but a Mercurial or Perforce backend would mint +// its own scheme). Nothing outside an implementation parses a URI — it is a token +// handed back to ask questions ("what is the latest commit of this ref?", "is A an +// ancestor of B?"). A SourceControl is bound to a single queue (a repo+ref) at +// construction by its Factory, so its methods take no queue argument. +package sourcecontrol + +//go:generate mockgen -source=sourcecontrol.go -destination=mock/sourcecontrol_mock.go -package=mock + +import ( + "context" + "errors" + "fmt" + + "github.com/uber/submitqueue/platform/base/page" +) + +// ErrNotFound is returned when a queue, ref, or URI cannot be resolved by the +// implementation (for example an unknown queue, or an ancestry query referencing +// a URI that is not on the ref). +var ErrNotFound = errors.New("source control reference not found") + +// IsNotFound returns true if any error in the error chain is an ErrNotFound. +func IsNotFound(err error) bool { + return errors.Is(err, ErrNotFound) +} + +// WrapNotFound wraps ErrNotFound with the original error from the implementation. +func WrapNotFound(err error) error { + return fmt.Errorf("%w: %w", ErrNotFound, err) +} + +// SourceControl resolves and compares commit URIs for the single queue it is +// bound to. Implementations interpret URIs; callers treat them as opaque tokens. +type SourceControl interface { + // Latest returns the URI of the latest commit on the queue's ref — the + // commit a new validation Request is minted against. Returns ErrNotFound if + // the queue or ref cannot be resolved. + Latest(ctx context.Context) (string, error) + + // IsAncestor reports whether ancestor is an ancestor of descendant in the + // queue's history. Stovepipe uses it to decide the build strategy: when the + // last-green URI is no longer an ancestor of the latest commit (false), + // history was rewritten and a full build is required instead of an + // incremental one. Returns ErrNotFound if either URI is unknown to the ref. + IsAncestor(ctx context.Context, ancestor, descendant string) (bool, error) + + // History returns a bounded page of the queue's commit URIs, newest first. + // It is paginated with an opaque cursor so a remote backend stays cheap: + // callers pass an empty cursor for the first (newest) page and the page's + // NextCursor to walk further back, stopping when NextCursor is empty. limit + // caps the page size; a limit of zero or less lets the implementation choose a + // default. Callers join the returned URIs against the request store to render + // the greenness/status of each commit. Returns ErrNotFound if the cursor does + // not refer to a position on the ref. + History(ctx context.Context, cursor string, limit int) (page.Page[string], error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything an implementation needs (the VCS endpoint, +// credentials, the ref it maps to) is injected at construction by the integrator. +type Config struct { + // QueueName identifies the queue (a repo+ref) this SourceControl serves. + QueueName string +} + +// Factory builds the SourceControl for a queue. Implementations are provided by +// integrators (and tests) and inject whatever they need at construction. The +// per-queue routing adapter lives in the wiring layer, not here. +type Factory interface { + // For returns the SourceControl for the given queue. + For(cfg Config) (SourceControl, error) +}