Skip to content

Commit c979c6b

Browse files
committed
refactor(change): persist typed change details from the change provider
## Summary ### Why? The change provider already produces rich per-URI facts (author, changed files, line counts), but its value types lived in the extension layer and the data was thrown away — validate fetched ChangeInfo only to log a file count, and ChangeRecord stored an opaque Metadata JSON string that was never written. Nothing downstream could read typed change facts. ### What? - Move the change value types into entities: entity.User, entity.ChangedFile (now with LinesModified), entity.ChangeDetails (the facts), and entity.ChangeInfo (URI -> Details), with aggregation helpers. The changeprovider extension and GitHub impl now produce these. - Replace ChangeRecord.Metadata (opaque string) with typed Details (ChangeDetails); the change table's metadata JSON column becomes details. - Add ChangeStore.UpdateDetails — a version-guarded conditional write, following the optimistic-locking contract (arithmetic in the controller). - validate now persists each fetched ChangeInfo onto the request's change records (per-URI, idempotent; ErrVersionMismatch is a benign no-op). This is the producer half: typed details now exist and are persisted. The score controller consumes them in a follow-up. ## Test Plan - ✅ `make build`, `make test`, `make lint`, `make check-mocks/gazelle/tidy` - ✅ `make integration-test` (storage contract suite round-trips Details and covers UpdateDetails create/update/version-mismatch)
1 parent 771be50 commit c979c6b

16 files changed

Lines changed: 390 additions & 98 deletions

File tree

submitqueue/entity/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ go_library(
77
"batch_dependent.go",
88
"build.go",
99
"cancel_request.go",
10+
"change_provider.go",
1011
"change_record.go",
1112
"land_request.go",
1213
"queue_config.go",
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
// User represents the author of a change.
18+
type User struct {
19+
// Name is the display name of the user.
20+
Name string `json:"name"`
21+
// Email is the email address of the user.
22+
Email string `json:"email"`
23+
}
24+
25+
// ChangedFile represents a single file modification in a change.
26+
type ChangedFile struct {
27+
// Path is the file path relative to the repository root.
28+
Path string `json:"path"`
29+
// Patch is the diff patch content for this file.
30+
Patch string `json:"patch,omitempty"`
31+
// LinesAdded is the number of lines added in this file.
32+
LinesAdded int `json:"lines_added"`
33+
// LinesDeleted is the number of lines deleted in this file.
34+
LinesDeleted int `json:"lines_deleted"`
35+
// LinesModified is the number of lines modified in this file. Some providers
36+
// (e.g. GitHub) report only additions and deletions and leave this zero.
37+
LinesModified int `json:"lines_modified"`
38+
}
39+
40+
// TotalLines returns the total number of lines touched in this file.
41+
func (f ChangedFile) TotalLines() int {
42+
return f.LinesAdded + f.LinesDeleted + f.LinesModified
43+
}
44+
45+
// ChangeDetails holds the provider-supplied facts about a single change (author,
46+
// modified files, line counts). It carries no identity — the owning URI lives on
47+
// ChangeInfo (provider correlation) and ChangeRecord (persisted claim).
48+
type ChangeDetails struct {
49+
// User is the author of the change.
50+
User User `json:"user"`
51+
// ChangedFiles is the list of files modified in this change. Order is unspecified.
52+
ChangedFiles []ChangedFile `json:"changed_files,omitempty"`
53+
}
54+
55+
// TotalLinesChanged returns the total number of lines touched across all files in the change.
56+
func (d ChangeDetails) TotalLinesChanged() int {
57+
total := 0
58+
for _, f := range d.ChangedFiles {
59+
total += f.TotalLines()
60+
}
61+
return total
62+
}
63+
64+
// FileCount returns the number of files touched in the change.
65+
func (d ChangeDetails) FileCount() int {
66+
return len(d.ChangedFiles)
67+
}
68+
69+
// ChangeInfo maps a change URI to its details. It is the change provider's return
70+
// type: for a Change with multiple URIs (e.g. a stacked PR set), the provider
71+
// returns one ChangeInfo per URI so callers can correlate results to inputs by URI.
72+
type ChangeInfo struct {
73+
// URI is the full change URI for correlation with the input request
74+
// (e.g., "github://uber/repo/pull/98/c3a4d5e6f7890123456789abcdef0123456789ab").
75+
URI string `json:"uri"`
76+
// Details is the provider-supplied facts for this URI.
77+
Details ChangeDetails `json:"details"`
78+
}

submitqueue/entity/change_record.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
package entity
1616

1717
// ChangeRecord represents a single URI's claim by a request, persisted in the change store.
18-
// The (Queue, URI, RequestID) triple is the identity and is immutable; Metadata may be
19-
// updated over time as additional information about the change (e.g., PR title, author,
20-
// mergeability) becomes available.
18+
// The (Queue, URI, RequestID) triple is the identity and is immutable; Details may be
19+
// updated over time as the change provider supplies information about the change (author,
20+
// changed files, line counts).
2121
type ChangeRecord struct {
2222
// URI identifies the change (RFC 3986). Same scheme/format as entity.Change.URIs.
2323
// Example: "github://uber/submitqueue/pull/123/c3a4d5e6f7890123456789abcdef0123456789ab".
@@ -29,27 +29,27 @@ type ChangeRecord struct {
2929
// RequestID participates in the change-store primary key so that concurrent claims
3030
// by different requests on the same URI coexist as distinct rows. Same-request
3131
// retries collide on the PK and are absorbed idempotently; different-request
32-
// collisions surface as additional rows that callers detect via FindOverlapping.
32+
// collisions surface as additional rows that callers detect via GetByURI.
3333
RequestID string `json:"request_id"`
3434

3535
// Queue is the queue the owning request belongs to. It is the leading column of
3636
// the change-store primary key, so queue-scoped duplicate checks become PK-prefix
3737
// scans and the table is shardable by queue.
3838
Queue string `json:"queue"`
3939

40-
// Metadata is a JSON-encoded blob of provider-specific information about the change
41-
// (e.g., PR title, author, mergeable state). Stored as `'{}'` when no metadata has
42-
// been populated yet; updated by downstream enrichment.
43-
Metadata string `json:"metadata,omitempty"`
40+
// Details holds the provider-supplied facts about the change (author, changed files,
41+
// line counts). Zero-valued when no provider data has been populated yet; updated by
42+
// downstream enrichment (the validate controller) via the change store's UpdateDetails.
43+
Details ChangeDetails `json:"details"`
4444

4545
// CreatedAt is the Unix milliseconds timestamp when this record was first created.
4646
CreatedAt int64 `json:"created_at"`
4747

48-
// UpdatedAt is the Unix milliseconds timestamp when this record's Metadata was last updated.
48+
// UpdatedAt is the Unix milliseconds timestamp when this record's Details was last updated.
4949
// Equal to CreatedAt when the record has never been updated.
5050
UpdatedAt int64 `json:"updated_at"`
5151

52-
// Version is the optimistic-locking counter for mutable fields (Metadata).
52+
// Version is the optimistic-locking counter for mutable fields (Details).
5353
// Starts at 1 on Create and is incremented by callers on every update.
5454
// Mirrors the request-store convention: callers compute newVersion = oldVersion + 1
5555
// and pass both to the update method; the store performs a pure conditional write.

submitqueue/extension/changeprovider/change_provider.go

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,44 +22,17 @@ import (
2222
"github.com/uber/submitqueue/submitqueue/entity"
2323
)
2424

25-
// User represents the author of a change.
26-
type User struct {
27-
// Name is the display name of the user.
28-
Name string
29-
// Email is the email address of the user.
30-
Email string
31-
}
32-
33-
// ChangedFile represents a single file modification in a change.
34-
type ChangedFile struct {
35-
// Path is the file path relative to the repository root.
36-
Path string
37-
// Patch is the diff patch content for this file.
38-
Patch string
39-
// LinesAdded is the number of lines added in this file.
40-
LinesAdded int
41-
// LinesDeleted is the number of lines deleted in this file.
42-
LinesDeleted int
43-
}
44-
45-
// ChangeInfo contains metadata and file changes for a code change.
46-
type ChangeInfo struct {
47-
// URI is the full change URI for correlation with the input request
48-
// (e.g., "github://uber/repo/pull/98/c3a4d5e6f7890123456789abcdef0123456789ab" or "phab://D123/xyz789").
49-
URI string
50-
// User is the author of the change.
51-
User User
52-
// ChangedFiles is the list of files modified in this change. Order is unspecified.
53-
ChangedFiles []ChangedFile
54-
}
55-
5625
// ChangeProvider fetches change metadata from external systems
5726
// Each implementation is configured for a specific provider (GitHub, GitLab, Phabricator).
27+
//
28+
// The change value types it produces — entity.ChangeInfo, entity.ChangeDetails,
29+
// entity.User, entity.ChangedFile — live in the entity package so the same typed
30+
// facts can be persisted (entity.ChangeRecord) and scored without re-declaration.
5831
type ChangeProvider interface {
5932
// Get retrieves change information for the provided Change.
6033
// For a Change with multiple URIs (e.g., stacked PRs), returns one ChangeInfo per URI.
6134
// Returns a slice of ChangeInfo, one for each change in the stack.
62-
Get(ctx context.Context, change entity.Change) ([]ChangeInfo, error)
35+
Get(ctx context.Context, change entity.Change) ([]entity.ChangeInfo, error)
6336
}
6437

6538
// Config carries the per-queue identity handed to a Factory. The system knows

submitqueue/extension/changeprovider/github/convert.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,32 @@
11
package github
22

33
import (
4+
"github.com/uber/submitqueue/submitqueue/entity"
45
entitygithub "github.com/uber/submitqueue/submitqueue/entity/github"
5-
"github.com/uber/submitqueue/submitqueue/extension/changeprovider"
66
)
77

8-
// convertToChangeInfo converts GitHub PR data to ChangeInfo.
9-
func convertToChangeInfo(parsed entitygithub.ChangeID, prData *pullRequestData) changeprovider.ChangeInfo {
10-
changedFiles := convertFiles(prData.Files.Nodes)
11-
12-
return changeprovider.ChangeInfo{
8+
// convertToChangeInfo converts GitHub PR data to an entity.ChangeInfo.
9+
func convertToChangeInfo(parsed entitygithub.ChangeID, prData *pullRequestData) entity.ChangeInfo {
10+
return entity.ChangeInfo{
1311
URI: parsed.String(),
14-
User: changeprovider.User{
15-
Name: prData.Author.Name,
16-
Email: prData.Author.Email,
12+
Details: entity.ChangeDetails{
13+
User: entity.User{
14+
Name: prData.Author.Name,
15+
Email: prData.Author.Email,
16+
},
17+
ChangedFiles: convertFiles(prData.Files.Nodes),
1718
},
18-
ChangedFiles: changedFiles,
1919
}
2020
}
2121

22-
// convertFiles converts GitHub file nodes to ChangedFile structs.
23-
func convertFiles(nodes []fileNode) []changeprovider.ChangedFile {
24-
changedFiles := make([]changeprovider.ChangedFile, 0, len(nodes))
22+
// convertFiles converts GitHub file nodes to entity.ChangedFile structs.
23+
// GitHub's API reports only additions and deletions per file, so LinesModified
24+
// is left zero here.
25+
func convertFiles(nodes []fileNode) []entity.ChangedFile {
26+
changedFiles := make([]entity.ChangedFile, 0, len(nodes))
2527

2628
for _, file := range nodes {
27-
changedFiles = append(changedFiles, changeprovider.ChangedFile{
29+
changedFiles = append(changedFiles, entity.ChangedFile{
2830
Path: file.Path,
2931
Patch: file.Patch,
3032
LinesAdded: file.Additions,

submitqueue/extension/changeprovider/github/provider.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func NewProvider(params Params) changeprovider.ChangeProvider {
4444

4545
// Get retrieves change information from GitHub for the provided Change.
4646
// Returns one ChangeInfo per URI (one per PR in stacked changes).
47-
func (p *provider) Get(ctx context.Context, change entity.Change) (_ []changeprovider.ChangeInfo, retErr error) {
47+
func (p *provider) Get(ctx context.Context, change entity.Change) (_ []entity.ChangeInfo, retErr error) {
4848
op := coremetrics.Begin(p.metricsScope, "get")
4949
defer func() { op.Complete(retErr) }()
5050

@@ -85,8 +85,8 @@ func (p *provider) Get(ctx context.Context, change entity.Change) (_ []changepro
8585
func (p *provider) fetchAllPRs(
8686
ctx context.Context,
8787
changeIDs []entitygithub.ChangeID,
88-
) ([]changeprovider.ChangeInfo, error) {
89-
changeInfos := make([]changeprovider.ChangeInfo, 0, len(changeIDs))
88+
) ([]entity.ChangeInfo, error) {
89+
changeInfos := make([]entity.ChangeInfo, 0, len(changeIDs))
9090

9191
for _, cid := range changeIDs {
9292
prData, err := p.fetchPullRequest(ctx, cid)
@@ -109,7 +109,7 @@ func (p *provider) fetchAllPRs(
109109
"org", cid.Org,
110110
"repo", cid.Repo,
111111
"pr", cid.PRNumber,
112-
"files_count", len(changeInfo.ChangedFiles),
112+
"files_count", len(changeInfo.Details.ChangedFiles),
113113
"head_sha", prData.HeadRefOid,
114114
)
115115
}

submitqueue/extension/changeprovider/github/provider_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ func TestProvider_Get(t *testing.T) {
115115
require.NoError(t, err)
116116
require.Len(t, infos, 1)
117117
assert.Equal(t, tt.uris[0], infos[0].URI)
118-
assert.Len(t, infos[0].ChangedFiles, 2)
118+
assert.Len(t, infos[0].Details.ChangedFiles, 2)
119119
})
120120
}
121121
}
@@ -154,7 +154,7 @@ func TestProvider_Get_Pagination(t *testing.T) {
154154
require.NoError(t, err)
155155
assert.Equal(t, 2, callCount)
156156
require.Len(t, infos, 1)
157-
assert.Len(t, infos[0].ChangedFiles, 2)
157+
assert.Len(t, infos[0].Details.ChangedFiles, 2)
158158
}
159159

160160
func TestProvider_Get_MultiplePRs(t *testing.T) {

submitqueue/extension/changeprovider/mock/change_provider_mock.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

submitqueue/extension/storage/change_store.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import (
2424

2525
// ChangeStore manages per-URI claim records for in-flight land requests.
2626
// Each row records that a specific URI was claimed by a specific request, scoped to a queue.
27-
// The (Queue, URI, RequestID) triple is the immutable identity of a record. Metadata may
28-
// evolve over time.
27+
// The (Queue, URI, RequestID) triple is the immutable identity of a record. Details may
28+
// evolve over time as the change provider supplies information about the change.
2929
//
3030
// The interface is intentionally per-record / per-URI so that any backend (SQL, DynamoDB,
3131
// Bigtable, …) can implement it without needing batch-atomicity or multi-key query support.
@@ -48,4 +48,17 @@ type ChangeStore interface {
4848
// state — callers that want to skip self filter by RequestID, and callers
4949
// that want only live owners consult RequestStore for liveness.
5050
GetByURI(ctx context.Context, queue string, uri string) ([]entity.ChangeRecord, error)
51+
52+
// Update conditionally replaces a record's mutable fields (Details, UpdatedAt)
53+
// and sets its version to newVersion, guarded by oldVersion. The record carries
54+
// the row to write: its (Queue, URI, RequestID) identify the row and its Details
55+
// and UpdatedAt are the new values. The store performs a pure conditional write —
56+
// it applies the record only when the persisted version equals oldVersion.
57+
// Version arithmetic is owned by the caller, which passes both oldVersion (the
58+
// where-clause guard) and newVersion (the value written to the version column);
59+
// record.Version is not read by the store.
60+
//
61+
// Returns ErrVersionMismatch when no row matches the identity at oldVersion —
62+
// i.e. a stale read, a concurrent update that won, or a missing record.
63+
Update(ctx context.Context, record entity.ChangeRecord, oldVersion int32, newVersion int32) error
5164
}

submitqueue/extension/storage/mock/change_store_mock.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)