chore: move chatd and related packages to /x/ subpackage (#23445)

- Moves `coderd/chatd/`, `coderd/gitsync/`, `enterprise/coderd/chatd/`
under `x/` parent directories to signal instability
- Adds `Experimental:` glue code comments in `coderd/coderd.go`

> 🤖 This PR was created with the help of Coder Agents, and was
reviewed by my human. 🧑‍💻
This commit is contained in:
Cian Johnston
2026-03-23 17:34:43 +00:00
committed by GitHub
parent 86d8b6daee
commit 80a172f932
64 changed files with 92 additions and 90 deletions
+332
View File
@@ -0,0 +1,332 @@
package gitsync
import (
"context"
"database/sql"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
"github.com/coder/quartz"
)
const (
// DiffStatusTTL is how long a successfully refreshed
// diff status remains fresh before becoming stale again.
DiffStatusTTL = 120 * time.Second
// defaultConcurrency is the maximum number of HTTP calls
// made in parallel during a single Refresh batch.
defaultConcurrency = 10
)
// ProviderResolver maps a git remote origin to the gitprovider
// that handles it. Returns nil if no provider matches.
type ProviderResolver func(origin string) gitprovider.Provider
var ErrNoTokenAvailable error = errors.New("no token available")
// ErrRateLimitSkipped indicates that a row was skipped because
// a prior request in the same group hit a rate limit.
var ErrRateLimitSkipped error = errors.New("skipped due to rate limit")
// TokenResolver obtains the user's git access token for a given
// remote origin. Should return nil if no token is available, in
// which case ErrNoTokenAvailable will be returned.
type TokenResolver func(
ctx context.Context,
userID uuid.UUID,
origin string,
) (*string, error)
// RefresherOption configures a Refresher.
type RefresherOption func(*Refresher)
// WithConcurrency sets the maximum number of concurrent HTTP
// calls per Refresh batch. Defaults to defaultConcurrency.
func WithConcurrency(n int) RefresherOption {
return func(r *Refresher) {
if n > 0 {
r.concurrency = n
}
}
}
// Refresher contains the stateless business logic for fetching
// fresh PR data from a git provider given a stale
// database.ChatDiffStatus row.
type Refresher struct {
providers ProviderResolver
tokens TokenResolver
logger slog.Logger
clock quartz.Clock
concurrency int
}
// NewRefresher creates a Refresher with the given dependency
// functions.
func NewRefresher(
providers ProviderResolver,
tokens TokenResolver,
logger slog.Logger,
clock quartz.Clock,
opts ...RefresherOption,
) *Refresher {
r := &Refresher{
providers: providers,
tokens: tokens,
logger: logger,
clock: clock,
concurrency: defaultConcurrency,
}
for _, o := range opts {
o(r)
}
return r
}
// RefreshRequest pairs a stale row with the chat owner who
// holds the git token needed for API calls.
type RefreshRequest struct {
Row database.ChatDiffStatus
OwnerID uuid.UUID
}
// RefreshResult is the outcome for a single row.
// - Params != nil, Error == nil → success, caller should upsert.
// - Params == nil, Error == nil → no PR yet, caller should skip.
// - Params == nil, Error != nil → row-level failure.
type RefreshResult struct {
Request RefreshRequest
Params *database.UpsertChatDiffStatusParams
Error error
}
// groupKey identifies a unique (owner, origin) pair so that
// provider and token resolution happen once per group.
type groupKey struct {
ownerID uuid.UUID
origin string
}
// resolvedGroup holds the pre-resolved provider and token for
// a group of requests that share the same (owner, origin).
type resolvedGroup struct {
provider gitprovider.Provider
token string
indices []int
}
// Refresh fetches fresh PR data for a batch of stale rows.
// Rows are grouped internally by (ownerID, origin) so that
// provider and token resolution happen once per group. HTTP
// calls within and across groups run concurrently, bounded by
// the Refresher's concurrency limit.
//
// A top-level error is returned only when the entire batch
// fails catastrophically. Per-row outcomes are in the
// returned RefreshResult slice (one per input request, same
// order).
func (r *Refresher) Refresh(
ctx context.Context,
requests []RefreshRequest,
) ([]RefreshResult, error) {
results := make([]RefreshResult, len(requests))
for i, req := range requests {
results[i].Request = req
}
// Group request indices by (ownerID, origin).
groups := make(map[groupKey][]int)
for i, req := range requests {
key := groupKey{
ownerID: req.OwnerID,
origin: req.Row.GitRemoteOrigin,
}
groups[key] = append(groups[key], i)
}
// Pre-resolve providers and tokens sequentially. This is
// fast (DB + in-memory config lookups) and avoids
// duplicate resolution for rows in the same group.
var resolved []resolvedGroup
for key, indices := range groups {
provider := r.providers(key.origin)
if provider == nil {
err := xerrors.Errorf("no provider for origin %q", key.origin)
for _, i := range indices {
results[i].Error = err
}
continue
}
token, err := r.tokens(ctx, key.ownerID, key.origin)
if err != nil {
err = xerrors.Errorf("resolve token: %w", err)
} else if token == nil || len(*token) == 0 {
err = ErrNoTokenAvailable
}
if err != nil {
for _, i := range indices {
results[i].Error = err
}
continue
}
resolved = append(resolved, resolvedGroup{
provider: provider,
token: *token,
indices: indices,
})
}
// Process all HTTP calls concurrently with a shared
// semaphore. Each group tracks rate-limit errors
// independently so that a limit hit on one provider
// doesn't stall requests to other providers.
sem := make(chan struct{}, r.concurrency)
var wg sync.WaitGroup
for _, grp := range resolved {
var rateLimitErr atomic.Pointer[gitprovider.RateLimitError]
for _, idx := range grp.indices {
wg.Add(1)
go func() {
defer wg.Done()
// Best-effort rate-limit check before acquiring
// the semaphore to avoid unnecessary blocking.
if rl := rateLimitErr.Load(); rl != nil {
results[idx] = RefreshResult{
Request: requests[idx],
Error: fmt.Errorf("%w: %w", ErrRateLimitSkipped, rl),
}
return
}
// Acquire semaphore slot.
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
results[idx] = RefreshResult{
Request: requests[idx],
Error: ctx.Err(),
}
return
}
// Best-effort rate-limit check after acquiring
// in case it was set while we waited.
if rl := rateLimitErr.Load(); rl != nil {
results[idx] = RefreshResult{
Request: requests[idx],
Error: fmt.Errorf("%w: %w", ErrRateLimitSkipped, rl),
}
return
}
params, err := r.refreshOne(ctx, grp.provider, grp.token, requests[idx].Row)
results[idx] = RefreshResult{
Request: requests[idx],
Params: params,
Error: err,
}
var rlErr *gitprovider.RateLimitError
if errors.As(err, &rlErr) {
rateLimitErr.Store(rlErr)
}
}()
}
}
wg.Wait()
return results, nil
}
// refreshOne processes a single row using an already-resolved
// provider and token.
func (r *Refresher) refreshOne(
ctx context.Context,
provider gitprovider.Provider,
token string,
row database.ChatDiffStatus,
) (*database.UpsertChatDiffStatusParams, error) {
var ref gitprovider.PRRef
var prURL string
if row.Url.Valid && row.Url.String != "" {
// Row already has a PR URL — parse it directly.
parsed, ok := provider.ParsePullRequestURL(row.Url.String)
if !ok {
return nil, xerrors.Errorf("parse pull request URL %q", row.Url.String)
}
ref = parsed
prURL = row.Url.String
} else {
// No PR URL — resolve owner/repo from the remote origin,
// then look up the open PR for this branch.
owner, repo, _, ok := provider.ParseRepositoryOrigin(row.GitRemoteOrigin)
if !ok {
return nil, xerrors.Errorf("parse repository origin %q", row.GitRemoteOrigin)
}
resolved, err := provider.ResolveBranchPullRequest(ctx, token, gitprovider.BranchRef{
Owner: owner,
Repo: repo,
Branch: row.GitBranch,
})
if err != nil {
return nil, xerrors.Errorf("resolve branch pull request: %w", err)
}
if resolved == nil {
// No PR exists yet for this branch.
return nil, nil
}
ref = *resolved
prURL = provider.BuildPullRequestURL(ref)
}
status, err := provider.FetchPullRequestStatus(ctx, token, ref)
if err != nil {
return nil, xerrors.Errorf("fetch pull request status: %w", err)
}
now := r.clock.Now().UTC()
params := &database.UpsertChatDiffStatusParams{
ChatID: row.ChatID,
Url: sql.NullString{String: prURL, Valid: prURL != ""},
PullRequestState: sql.NullString{
String: string(status.State),
Valid: status.State != "",
},
PullRequestTitle: status.Title,
PullRequestDraft: status.Draft,
ChangesRequested: status.ChangesRequested,
Additions: status.DiffStats.Additions,
Deletions: status.DiffStats.Deletions,
ChangedFiles: status.DiffStats.ChangedFiles,
AuthorLogin: sql.NullString{String: status.AuthorLogin, Valid: status.AuthorLogin != ""},
AuthorAvatarUrl: sql.NullString{String: status.AuthorAvatarURL, Valid: status.AuthorAvatarURL != ""},
BaseBranch: sql.NullString{String: status.BaseBranch, Valid: status.BaseBranch != ""},
HeadBranch: sql.NullString{String: status.HeadBranch, Valid: status.HeadBranch != ""},
PrNumber: sql.NullInt32{Int32: int32(status.PRNumber), Valid: true},
Commits: sql.NullInt32{Int32: status.Commits, Valid: true},
Approved: sql.NullBool{Bool: status.Approved, Valid: true},
ReviewerCount: sql.NullInt32{Int32: status.ReviewerCount, Valid: true},
RefreshedAt: now,
StaleAt: now.Add(DiffStatusTTL),
}
return params, nil
}
+823
View File
@@ -0,0 +1,823 @@
package gitsync_test
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/gitsync"
"github.com/coder/quartz"
)
// mockProvider implements gitprovider.Provider with function fields
// so each test can wire only the methods it needs. Any method left
// nil panics with "unexpected call".
type mockProvider struct {
fetchPullRequestStatus func(ctx context.Context, token string, ref gitprovider.PRRef) (*gitprovider.PRStatus, error)
resolveBranchPR func(ctx context.Context, token string, ref gitprovider.BranchRef) (*gitprovider.PRRef, error)
fetchPullRequestDiff func(ctx context.Context, token string, ref gitprovider.PRRef) (string, error)
fetchBranchDiff func(ctx context.Context, token string, ref gitprovider.BranchRef) (string, error)
parseRepositoryOrigin func(raw string) (string, string, string, bool)
parsePullRequestURL func(raw string) (gitprovider.PRRef, bool)
normalizePullRequestURL func(raw string) string
buildBranchURL func(owner, repo, branch string) string
buildRepositoryURL func(owner, repo string) string
buildPullRequestURL func(ref gitprovider.PRRef) string
}
func (m *mockProvider) FetchPullRequestStatus(ctx context.Context, token string, ref gitprovider.PRRef) (*gitprovider.PRStatus, error) {
if m.fetchPullRequestStatus == nil {
panic("unexpected call to FetchPullRequestStatus")
}
return m.fetchPullRequestStatus(ctx, token, ref)
}
func (m *mockProvider) ResolveBranchPullRequest(ctx context.Context, token string, ref gitprovider.BranchRef) (*gitprovider.PRRef, error) {
if m.resolveBranchPR == nil {
panic("unexpected call to ResolveBranchPullRequest")
}
return m.resolveBranchPR(ctx, token, ref)
}
func (m *mockProvider) FetchPullRequestDiff(ctx context.Context, token string, ref gitprovider.PRRef) (string, error) {
if m.fetchPullRequestDiff == nil {
panic("unexpected call to FetchPullRequestDiff")
}
return m.fetchPullRequestDiff(ctx, token, ref)
}
func (m *mockProvider) FetchBranchDiff(ctx context.Context, token string, ref gitprovider.BranchRef) (string, error) {
if m.fetchBranchDiff == nil {
panic("unexpected call to FetchBranchDiff")
}
return m.fetchBranchDiff(ctx, token, ref)
}
func (m *mockProvider) ParseRepositoryOrigin(raw string) (string, string, string, bool) {
if m.parseRepositoryOrigin == nil {
panic("unexpected call to ParseRepositoryOrigin")
}
return m.parseRepositoryOrigin(raw)
}
func (m *mockProvider) ParsePullRequestURL(raw string) (gitprovider.PRRef, bool) {
if m.parsePullRequestURL == nil {
panic("unexpected call to ParsePullRequestURL")
}
return m.parsePullRequestURL(raw)
}
func (m *mockProvider) NormalizePullRequestURL(raw string) string {
if m.normalizePullRequestURL == nil {
panic("unexpected call to NormalizePullRequestURL")
}
return m.normalizePullRequestURL(raw)
}
func (m *mockProvider) BuildBranchURL(owner, repo, branch string) string {
if m.buildBranchURL == nil {
panic("unexpected call to BuildBranchURL")
}
return m.buildBranchURL(owner, repo, branch)
}
func (m *mockProvider) BuildRepositoryURL(owner, repo string) string {
if m.buildRepositoryURL == nil {
panic("unexpected call to BuildRepositoryURL")
}
return m.buildRepositoryURL(owner, repo)
}
func (m *mockProvider) BuildPullRequestURL(ref gitprovider.PRRef) string {
if m.buildPullRequestURL == nil {
panic("unexpected call to BuildPullRequestURL")
}
return m.buildPullRequestURL(ref)
}
func TestRefresher_WithPRURL(t *testing.T) {
t.Parallel()
mp := &mockProvider{
parsePullRequestURL: func(raw string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 42}, true
},
fetchPullRequestStatus: func(_ context.Context, _ string, _ gitprovider.PRRef) (*gitprovider.PRStatus, error) {
return &gitprovider.PRStatus{
State: gitprovider.PRStateOpen,
DiffStats: gitprovider.DiffStats{
Additions: 10,
Deletions: 5,
ChangedFiles: 3,
},
}, nil
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
chatID := uuid.New()
row := database.ChatDiffStatus{
ChatID: chatID,
Url: sql.NullString{String: "https://github.com/org/repo/pull/42", Valid: true},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
require.NoError(t, res.Error)
require.NotNil(t, res.Params)
assert.Equal(t, chatID, res.Params.ChatID)
assert.Equal(t, "open", res.Params.PullRequestState.String)
assert.True(t, res.Params.PullRequestState.Valid)
assert.Equal(t, int32(10), res.Params.Additions)
assert.Equal(t, int32(5), res.Params.Deletions)
assert.Equal(t, int32(3), res.Params.ChangedFiles)
// StaleAt should be ~120s after RefreshedAt.
diff := res.Params.StaleAt.Sub(res.Params.RefreshedAt)
assert.InDelta(t, 120, diff.Seconds(), 5)
}
func TestRefresher_BranchResolvesToPR(t *testing.T) {
t.Parallel()
mp := &mockProvider{
parseRepositoryOrigin: func(_ string) (string, string, string, bool) {
return "org", "repo", "https://github.com/org/repo", true
},
resolveBranchPR: func(_ context.Context, _ string, _ gitprovider.BranchRef) (*gitprovider.PRRef, error) {
return &gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 7}, nil
},
fetchPullRequestStatus: func(_ context.Context, _ string, _ gitprovider.PRRef) (*gitprovider.PRStatus, error) {
return &gitprovider.PRStatus{State: gitprovider.PRStateOpen}, nil
},
buildPullRequestURL: func(_ gitprovider.PRRef) string {
return "https://github.com/org/repo/pull/7"
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
row := database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
require.NoError(t, res.Error)
require.NotNil(t, res.Params)
assert.Contains(t, res.Params.Url.String, "pull/7")
assert.True(t, res.Params.Url.Valid)
assert.Equal(t, "open", res.Params.PullRequestState.String)
}
func TestRefresher_BranchNoPRYet(t *testing.T) {
t.Parallel()
mp := &mockProvider{
parseRepositoryOrigin: func(_ string) (string, string, string, bool) {
return "org", "repo", "https://github.com/org/repo", true
},
resolveBranchPR: func(_ context.Context, _ string, _ gitprovider.BranchRef) (*gitprovider.PRRef, error) {
return nil, nil
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
row := database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
assert.NoError(t, res.Error)
assert.Nil(t, res.Params)
}
func TestRefresher_NoProviderForOrigin(t *testing.T) {
t.Parallel()
providers := func(_ string) gitprovider.Provider { return nil }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
row := database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://example.com/pr/1", Valid: true},
GitRemoteOrigin: "https://example.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
assert.Nil(t, res.Params)
require.Error(t, res.Error)
assert.Contains(t, res.Error.Error(), "no provider")
}
func TestRefresher_TokenResolutionFails(t *testing.T) {
t.Parallel()
var fetchCalled atomic.Bool
mp := &mockProvider{
fetchPullRequestStatus: func(_ context.Context, _ string, _ gitprovider.PRRef) (*gitprovider.PRStatus, error) {
fetchCalled.Store(true)
return nil, errors.New("should not be called")
},
parsePullRequestURL: func(_ string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, true
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return nil, errors.New("token lookup failed")
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
row := database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/1", Valid: true},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
assert.Nil(t, res.Params)
require.Error(t, res.Error)
assert.False(t, fetchCalled.Load(), "FetchPullRequestStatus should not be called when token resolution fails")
}
func TestRefresher_EmptyToken(t *testing.T) {
t.Parallel()
mp := &mockProvider{}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref(""), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
row := database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/1", Valid: true},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
assert.Nil(t, res.Params)
require.ErrorIs(t, res.Error, gitsync.ErrNoTokenAvailable)
}
func TestRefresher_ProviderFetchFails(t *testing.T) {
t.Parallel()
mp := &mockProvider{
parsePullRequestURL: func(_ string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 42}, true
},
fetchPullRequestStatus: func(_ context.Context, _ string, _ gitprovider.PRRef) (*gitprovider.PRStatus, error) {
return nil, errors.New("api error")
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
row := database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/42", Valid: true},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
assert.Nil(t, res.Params)
require.Error(t, res.Error)
assert.Contains(t, res.Error.Error(), "api error")
}
func TestRefresher_PRURLParseFailure(t *testing.T) {
t.Parallel()
mp := &mockProvider{
parsePullRequestURL: func(_ string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{}, false
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
row := database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/not-a-pr", Valid: true},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
assert.Nil(t, res.Params)
require.Error(t, res.Error)
}
func TestRefresher_BatchGroupsByOwnerAndOrigin(t *testing.T) {
t.Parallel()
mp := &mockProvider{
parsePullRequestURL: func(_ string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, true
},
fetchPullRequestStatus: func(_ context.Context, _ string, _ gitprovider.PRRef) (*gitprovider.PRStatus, error) {
return &gitprovider.PRStatus{State: gitprovider.PRStateOpen}, nil
},
}
providers := func(_ string) gitprovider.Provider { return mp }
var tokenCalls atomic.Int32
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
tokenCalls.Add(1)
return ptr.Ref("test-token"), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
ownerID := uuid.New()
originA := "https://github.com/org/repo"
originB := "https://gitlab.com/org/repo"
requests := []gitsync.RefreshRequest{
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/1", Valid: true},
GitRemoteOrigin: originA,
GitBranch: "feature-1",
},
OwnerID: ownerID,
},
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/1", Valid: true},
GitRemoteOrigin: originA,
GitBranch: "feature-2",
},
OwnerID: ownerID,
},
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://gitlab.com/org/repo/pull/1", Valid: true},
GitRemoteOrigin: originB,
GitBranch: "feature-3",
},
OwnerID: ownerID,
},
}
results, err := r.Refresh(context.Background(), requests)
require.NoError(t, err)
require.Len(t, results, 3)
for i, res := range results {
require.NoError(t, res.Error, "result[%d] should not have an error", i)
require.NotNil(t, res.Params, "result[%d] should have params", i)
}
// Two distinct (ownerID, origin) groups → exactly 2 token
// resolution calls.
assert.Equal(t, int32(2), tokenCalls.Load(),
"TokenResolver should be called once per (owner, origin) group")
}
func TestRefresher_UsesInjectedClock(t *testing.T) {
t.Parallel()
mClock := quartz.NewMock(t)
fixedTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
mClock.Set(fixedTime)
mp := &mockProvider{
parsePullRequestURL: func(raw string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 42}, true
},
fetchPullRequestStatus: func(_ context.Context, _ string, _ gitprovider.PRRef) (*gitprovider.PRStatus, error) {
return &gitprovider.PRStatus{
State: gitprovider.PRStateOpen,
DiffStats: gitprovider.DiffStats{
Additions: 10,
Deletions: 5,
ChangedFiles: 3,
},
}, nil
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), mClock)
chatID := uuid.New()
row := database.ChatDiffStatus{
ChatID: chatID,
Url: sql.NullString{String: "https://github.com/org/repo/pull/42", Valid: true},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature",
}
ownerID := uuid.New()
results, err := r.Refresh(context.Background(), []gitsync.RefreshRequest{
{Row: row, OwnerID: ownerID},
})
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
require.NoError(t, res.Error)
require.NotNil(t, res.Params)
// The mock clock is deterministic, so times must be exact.
assert.Equal(t, fixedTime, res.Params.RefreshedAt)
assert.Equal(t, fixedTime.Add(gitsync.DiffStatusTTL), res.Params.StaleAt)
}
func TestRefresher_RateLimitSkipsRemainingInGroup(t *testing.T) {
t.Parallel()
var callCount atomic.Int32
mp := &mockProvider{
parsePullRequestURL: func(raw string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, raw != ""
},
fetchPullRequestStatus: func(_ context.Context, _ string, _ gitprovider.PRRef) (*gitprovider.PRStatus, error) {
// Every call returns a rate limit error. With
// concurrency=1 the first goroutine to acquire the
// semaphore makes the only real call; remaining
// goroutines see the flag and skip.
callCount.Add(1)
return nil, &gitprovider.RateLimitError{
RetryAfter: time.Now().Add(60 * time.Second),
}
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
// Concurrency=1 ensures sequential semaphore acquisition so
// the rate-limit flag is always visible to later goroutines.
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal(), gitsync.WithConcurrency(1))
ownerID := uuid.New()
origin := "https://github.com/org/repo"
requests := []gitsync.RefreshRequest{
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/1", Valid: true},
GitRemoteOrigin: origin,
GitBranch: "feat-1",
},
OwnerID: ownerID,
},
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/2", Valid: true},
GitRemoteOrigin: origin,
GitBranch: "feat-2",
},
OwnerID: ownerID,
},
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/3", Valid: true},
GitRemoteOrigin: origin,
GitBranch: "feat-3",
},
OwnerID: ownerID,
},
}
results, err := r.Refresh(context.Background(), requests)
require.NoError(t, err)
require.Len(t, results, 3)
// With concurrency=1, the first goroutine to acquire the
// semaphore makes the only API call (which rate-limits).
// The remaining goroutines see the rate-limit flag and
// skip. Goroutine scheduling order is non-deterministic,
// so we verify aggregate counts rather than per-index
// results.
var directCount, skippedCount int
for _, res := range results {
require.Error(t, res.Error)
var rlErr *gitprovider.RateLimitError
require.True(t, errors.As(res.Error, &rlErr),
"every result should wrap *RateLimitError")
if errors.Is(res.Error, gitsync.ErrRateLimitSkipped) {
skippedCount++
} else {
directCount++
}
}
assert.Equal(t, 1, directCount,
"exactly one row should be directly rate-limited")
assert.Equal(t, 2, skippedCount,
"two rows should be skipped due to rate limit")
assert.Equal(t, int32(1), callCount.Load(),
"FetchPullRequestStatus should be called exactly once")
}
func TestRefresher_CorrectTokenPerOrigin(t *testing.T) {
t.Parallel()
var tokenCalls atomic.Int32
tokens := func(_ context.Context, _ uuid.UUID, origin string) (*string, error) {
tokenCalls.Add(1)
switch {
case strings.Contains(origin, "github.com"):
return ptr.Ref("gh-public-token"), nil
case strings.Contains(origin, "ghes.corp.com"):
return ptr.Ref("ghe-private-token"), nil
default:
return nil, fmt.Errorf("unexpected origin: %s", origin)
}
}
// Track which token each FetchPullRequestStatus call received,
// keyed by chat ID. We pass the chat ID through the PRRef.Number
// field (unique per request) so FetchPullRequestStatus can
// identify which row it's processing.
var mu sync.Mutex
tokensByPR := make(map[int]string)
mp := &mockProvider{
parsePullRequestURL: func(raw string) (gitprovider.PRRef, bool) {
// Extract a unique PR number from the URL to identify
// each row inside FetchPullRequestStatus.
var num int
switch {
case strings.HasSuffix(raw, "/pull/1"):
num = 1
case strings.HasSuffix(raw, "/pull/2"):
num = 2
case strings.HasSuffix(raw, "/pull/10"):
num = 10
default:
return gitprovider.PRRef{}, false
}
return gitprovider.PRRef{Owner: "org", Repo: "repo", Number: num}, true
},
fetchPullRequestStatus: func(_ context.Context, token string, ref gitprovider.PRRef) (*gitprovider.PRStatus, error) {
mu.Lock()
tokensByPR[ref.Number] = token
mu.Unlock()
return &gitprovider.PRStatus{State: gitprovider.PRStateOpen}, nil
},
}
providers := func(_ string) gitprovider.Provider { return mp }
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal())
ownerID := uuid.New()
requests := []gitsync.RefreshRequest{
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/1", Valid: true},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature-1",
},
OwnerID: ownerID,
},
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://github.com/org/repo/pull/2", Valid: true},
GitRemoteOrigin: "https://github.com/org/repo",
GitBranch: "feature-2",
},
OwnerID: ownerID,
},
{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: "https://ghes.corp.com/org/repo/pull/10", Valid: true},
GitRemoteOrigin: "https://ghes.corp.com/org/repo",
GitBranch: "feature-3",
},
OwnerID: ownerID,
},
}
results, err := r.Refresh(context.Background(), requests)
require.NoError(t, err)
require.Len(t, results, 3)
for i, res := range results {
require.NoError(t, res.Error, "result[%d] should not have an error", i)
require.NotNil(t, res.Params, "result[%d] should have params", i)
}
// github.com rows (PR #1 and #2) should use the public token.
assert.Equal(t, "gh-public-token", tokensByPR[1],
"github.com PR #1 should use gh-public-token")
assert.Equal(t, "gh-public-token", tokensByPR[2],
"github.com PR #2 should use gh-public-token")
// ghes.corp.com row (PR #10) should use the GHE token.
assert.Equal(t, "ghe-private-token", tokensByPR[10],
"ghes.corp.com PR #10 should use ghe-private-token")
// Token resolution should be called exactly twice — once per
// (owner, origin) group.
assert.Equal(t, int32(2), tokenCalls.Load(),
"TokenResolver should be called once per (owner, origin) group")
}
func TestRefresher_ConcurrentProcessing(t *testing.T) {
t.Parallel()
const numRows = 3
// gate blocks all goroutines until numRows goroutines have
// entered FetchPullRequestStatus, proving they run concurrently.
gate := make(chan struct{})
var entered atomic.Int32
mp := &mockProvider{
parsePullRequestURL: func(raw string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{Owner: "org", Repo: "repo", Number: 1}, true
},
fetchPullRequestStatus: func(_ context.Context, _ string, _ gitprovider.PRRef) (*gitprovider.PRStatus, error) {
if entered.Add(1) == numRows {
close(gate)
}
// Block until all goroutines have entered.
<-gate
return &gitprovider.PRStatus{State: gitprovider.PRStateOpen}, nil
},
}
providers := func(_ string) gitprovider.Provider { return mp }
tokens := func(_ context.Context, _ uuid.UUID, _ string) (*string, error) {
return ptr.Ref("test-token"), nil
}
// Concurrency must be >= numRows so all goroutines can enter
// simultaneously.
r := gitsync.NewRefresher(providers, tokens, slogtest.Make(t, nil), quartz.NewReal(), gitsync.WithConcurrency(numRows))
ownerID := uuid.New()
origin := "https://github.com/org/repo"
requests := make([]gitsync.RefreshRequest, numRows)
for i := range requests {
requests[i] = gitsync.RefreshRequest{
Row: database.ChatDiffStatus{
ChatID: uuid.New(),
Url: sql.NullString{String: fmt.Sprintf("https://github.com/org/repo/pull/%d", i+1), Valid: true},
GitRemoteOrigin: origin,
GitBranch: fmt.Sprintf("feat-%d", i+1),
},
OwnerID: ownerID,
}
}
results, err := r.Refresh(context.Background(), requests)
require.NoError(t, err)
require.Len(t, results, numRows)
for i, res := range results {
if res.Error != nil {
t.Logf("result[%d] error: %v", i, res.Error)
}
assert.NoError(t, res.Error, "result[%d]", i)
assert.NotNil(t, res.Params, "result[%d]", i)
}
// All numRows goroutines entered FetchPullRequestStatus
// concurrently.
assert.Equal(t, int32(numRows), entered.Load())
}
+351
View File
@@ -0,0 +1,351 @@
package gitsync
import (
"context"
"database/sql"
"errors"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/quartz"
)
const (
// defaultBatchSize is the maximum number of stale rows fetched
// per tick.
defaultBatchSize int32 = 50
// defaultInterval is the polling interval between ticks.
defaultInterval = 10 * time.Second
// defaultTickTimeout is the maximum time a single tick may
// run. Decoupled from the polling interval so that a batch
// of concurrent HTTP calls has enough headroom to complete.
defaultTickTimeout = 30 * time.Second
// NoTokenBackoff is the backoff duration applied to rows
// whose owner has no linked external-auth token. Much longer
// than DiffStatusTTL because the user must manually link
// their account before retrying is useful.
NoTokenBackoff = 10 * time.Minute
)
// Store is the narrow DB interface the Worker needs.
type Store interface {
AcquireStaleChatDiffStatuses(
ctx context.Context, limitVal int32,
) ([]database.AcquireStaleChatDiffStatusesRow, error)
BackoffChatDiffStatus(
ctx context.Context, arg database.BackoffChatDiffStatusParams,
) error
UpsertChatDiffStatus(
ctx context.Context, arg database.UpsertChatDiffStatusParams,
) (database.ChatDiffStatus, error)
UpsertChatDiffStatusReference(
ctx context.Context, arg database.UpsertChatDiffStatusReferenceParams,
) (database.ChatDiffStatus, error)
GetChats(
ctx context.Context, arg database.GetChatsParams,
) ([]database.Chat, error)
}
// EventPublisher notifies the frontend of diff status changes.
type PublishDiffStatusChangeFunc func(ctx context.Context, chatID uuid.UUID) error
// Worker is a background loop that periodically refreshes stale
// chat diff statuses by delegating to a Refresher.
type Worker struct {
store Store
refresher *Refresher
publishDiffStatusChangeFn PublishDiffStatusChangeFunc
clock quartz.Clock
logger slog.Logger
batchSize int32
interval time.Duration
tickTimeout time.Duration
done chan struct{}
}
// WorkerOption configures a Worker.
type WorkerOption func(*Worker)
// WithTickTimeout sets the maximum duration for a single tick.
func WithTickTimeout(d time.Duration) WorkerOption {
return func(w *Worker) {
if d > 0 {
w.tickTimeout = d
}
}
}
// NewWorker creates a Worker with default batch size and interval.
func NewWorker(
store Store,
refresher *Refresher,
publisher PublishDiffStatusChangeFunc,
clock quartz.Clock,
logger slog.Logger,
opts ...WorkerOption,
) *Worker {
w := &Worker{
store: store,
refresher: refresher,
publishDiffStatusChangeFn: publisher,
clock: clock,
logger: logger,
batchSize: defaultBatchSize,
interval: defaultInterval,
tickTimeout: defaultTickTimeout,
done: make(chan struct{}),
}
for _, o := range opts {
o(w)
}
return w
}
// Start launches the background loop. It blocks until ctx is
// cancelled, then closes w.done.
func (w *Worker) Start(ctx context.Context) {
defer close(w.done)
ticker := w.clock.NewTicker(w.interval, "gitsync", "worker")
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.tick(ctx)
}
}
}
// Done returns a channel that is closed when the worker exits.
func (w *Worker) Done() <-chan struct{} {
return w.done
}
func chatDiffStatusFromRow(row database.AcquireStaleChatDiffStatusesRow) database.ChatDiffStatus {
return database.ChatDiffStatus{
ChatID: row.ChatID,
Url: row.Url,
PullRequestState: row.PullRequestState,
ChangesRequested: row.ChangesRequested,
Additions: row.Additions,
Deletions: row.Deletions,
ChangedFiles: row.ChangedFiles,
AuthorLogin: row.AuthorLogin,
AuthorAvatarUrl: row.AuthorAvatarUrl,
BaseBranch: row.BaseBranch,
HeadBranch: row.HeadBranch,
PrNumber: row.PrNumber,
Commits: row.Commits,
Approved: row.Approved,
ReviewerCount: row.ReviewerCount,
RefreshedAt: row.RefreshedAt,
StaleAt: row.StaleAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
GitBranch: row.GitBranch,
GitRemoteOrigin: row.GitRemoteOrigin,
PullRequestTitle: row.PullRequestTitle,
PullRequestDraft: row.PullRequestDraft,
}
}
func (w *Worker) tick(ctx context.Context) {
// Use a dedicated tick timeout that is longer than the
// polling interval. This gives concurrent HTTP calls enough
// headroom without stalling the next tick excessively.
ctx, cancel := context.WithTimeout(ctx, w.tickTimeout)
defer cancel()
acquiredRows, err := w.store.AcquireStaleChatDiffStatuses(ctx, w.batchSize)
if err != nil {
w.logger.Warn(ctx, "acquire stale chat diff statuses",
slog.Error(err))
return
}
if len(acquiredRows) == 0 {
return
}
// Build refresh requests directly from acquired rows.
requests := make([]RefreshRequest, 0, len(acquiredRows))
for _, row := range acquiredRows {
requests = append(requests, RefreshRequest{
Row: chatDiffStatusFromRow(row),
OwnerID: row.OwnerID,
})
}
results, err := w.refresher.Refresh(ctx, requests)
if err != nil {
w.logger.Warn(ctx, "batch refresh chat diff statuses",
slog.Error(err))
return
}
for _, res := range results {
if res.Error != nil {
w.logger.Debug(ctx, "refresh chat diff status",
slog.F("chat_id", res.Request.Row.ChatID),
slog.Error(res.Error))
// Apply a longer backoff for rows whose owner has
// no linked token — retrying every 2 minutes is
// pointless until the user links their account.
backoff := DiffStatusTTL
if errors.Is(res.Error, ErrNoTokenAvailable) {
backoff = NoTokenBackoff
}
// Back off so the row isn't retried immediately.
if err := w.store.BackoffChatDiffStatus(ctx,
database.BackoffChatDiffStatusParams{
ChatID: res.Request.Row.ChatID,
StaleAt: w.clock.Now().UTC().Add(backoff),
},
); err != nil {
w.logger.Warn(ctx, "backoff failed chat diff status",
slog.F("chat_id", res.Request.Row.ChatID),
slog.Error(err))
}
continue
}
if res.Params == nil {
// No PR yet — skip.
continue
}
if _, err := w.store.UpsertChatDiffStatus(ctx, *res.Params); err != nil {
w.logger.Warn(ctx, "upsert refreshed chat diff status",
slog.F("chat_id", res.Request.Row.ChatID),
slog.Error(err))
continue
}
if w.publishDiffStatusChangeFn != nil {
if err := w.publishDiffStatusChangeFn(ctx, res.Request.Row.ChatID); err != nil {
w.logger.Debug(ctx, "publish diff status change",
slog.F("chat_id", res.Request.Row.ChatID),
slog.Error(err))
}
}
}
}
// MarkStale persists the git ref on all chats for a workspace,
// setting stale_at to the past so the next tick picks them up.
// Publishes a diff status event for each affected chat.
// Called from workspaceagents handlers. No goroutines spawned.
func (w *Worker) MarkStale(
ctx context.Context,
workspaceID, ownerID uuid.UUID,
branch, origin string,
) {
if branch == "" || origin == "" {
return
}
chats, err := w.store.GetChats(ctx, database.GetChatsParams{
OwnerID: ownerID,
})
if err != nil {
w.logger.Warn(ctx, "list chats for git ref storage",
slog.F("workspace_id", workspaceID),
slog.Error(err))
return
}
for _, chat := range filterChatsByWorkspaceID(chats, workspaceID) {
_, err := w.store.UpsertChatDiffStatusReference(ctx,
database.UpsertChatDiffStatusReferenceParams{
ChatID: chat.ID,
GitBranch: branch,
GitRemoteOrigin: origin,
StaleAt: w.clock.Now().Add(-time.Second),
Url: sql.NullString{},
},
)
if err != nil {
w.logger.Warn(ctx, "store git ref on chat diff status",
slog.F("chat_id", chat.ID),
slog.F("workspace_id", workspaceID),
slog.Error(err))
continue
}
// Notify the frontend immediately so the UI shows the
// branch info even before the worker refreshes PR data.
if w.publishDiffStatusChangeFn != nil {
if pubErr := w.publishDiffStatusChangeFn(ctx, chat.ID); pubErr != nil {
w.logger.Debug(ctx, "publish diff status after mark stale",
slog.F("chat_id", chat.ID), slog.Error(pubErr))
}
}
}
}
// RefreshChat synchronously refreshes a single chat's diff
// status using the same Refresher pipeline as the background
// worker. Returns nil, nil when no PR exists yet for the
// branch. Called from HTTP handlers for instant feedback.
func (w *Worker) RefreshChat(
ctx context.Context,
row database.ChatDiffStatus,
ownerID uuid.UUID,
) (*database.ChatDiffStatus, error) {
requests := []RefreshRequest{{
Row: row,
OwnerID: ownerID,
}}
results, err := w.refresher.Refresh(ctx, requests)
if err != nil {
return nil, xerrors.Errorf("refresh chat diff status: %w", err)
}
if len(results) == 0 {
return nil, nil
}
res := results[0]
if res.Error != nil {
return nil, xerrors.Errorf("refresh chat diff status: %w", res.Error)
}
if res.Params == nil {
return nil, nil
}
upserted, err := w.store.UpsertChatDiffStatus(ctx, *res.Params)
if err != nil {
return nil, xerrors.Errorf("upsert chat diff status: %w", err)
}
if w.publishDiffStatusChangeFn != nil {
if err := w.publishDiffStatusChangeFn(ctx, row.ChatID); err != nil {
w.logger.Debug(ctx, "publish diff status change",
slog.F("chat_id", row.ChatID),
slog.Error(err))
}
}
return &upserted, nil
}
// filterChatsByWorkspaceID returns only chats associated with
// the given workspace.
func filterChatsByWorkspaceID(
chats []database.Chat,
workspaceID uuid.UUID,
) []database.Chat {
filtered := make([]database.Chat, 0, len(chats))
for _, chat := range chats {
if !chat.WorkspaceID.Valid || chat.WorkspaceID.UUID != workspaceID {
continue
}
filtered = append(filtered, chat)
}
return filtered
}
+962
View File
@@ -0,0 +1,962 @@
package gitsync_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/gitsync"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
// testRefresherCfg configures newTestRefresher.
type testRefresherCfg struct {
resolveBranchPR func(context.Context, string, gitprovider.BranchRef) (*gitprovider.PRRef, error)
fetchPRStatus func(context.Context, string, gitprovider.PRRef) (*gitprovider.PRStatus, error)
refresherOpts []gitsync.RefresherOption
}
type testRefresherOpt func(*testRefresherCfg)
func withResolveBranchPR(f func(context.Context, string, gitprovider.BranchRef) (*gitprovider.PRRef, error)) testRefresherOpt {
return func(c *testRefresherCfg) { c.resolveBranchPR = f }
}
func withRefresherOpts(opts ...gitsync.RefresherOption) testRefresherOpt {
return func(c *testRefresherCfg) { c.refresherOpts = opts }
}
// newTestRefresher creates a Refresher backed by mock
// provider/token resolvers. The provider recognises any origin,
// resolves branches to a canned PR, and returns a canned PRStatus.
func newTestRefresher(t *testing.T, clk quartz.Clock, opts ...testRefresherOpt) *gitsync.Refresher {
t.Helper()
cfg := testRefresherCfg{
resolveBranchPR: func(context.Context, string, gitprovider.BranchRef) (*gitprovider.PRRef, error) {
return &gitprovider.PRRef{Owner: "o", Repo: "r", Number: 1}, nil
},
fetchPRStatus: func(context.Context, string, gitprovider.PRRef) (*gitprovider.PRStatus, error) {
return &gitprovider.PRStatus{
State: gitprovider.PRStateOpen,
DiffStats: gitprovider.DiffStats{
Additions: 10,
Deletions: 3,
ChangedFiles: 2,
},
}, nil
},
}
for _, o := range opts {
o(&cfg)
}
prov := &mockProvider{
parseRepositoryOrigin: func(string) (string, string, string, bool) {
return "owner", "repo", "https://github.com/owner/repo", true
},
parsePullRequestURL: func(raw string) (gitprovider.PRRef, bool) {
return gitprovider.PRRef{Owner: "owner", Repo: "repo", Number: 1}, raw != ""
},
resolveBranchPR: cfg.resolveBranchPR,
fetchPullRequestStatus: cfg.fetchPRStatus,
buildPullRequestURL: func(ref gitprovider.PRRef) string {
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", ref.Owner, ref.Repo, ref.Number)
},
}
providers := func(string) gitprovider.Provider { return prov }
tokens := func(context.Context, uuid.UUID, string) (*string, error) {
return ptr.Ref("tok"), nil
}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
return gitsync.NewRefresher(providers, tokens, logger, clk, cfg.refresherOpts...)
}
// makeAcquiredRowWithBranch returns an AcquireStaleChatDiffStatusesRow with
// the given branch and a non-empty origin so the Refresher goes through the
// branch-resolution path.
func makeAcquiredRowWithBranch(chatID, ownerID uuid.UUID, branch string) database.AcquireStaleChatDiffStatusesRow {
return database.AcquireStaleChatDiffStatusesRow{
ChatID: chatID,
GitBranch: branch,
GitRemoteOrigin: "https://github.com/owner/repo",
StaleAt: time.Now().Add(-time.Minute),
OwnerID: ownerID,
}
}
// tickOnce traps the worker's NewTicker call, starts the worker,
// fires one tick, waits for it to finish by observing the given
// tickDone channel, then shuts the worker down. The tickDone
// channel must be closed when the last expected operation in the
// tick completes. For tests where the tick does nothing (e.g. 0
// stale rows or store error), tickDone should be closed inside
// acquireStaleChatDiffStatuses.
func tickOnce(
ctx context.Context,
t *testing.T,
mClock *quartz.Mock,
worker *gitsync.Worker,
tickDone <-chan struct{},
) {
t.Helper()
trap := mClock.Trap().NewTicker("gitsync", "worker")
defer trap.Close()
workerCtx, cancel := context.WithCancel(ctx)
defer cancel()
go worker.Start(workerCtx)
// Wait for the worker to create its ticker.
trap.MustWait(ctx).MustRelease(ctx)
// Fire one tick. The waiter resolves when the channel receive
// completes, not when w.tick() returns, so we use tickDone to
// know when to proceed.
_, w := mClock.AdvanceNext()
w.MustWait(ctx)
// Wait for the tick's business logic to finish.
select {
case <-tickDone:
case <-ctx.Done():
t.Fatal("timed out waiting for tick to complete")
}
cancel()
<-worker.Done()
}
func TestWorker_SkipsFreshRows(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
tickDone := make(chan struct{})
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().AcquireStaleChatDiffStatuses(gomock.Any(), gomock.Any()).
DoAndReturn(func(context.Context, int32) ([]database.AcquireStaleChatDiffStatusesRow, error) {
// No stale rows — tick returns immediately.
close(tickDone)
return nil, nil
})
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
tickOnce(ctx, t, mClock, worker, tickDone)
}
func TestWorker_LimitsToNRows(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
var capturedLimit atomic.Int32
var upsertCount atomic.Int32
ownerID := uuid.New()
const numRows = 5
tickDone := make(chan struct{})
rows := make([]database.AcquireStaleChatDiffStatusesRow, numRows)
for i := range rows {
rows[i] = makeAcquiredRowWithBranch(uuid.New(), ownerID, "feature")
}
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().AcquireStaleChatDiffStatuses(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, limitVal int32) ([]database.AcquireStaleChatDiffStatusesRow, error) {
capturedLimit.Store(limitVal)
return rows, nil
})
store.EXPECT().UpsertChatDiffStatus(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusParams) (database.ChatDiffStatus, error) {
upsertCount.Add(1)
return database.ChatDiffStatus{ChatID: arg.ChatID}, nil
}).Times(numRows)
pub := func(_ context.Context, _ uuid.UUID) error {
if upsertCount.Load() == numRows {
close(tickDone)
}
return nil
}
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
tickOnce(ctx, t, mClock, worker, tickDone)
// The default batch size is 50.
assert.Equal(t, int32(50), capturedLimit.Load())
assert.Equal(t, int32(numRows), upsertCount.Load())
}
func TestWorker_RefresherReturnsNilNil_SkipsUpsert(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
chatID := uuid.New()
ownerID := uuid.New()
// When the Refresher returns (nil, nil) the worker skips the
// upsert and publish. We signal tickDone from the refresher
// mock since that is the last operation before the tick
// returns.
tickDone := make(chan struct{})
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().AcquireStaleChatDiffStatuses(gomock.Any(), gomock.Any()).
Return([]database.AcquireStaleChatDiffStatusesRow{makeAcquiredRowWithBranch(chatID, ownerID, "feature")}, nil)
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
// ResolveBranchPullRequest returns nil → Refresher returns
// (nil, nil).
refresher := newTestRefresher(t, mClock, withResolveBranchPR(
func(context.Context, string, gitprovider.BranchRef) (*gitprovider.PRRef, error) {
close(tickDone)
return nil, nil
},
))
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
tickOnce(ctx, t, mClock, worker, tickDone)
}
func TestWorker_RefresherError_BacksOffRow(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
chat1 := uuid.New()
chat2 := uuid.New()
ownerID := uuid.New()
var upsertCount atomic.Int32
var publishCount atomic.Int32
var backoffCount atomic.Int32
var mu sync.Mutex
var backoffArgs []database.BackoffChatDiffStatusParams
tickDone := make(chan struct{})
var closeOnce sync.Once
// Two rows processed: one fails (backoff), one succeeds
// (upsert+publish). Both must finish before we close tickDone.
var terminalOps atomic.Int32
signalIfDone := func() {
if terminalOps.Add(1) == 2 {
closeOnce.Do(func() { close(tickDone) })
}
}
mClock := quartz.NewMock(t)
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().AcquireStaleChatDiffStatuses(gomock.Any(), gomock.Any()).
Return([]database.AcquireStaleChatDiffStatusesRow{
makeAcquiredRowWithBranch(chat1, ownerID, "fail-branch"),
makeAcquiredRowWithBranch(chat2, ownerID, "success-branch"),
}, nil)
store.EXPECT().BackoffChatDiffStatus(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.BackoffChatDiffStatusParams) error {
backoffCount.Add(1)
mu.Lock()
backoffArgs = append(backoffArgs, arg)
mu.Unlock()
signalIfDone()
return nil
})
store.EXPECT().UpsertChatDiffStatus(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusParams) (database.ChatDiffStatus, error) {
upsertCount.Add(1)
return database.ChatDiffStatus{ChatID: arg.ChatID}, nil
})
pub := func(_ context.Context, _ uuid.UUID) error {
// Only the successful row publishes.
publishCount.Add(1)
signalIfDone()
return nil
}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
// Fail ResolveBranchPullRequest based on the branch name
// so the behavior is deterministic regardless of execution
// order.
refresher := newTestRefresher(t, mClock, withResolveBranchPR(
func(_ context.Context, _ string, ref gitprovider.BranchRef) (*gitprovider.PRRef, error) {
if ref.Branch == "fail-branch" {
return nil, fmt.Errorf("simulated provider error")
}
return &gitprovider.PRRef{Owner: "o", Repo: "r", Number: 1}, nil
},
))
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
tickOnce(ctx, t, mClock, worker, tickDone)
// BackoffChatDiffStatus was called for the failed row.
assert.Equal(t, int32(1), backoffCount.Load())
mu.Lock()
require.Len(t, backoffArgs, 1)
assert.Equal(t, chat1, backoffArgs[0].ChatID)
// stale_at should be approximately clock.Now() + DiffStatusTTL (120s).
expectedStaleAt := mClock.Now().UTC().Add(gitsync.DiffStatusTTL)
assert.WithinDuration(t, expectedStaleAt, backoffArgs[0].StaleAt, time.Second)
mu.Unlock()
// UpsertChatDiffStatus was called for the successful row.
assert.Equal(t, int32(1), upsertCount.Load())
// PublishDiffStatusChange was called only for the successful row.
assert.Equal(t, int32(1), publishCount.Load())
}
func TestWorker_UpsertError_ContinuesNextRow(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
chat1 := uuid.New()
chat2 := uuid.New()
ownerID := uuid.New()
var publishCount atomic.Int32
tickDone := make(chan struct{})
var closeOnce sync.Once
var mu sync.Mutex
upsertedChatIDs := make(map[uuid.UUID]struct{})
// We have 2 rows. The upsert for chat1 fails; the upsert
// for chat2 succeeds and publishes. Because goroutines run
// concurrently we don't know which finishes last, so we
// track the total number of "terminal" events (upsert error
// + publish success) and close tickDone when both have
// occurred.
var terminalOps atomic.Int32
signalIfDone := func() {
if terminalOps.Add(1) == 2 {
closeOnce.Do(func() { close(tickDone) })
}
}
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().AcquireStaleChatDiffStatuses(gomock.Any(), gomock.Any()).
Return([]database.AcquireStaleChatDiffStatusesRow{
makeAcquiredRowWithBranch(chat1, ownerID, "feature"),
makeAcquiredRowWithBranch(chat2, ownerID, "feature"),
}, nil)
store.EXPECT().UpsertChatDiffStatus(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusParams) (database.ChatDiffStatus, error) {
if arg.ChatID == chat1 {
// Terminal event for the failing row.
signalIfDone()
return database.ChatDiffStatus{}, fmt.Errorf("db write error")
}
mu.Lock()
upsertedChatIDs[arg.ChatID] = struct{}{}
mu.Unlock()
return database.ChatDiffStatus{ChatID: arg.ChatID}, nil
}).Times(2)
pub := func(_ context.Context, _ uuid.UUID) error {
publishCount.Add(1)
// Terminal event for the successful row.
signalIfDone()
return nil
}
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
tickOnce(ctx, t, mClock, worker, tickDone)
mu.Lock()
_, gotChat2 := upsertedChatIDs[chat2]
mu.Unlock()
assert.True(t, gotChat2, "chat2 should have been upserted")
assert.Equal(t, int32(1), publishCount.Load())
}
func TestWorker_RespectsShutdown(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().AcquireStaleChatDiffStatuses(gomock.Any(), gomock.Any()).
Return(nil, nil).AnyTimes()
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
trap := mClock.Trap().NewTicker("gitsync", "worker")
defer trap.Close()
workerCtx, cancel := context.WithCancel(ctx)
go worker.Start(workerCtx)
// Wait for ticker creation so the worker is running.
trap.MustWait(ctx).MustRelease(ctx)
// Cancel immediately.
cancel()
select {
case <-worker.Done():
// Success — worker shut down.
case <-ctx.Done():
t.Fatal("timed out waiting for worker to shut down")
}
}
func TestWorker_MarkStale_UpsertAndPublish(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
workspaceID := uuid.New()
ownerID := uuid.New()
chat1 := uuid.New()
chat2 := uuid.New()
chatOther := uuid.New()
var mu sync.Mutex
var upsertRefCalls []database.UpsertChatDiffStatusReferenceParams
var publishedIDs []uuid.UUID
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().GetChats(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.GetChatsParams) ([]database.Chat, error) {
require.Equal(t, ownerID, arg.OwnerID)
return []database.Chat{
{ID: chat1, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}},
{ID: chat2, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}},
{ID: chatOther, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}},
}, nil
})
store.EXPECT().UpsertChatDiffStatusReference(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusReferenceParams) (database.ChatDiffStatus, error) {
mu.Lock()
upsertRefCalls = append(upsertRefCalls, arg)
mu.Unlock()
return database.ChatDiffStatus{ChatID: arg.ChatID}, nil
}).Times(2)
pub := func(_ context.Context, chatID uuid.UUID) error {
mu.Lock()
publishedIDs = append(publishedIDs, chatID)
mu.Unlock()
return nil
}
mClock := quartz.NewMock(t)
now := mClock.Now()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
worker.MarkStale(ctx, workspaceID, ownerID, "feature", "https://github.com/owner/repo")
mu.Lock()
defer mu.Unlock()
require.Len(t, upsertRefCalls, 2)
for _, call := range upsertRefCalls {
assert.Equal(t, "feature", call.GitBranch)
assert.Equal(t, "https://github.com/owner/repo", call.GitRemoteOrigin)
assert.True(t, call.StaleAt.Before(now),
"stale_at should be in the past, got %v vs now %v", call.StaleAt, now)
assert.Equal(t, sql.NullString{}, call.Url)
}
require.Len(t, publishedIDs, 2)
assert.ElementsMatch(t, []uuid.UUID{chat1, chat2}, publishedIDs)
}
func TestWorker_MarkStale_NoMatchingChats(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
workspaceID := uuid.New()
ownerID := uuid.New()
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().GetChats(gomock.Any(), gomock.Any()).
Return([]database.Chat{
{ID: uuid.New(), OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}},
{ID: uuid.New(), OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}},
}, nil)
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
worker.MarkStale(ctx, workspaceID, ownerID, "main", "https://github.com/x/y")
}
func TestWorker_MarkStale_UpsertFails_ContinuesNext(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
workspaceID := uuid.New()
ownerID := uuid.New()
chat1 := uuid.New()
chat2 := uuid.New()
var publishCount atomic.Int32
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().GetChats(gomock.Any(), gomock.Any()).
Return([]database.Chat{
{ID: chat1, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}},
{ID: chat2, OwnerID: ownerID, WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}},
}, nil)
store.EXPECT().UpsertChatDiffStatusReference(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusReferenceParams) (database.ChatDiffStatus, error) {
if arg.ChatID == chat1 {
return database.ChatDiffStatus{}, fmt.Errorf("upsert ref error")
}
return database.ChatDiffStatus{ChatID: arg.ChatID}, nil
}).Times(2)
pub := func(_ context.Context, _ uuid.UUID) error {
publishCount.Add(1)
return nil
}
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
worker.MarkStale(ctx, workspaceID, ownerID, "dev", "https://github.com/a/b")
assert.Equal(t, int32(1), publishCount.Load())
}
func TestWorker_MarkStale_GetChatsFails(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().GetChats(gomock.Any(), gomock.Any()).
Return(nil, fmt.Errorf("db error"))
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
worker.MarkStale(ctx, uuid.New(), uuid.New(), "main", "https://github.com/x/y")
}
func TestWorker_TickStoreError(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
tickDone := make(chan struct{})
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().AcquireStaleChatDiffStatuses(gomock.Any(), gomock.Any()).
DoAndReturn(func(context.Context, int32) ([]database.AcquireStaleChatDiffStatusesRow, error) {
close(tickDone)
return nil, fmt.Errorf("database unavailable")
})
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
tickOnce(ctx, t, mClock, worker, tickDone)
}
func TestWorker_MarkStale_EmptyBranchOrOrigin(t *testing.T) {
t.Parallel()
tests := []struct {
name string
branch string
origin string
}{
{"both empty", "", ""},
{"branch empty", "", "https://github.com/x/y"},
{"origin empty", "main", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
worker.MarkStale(ctx, uuid.New(), uuid.New(), tc.branch, tc.origin)
})
}
}
// TestWorker exercises the worker tick against a
// real PostgreSQL database to verify that the SQL queries, foreign key
// constraints, and upsert logic work end-to-end.
func TestWorker(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// 1. Real database store.
db, _ := dbtestutil.NewDB(t)
// 2. Create a user (FK for chats).
user := dbgen.User(t, db, database.User{})
// 3. Set up FK chain: chat_providers -> chat_model_configs -> chats.
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: "openai",
DisplayName: "OpenAI",
Enabled: true,
})
require.NoError(t, err)
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
Provider: "openai",
Model: "test-model",
DisplayName: "Test Model",
Enabled: true,
ContextLimit: 100000,
CompressionThreshold: 70,
Options: json.RawMessage("{}"),
})
require.NoError(t, err)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "integration-test",
})
require.NoError(t, err)
// 4. Seed a stale diff status row so the worker picks it up.
_, err = db.UpsertChatDiffStatusReference(ctx, database.UpsertChatDiffStatusReferenceParams{
ChatID: chat.ID,
GitBranch: "feature",
GitRemoteOrigin: "https://github.com/o/r",
StaleAt: time.Now().Add(-time.Minute),
Url: sql.NullString{},
})
require.NoError(t, err)
// 5. Mock refresher returns a canned PR status.
mClock := quartz.NewMock(t)
refresher := newTestRefresher(t, mClock)
// 6. Track publish calls.
var publishCount atomic.Int32
tickDone := make(chan struct{})
pub := func(_ context.Context, chatID uuid.UUID) error {
assert.Equal(t, chat.ID, chatID)
if publishCount.Add(1) == 1 {
close(tickDone)
}
return nil
}
// 7. Create and run the worker for one tick.
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
worker := gitsync.NewWorker(db, refresher, pub, mClock, logger)
tickOnce(ctx, t, mClock, worker, tickDone)
// 8. Assert publisher was called.
require.Equal(t, int32(1), publishCount.Load())
// 9. Read back and verify persisted fields.
status, err := db.GetChatDiffStatusByChatID(ctx, chat.ID)
require.NoError(t, err)
// The mock resolveBranchPR returns PRRef{Owner: "o", Repo: "r", Number: 1}
// and buildPullRequestURL formats it as https://github.com/o/r/pull/1.
assert.Equal(t, "https://github.com/o/r/pull/1", status.Url.String)
assert.True(t, status.Url.Valid)
assert.Equal(t, string(gitprovider.PRStateOpen), status.PullRequestState.String)
assert.True(t, status.PullRequestState.Valid)
assert.Equal(t, int32(10), status.Additions)
assert.Equal(t, int32(3), status.Deletions)
assert.Equal(t, int32(2), status.ChangedFiles)
assert.True(t, status.RefreshedAt.Valid, "refreshed_at should be set")
// The mock clock's Now() + DiffStatusTTL determines stale_at.
expectedStaleAt := mClock.Now().Add(gitsync.DiffStatusTTL)
assert.WithinDuration(t, expectedStaleAt, status.StaleAt, time.Second)
}
func TestRefreshChat_Success(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
chatID := uuid.New()
ownerID := uuid.New()
row := database.ChatDiffStatus{
ChatID: chatID,
GitBranch: "feature",
GitRemoteOrigin: "https://github.com/owner/repo",
}
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
upsertedStatus := database.ChatDiffStatus{
ChatID: chatID,
Url: sql.NullString{String: "https://github.com/o/r/pull/1", Valid: true},
Additions: 10,
Deletions: 3,
ChangedFiles: 2,
}
store.EXPECT().UpsertChatDiffStatus(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.UpsertChatDiffStatusParams) (database.ChatDiffStatus, error) {
assert.Equal(t, chatID, arg.ChatID)
return upsertedStatus, nil
})
var publishCalled atomic.Bool
pub := func(_ context.Context, id uuid.UUID) error {
assert.Equal(t, chatID, id)
publishCalled.Store(true)
return nil
}
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
result, err := worker.RefreshChat(ctx, row, ownerID)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, chatID, result.ChatID)
assert.Equal(t, upsertedStatus.Url, result.Url)
assert.True(t, publishCalled.Load(), "publish should have been called")
}
func TestRefreshChat_NoPR(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
chatID := uuid.New()
ownerID := uuid.New()
row := database.ChatDiffStatus{
ChatID: chatID,
GitBranch: "feature",
GitRemoteOrigin: "https://github.com/owner/repo",
}
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
// UpsertChatDiffStatus should NOT be called.
var publishCalled atomic.Bool
pub := func(_ context.Context, _ uuid.UUID) error {
publishCalled.Store(true)
return nil
}
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
// ResolveBranchPullRequest returns nil → no PR exists yet.
refresher := newTestRefresher(t, mClock, withResolveBranchPR(
func(context.Context, string, gitprovider.BranchRef) (*gitprovider.PRRef, error) {
return nil, nil
},
))
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
result, err := worker.RefreshChat(ctx, row, ownerID)
require.NoError(t, err)
assert.Nil(t, result, "result should be nil when no PR exists")
assert.False(t, publishCalled.Load(), "publish should not be called when no PR exists")
}
func TestRefreshChat_RefreshError(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
chatID := uuid.New()
ownerID := uuid.New()
row := database.ChatDiffStatus{
ChatID: chatID,
Url: sql.NullString{String: "https://github.com/org/repo/pull/1", Valid: true},
GitBranch: "feature",
GitRemoteOrigin: "https://github.com/owner/repo",
}
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
// UpsertChatDiffStatus should NOT be called.
// Provider resolver returns nil → "no provider" error.
providers := func(string) gitprovider.Provider { return nil }
tokens := func(context.Context, uuid.UUID, string) (*string, error) {
return ptr.Ref("tok"), nil
}
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := gitsync.NewRefresher(providers, tokens, logger, mClock)
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
result, err := worker.RefreshChat(ctx, row, ownerID)
require.Error(t, err)
assert.Contains(t, err.Error(), "no provider")
assert.Nil(t, result)
}
func TestRefreshChat_UpsertError(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
chatID := uuid.New()
ownerID := uuid.New()
row := database.ChatDiffStatus{
ChatID: chatID,
GitBranch: "feature",
GitRemoteOrigin: "https://github.com/owner/repo",
}
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().UpsertChatDiffStatus(gomock.Any(), gomock.Any()).
Return(database.ChatDiffStatus{}, fmt.Errorf("db write error"))
var publishCalled atomic.Bool
pub := func(_ context.Context, _ uuid.UUID) error {
publishCalled.Store(true)
return nil
}
mClock := quartz.NewMock(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := newTestRefresher(t, mClock)
worker := gitsync.NewWorker(store, refresher, pub, mClock, logger)
result, err := worker.RefreshChat(ctx, row, ownerID)
require.Error(t, err)
assert.Contains(t, err.Error(), "upsert chat diff status")
assert.Nil(t, result)
assert.False(t, publishCalled.Load(), "publish should not be called when upsert fails")
}
func TestWorker_NoTokenBackoff(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
chatID := uuid.New()
ownerID := uuid.New()
var mu sync.Mutex
var backoffArgs []database.BackoffChatDiffStatusParams
tickDone := make(chan struct{})
mClock := quartz.NewMock(t)
ctrl := gomock.NewController(t)
store := dbmock.NewMockStore(ctrl)
store.EXPECT().AcquireStaleChatDiffStatuses(gomock.Any(), gomock.Any()).
Return([]database.AcquireStaleChatDiffStatusesRow{
makeAcquiredRowWithBranch(chatID, ownerID, "feature"),
}, nil)
store.EXPECT().BackoffChatDiffStatus(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, arg database.BackoffChatDiffStatusParams) error {
mu.Lock()
backoffArgs = append(backoffArgs, arg)
mu.Unlock()
close(tickDone)
return nil
})
// Token resolver returns empty token → ErrNoTokenAvailable.
// Provider methods should never be called.
prov := &mockProvider{}
providers := func(string) gitprovider.Provider { return prov }
tokens := func(context.Context, uuid.UUID, string) (*string, error) {
return ptr.Ref(""), nil
}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
refresher := gitsync.NewRefresher(providers, tokens, logger, mClock)
worker := gitsync.NewWorker(store, refresher, nil, mClock, logger)
tickOnce(ctx, t, mClock, worker, tickDone)
mu.Lock()
defer mu.Unlock()
require.Len(t, backoffArgs, 1)
assert.Equal(t, chatID, backoffArgs[0].ChatID)
// The backoff should use NoTokenBackoff (10min), not
// DiffStatusTTL (2min).
expectedStaleAt := mClock.Now().UTC().Add(gitsync.NoTokenBackoff)
assert.WithinDuration(t, expectedStaleAt, backoffArgs[0].StaleAt, time.Second)
}