Skip to content

Commit e035fd1

Browse files
committed
feat(sync): implement deferred per-target metrics recording
Activate Task 7 deferred metrics in RepositorySync.Execute: track finalBranchName/commitSHA/allChanges/finalErr via defer and call recordTargetResult on every exit path (success, failure, skipped, no_changes). Implement full recordTargetResult that resolves repo/group/ target DB IDs, calculates per-file line counts, builds a BroadcastSyncTargetResult, persists file change records, and aggregates run-level totals via Engine.RecordTargetStats. Extend SyncMetricsRecorder with LookupGroupID, LookupRepoID, and LookupTargetID; implement them in dbMetricsAdapter with sentinel errors for unconfigured repos. Update NewDBMetricsAdapter to accept all four repository types and wire them in the CLI sync command. Resolve group and source-repo DB IDs during recordSyncRunStart. Implement isRunningInCI with real env-var checks (CI, GITHUB_ACTIONS, GITLAB_CI, etc.).
1 parent d645d13 commit e035fd1

8 files changed

Lines changed: 495 additions & 61 deletions

File tree

internal/cli/sync.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,11 @@ func tryAttachMetricsRecorder(engine *sync.Engine, log *logrus.Logger) func() {
460460
return func() {}
461461
}
462462

463-
repo := db.NewBroadcastSyncRepo(database.DB())
464-
adapter := sync.NewDBMetricsAdapter(repo)
463+
syncRepo := db.NewBroadcastSyncRepo(database.DB())
464+
repoRepo := db.NewRepoRepository(database.DB())
465+
targetRepo := db.NewTargetRepository(database.DB())
466+
groupRepo := db.NewGroupRepository(database.DB())
467+
adapter := sync.NewDBMetricsAdapter(syncRepo, repoRepo, targetRepo, groupRepo)
465468
engine.SetSyncMetricsRecorder(adapter)
466469

467470
return func() { _ = database.Close() }

internal/sync/engine.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,18 @@ func (e *Engine) setCurrentRun(run *BroadcastSyncRun) {
141141
e.currentRun = run
142142
}
143143

144+
// RecordTargetStats adds per-target file/line counts to the current run totals (thread-safe).
145+
// Called from concurrent RepositorySync goroutines; the mutex ensures safe aggregation.
146+
func (e *Engine) RecordTargetStats(filesChanged, linesAdded, linesRemoved int) {
147+
e.currentRunMu.Lock()
148+
defer e.currentRunMu.Unlock()
149+
if e.currentRun != nil {
150+
e.currentRun.TotalFilesChanged += filesChanged
151+
e.currentRun.TotalLinesAdded += linesAdded
152+
e.currentRun.TotalLinesRemoved += linesRemoved
153+
}
154+
}
155+
144156
// GitClient returns the git client for repository operations.
145157
func (e *Engine) GitClient() git.Client {
146158
return e.git
@@ -581,10 +593,19 @@ func (e *Engine) recordSyncRunStart(ctx context.Context, group config.Group, cur
581593
SourceCommit: currentState.Source.LatestCommit,
582594
}
583595

584-
// TODO: Set group ID if available (requires DB lookup to convert string ID to uint)
585-
// For now, we'll rely on ExternalID and SourceCommit for tracking
586-
// groupID lookup would require database access here
587-
run.GroupID = nil
596+
// Resolve group ID from external ID string to DB uint
597+
if groupID, err := e.syncRepo.LookupGroupID(ctx, group.ID); err == nil {
598+
run.GroupID = &groupID
599+
} else {
600+
log.WithError(err).Debug("Could not resolve group DB ID, field will be nil")
601+
}
602+
603+
// Resolve source repo ID from "org/repo" to DB uint
604+
if sourceRepoID, err := e.syncRepo.LookupRepoID(ctx, currentState.Source.Repo); err == nil {
605+
run.SourceRepoID = &sourceRepoID
606+
} else {
607+
log.WithError(err).Debug("Could not resolve source repo DB ID, field will be nil")
608+
}
588609

589610
// Create the run in the database
590611
if err := e.syncRepo.CreateSyncRun(ctx, run); err != nil {

internal/sync/engine_unit_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ var (
1818
errMockFileChanges = errors.New("mock file changes error")
1919
)
2020

21+
// Static error variables for lookup mock methods.
22+
var (
23+
errMockLookup = errors.New("mock lookup error")
24+
)
25+
2126
// mockSyncMetricsRecorder is a minimal mock implementing SyncMetricsRecorder.
2227
type mockSyncMetricsRecorder struct{}
2328

@@ -37,6 +42,18 @@ func (m *mockSyncMetricsRecorder) CreateFileChanges(_ context.Context, _ []Broad
3742
return errMockFileChanges
3843
}
3944

45+
func (m *mockSyncMetricsRecorder) LookupGroupID(_ context.Context, _ string) (uint, error) {
46+
return 0, errMockLookup
47+
}
48+
49+
func (m *mockSyncMetricsRecorder) LookupRepoID(_ context.Context, _ string) (uint, error) {
50+
return 0, errMockLookup
51+
}
52+
53+
func (m *mockSyncMetricsRecorder) LookupTargetID(_ context.Context, _ uint, _ string) (uint, error) {
54+
return 0, errMockLookup
55+
}
56+
4057
func TestEngine_MetricsRecorder(t *testing.T) {
4158
t.Parallel()
4259

internal/sync/metrics.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"crypto/rand"
66
"encoding/hex"
77
"fmt"
8+
"os"
89
"time"
910
)
1011

@@ -22,6 +23,15 @@ type SyncMetricsRecorder interface {
2223

2324
// CreateFileChanges batch creates file change records
2425
CreateFileChanges(ctx context.Context, changes []BroadcastSyncFileChange) error
26+
27+
// LookupGroupID resolves a config group external ID string to a DB uint ID
28+
LookupGroupID(ctx context.Context, groupExternalID string) (uint, error)
29+
30+
// LookupRepoID resolves an "org/repo" string to a DB uint ID
31+
LookupRepoID(ctx context.Context, repoFullName string) (uint, error)
32+
33+
// LookupTargetID resolves a group DB ID + repo full name to a target DB uint ID
34+
LookupTargetID(ctx context.Context, groupDBID uint, repoFullName string) (uint, error)
2535
}
2636

2737
// BroadcastSyncRun is a minimal representation for the sync engine
@@ -150,8 +160,11 @@ func DetermineTrigger(options *Options) string {
150160

151161
// isRunningInCI checks if we're running in a CI environment
152162
func isRunningInCI() bool {
153-
// For now, we'll just return false as a safe default
154-
// This can be enhanced later with actual env var checking:
155-
// - Check for CI, GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, etc.
163+
ciVars := []string{"CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL", "BUILDKITE"}
164+
for _, v := range ciVars {
165+
if os.Getenv(v) != "" {
166+
return true
167+
}
168+
}
156169
return false
157170
}

internal/sync/metrics_adapter.go

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,37 @@ package sync
22

33
import (
44
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
58

69
"github.com/mrz1836/go-broadcast/internal/db"
710
)
811

12+
// Sentinel errors for metrics adapter
13+
var (
14+
ErrGroupRepoNotConfigured = errors.New("group repository not configured")
15+
ErrRepoRepoNotConfigured = errors.New("repo repository not configured")
16+
ErrTargetRepoNotConfigured = errors.New("target repository not configured")
17+
ErrInvalidRepoFullName = errors.New("invalid repo full name: expected org/repo format")
18+
)
19+
920
// dbMetricsAdapter adapts the db.BroadcastSyncRepo to the SyncMetricsRecorder interface
1021
type dbMetricsAdapter struct {
11-
repo db.BroadcastSyncRepo
22+
repo db.BroadcastSyncRepo
23+
repoRepo db.RepoRepository
24+
targetRepo db.TargetRepository
25+
groupRepo db.GroupRepository
1226
}
1327

1428
// NewDBMetricsAdapter creates a new database metrics adapter
15-
func NewDBMetricsAdapter(repo db.BroadcastSyncRepo) SyncMetricsRecorder {
16-
return &dbMetricsAdapter{repo: repo}
29+
func NewDBMetricsAdapter(repo db.BroadcastSyncRepo, repoRepo db.RepoRepository, targetRepo db.TargetRepository, groupRepo db.GroupRepository) SyncMetricsRecorder {
30+
return &dbMetricsAdapter{
31+
repo: repo,
32+
repoRepo: repoRepo,
33+
targetRepo: targetRepo,
34+
groupRepo: groupRepo,
35+
}
1736
}
1837

1938
// CreateSyncRun creates a new sync run record
@@ -58,6 +77,46 @@ func (a *dbMetricsAdapter) CreateFileChanges(ctx context.Context, changes []Broa
5877
return a.repo.CreateFileChanges(ctx, dbChanges)
5978
}
6079

80+
// LookupGroupID resolves a config group external ID string to a DB uint ID
81+
func (a *dbMetricsAdapter) LookupGroupID(ctx context.Context, groupExternalID string) (uint, error) {
82+
if a.groupRepo == nil {
83+
return 0, ErrGroupRepoNotConfigured
84+
}
85+
group, err := a.groupRepo.GetByExternalID(ctx, groupExternalID)
86+
if err != nil {
87+
return 0, fmt.Errorf("failed to look up group %q: %w", groupExternalID, err)
88+
}
89+
return group.ID, nil
90+
}
91+
92+
// LookupRepoID resolves an "org/repo" string to a DB uint ID
93+
func (a *dbMetricsAdapter) LookupRepoID(ctx context.Context, repoFullName string) (uint, error) {
94+
if a.repoRepo == nil {
95+
return 0, ErrRepoRepoNotConfigured
96+
}
97+
parts := strings.SplitN(repoFullName, "/", 2)
98+
if len(parts) != 2 {
99+
return 0, fmt.Errorf("%w: %q", ErrInvalidRepoFullName, repoFullName)
100+
}
101+
repo, err := a.repoRepo.GetByFullName(ctx, parts[0], parts[1])
102+
if err != nil {
103+
return 0, fmt.Errorf("failed to look up repo %q: %w", repoFullName, err)
104+
}
105+
return repo.ID, nil
106+
}
107+
108+
// LookupTargetID resolves a group DB ID + repo full name to a target DB uint ID
109+
func (a *dbMetricsAdapter) LookupTargetID(ctx context.Context, groupDBID uint, repoFullName string) (uint, error) {
110+
if a.targetRepo == nil {
111+
return 0, ErrTargetRepoNotConfigured
112+
}
113+
target, err := a.targetRepo.GetByRepoName(ctx, groupDBID, repoFullName)
114+
if err != nil {
115+
return 0, fmt.Errorf("failed to look up target for group %d, repo %q: %w", groupDBID, repoFullName, err)
116+
}
117+
return target.ID, nil
118+
}
119+
61120
// convertSyncRunToDB converts sync engine's BroadcastSyncRun to db model
62121
func convertSyncRunToDB(run *BroadcastSyncRun) *db.BroadcastSyncRun {
63122
return &db.BroadcastSyncRun{

internal/sync/metrics_adapter_test.go

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package sync
22

33
import (
4+
"context"
45
"testing"
56
"time"
67

@@ -158,7 +159,98 @@ func TestNewDBMetricsAdapter(t *testing.T) {
158159
t.Parallel()
159160

160161
testDB := db.TestDB(t)
161-
repo := db.NewBroadcastSyncRepo(testDB)
162-
adapter := NewDBMetricsAdapter(repo)
162+
syncRepo := db.NewBroadcastSyncRepo(testDB)
163+
repoRepo := db.NewRepoRepository(testDB)
164+
targetRepo := db.NewTargetRepository(testDB)
165+
groupRepo := db.NewGroupRepository(testDB)
166+
adapter := NewDBMetricsAdapter(syncRepo, repoRepo, targetRepo, groupRepo)
163167
require.NotNil(t, adapter)
164168
}
169+
170+
func TestDBMetricsAdapter_LookupGroupID_NotConfigured(t *testing.T) {
171+
t.Parallel()
172+
173+
testDB := db.TestDB(t)
174+
syncRepo := db.NewBroadcastSyncRepo(testDB)
175+
adapter := NewDBMetricsAdapter(syncRepo, nil, nil, nil)
176+
177+
_, err := adapter.LookupGroupID(context.Background(), "some-group")
178+
require.Error(t, err)
179+
assert.Contains(t, err.Error(), "group repository not configured")
180+
}
181+
182+
func TestDBMetricsAdapter_LookupRepoID_NotConfigured(t *testing.T) {
183+
t.Parallel()
184+
185+
testDB := db.TestDB(t)
186+
syncRepo := db.NewBroadcastSyncRepo(testDB)
187+
adapter := NewDBMetricsAdapter(syncRepo, nil, nil, nil)
188+
189+
_, err := adapter.LookupRepoID(context.Background(), "org/repo")
190+
require.Error(t, err)
191+
assert.Contains(t, err.Error(), "repo repository not configured")
192+
}
193+
194+
func TestDBMetricsAdapter_LookupTargetID_NotConfigured(t *testing.T) {
195+
t.Parallel()
196+
197+
testDB := db.TestDB(t)
198+
syncRepo := db.NewBroadcastSyncRepo(testDB)
199+
adapter := NewDBMetricsAdapter(syncRepo, nil, nil, nil)
200+
201+
_, err := adapter.LookupTargetID(context.Background(), 1, "org/repo")
202+
require.Error(t, err)
203+
assert.Contains(t, err.Error(), "target repository not configured")
204+
}
205+
206+
func TestDBMetricsAdapter_LookupRepoID_InvalidFormat(t *testing.T) {
207+
t.Parallel()
208+
209+
testDB := db.TestDB(t)
210+
syncRepo := db.NewBroadcastSyncRepo(testDB)
211+
repoRepo := db.NewRepoRepository(testDB)
212+
adapter := NewDBMetricsAdapter(syncRepo, repoRepo, nil, nil)
213+
214+
_, err := adapter.LookupRepoID(context.Background(), "invalid-no-slash")
215+
require.Error(t, err)
216+
assert.Contains(t, err.Error(), "invalid repo full name")
217+
}
218+
219+
func TestDBMetricsAdapter_LookupGroupID_NotFound(t *testing.T) {
220+
t.Parallel()
221+
222+
testDB := db.TestDB(t)
223+
syncRepo := db.NewBroadcastSyncRepo(testDB)
224+
groupRepo := db.NewGroupRepository(testDB)
225+
adapter := NewDBMetricsAdapter(syncRepo, nil, nil, groupRepo)
226+
227+
_, err := adapter.LookupGroupID(context.Background(), "nonexistent-group")
228+
require.Error(t, err)
229+
assert.Contains(t, err.Error(), "failed to look up group")
230+
}
231+
232+
func TestDBMetricsAdapter_LookupRepoID_NotFound(t *testing.T) {
233+
t.Parallel()
234+
235+
testDB := db.TestDB(t)
236+
syncRepo := db.NewBroadcastSyncRepo(testDB)
237+
repoRepo := db.NewRepoRepository(testDB)
238+
adapter := NewDBMetricsAdapter(syncRepo, repoRepo, nil, nil)
239+
240+
_, err := adapter.LookupRepoID(context.Background(), "nonexistent/repo")
241+
require.Error(t, err)
242+
assert.Contains(t, err.Error(), "failed to look up repo")
243+
}
244+
245+
func TestDBMetricsAdapter_LookupTargetID_NotFound(t *testing.T) {
246+
t.Parallel()
247+
248+
testDB := db.TestDB(t)
249+
syncRepo := db.NewBroadcastSyncRepo(testDB)
250+
targetRepo := db.NewTargetRepository(testDB)
251+
adapter := NewDBMetricsAdapter(syncRepo, nil, targetRepo, nil)
252+
253+
_, err := adapter.LookupTargetID(context.Background(), 999, "nonexistent/repo")
254+
require.Error(t, err)
255+
assert.Contains(t, err.Error(), "failed to look up target")
256+
}

0 commit comments

Comments
 (0)