fix(coderd/gitsync): consolidate chat diff refresh paths through Worker.RefreshChat (#22938)

## Problem

Two separate code paths refreshed chat diff statuses:

1. **HTTP handler** (`refreshChatDiffStatus`): resolved
provider/token/status inline, ran under the user's context. Worked fine
because the user owns their external auth links.

2. **Background worker** (`Refresher.Refresh`): ran under `AsChatd`
context, which lacked `ActionReadPersonal` on `ResourceUser`.
`GetExternalAuthLink` failed silently (`if err != nil { continue }`),
returning `ErrNoTokenAvailable` every time. Chat diff statuses got
`git_branch`/`git_remote_origin` from `MarkStale` but `refreshed_at`,
`url`, `pull_request_state` stayed nil.

Having two paths also meant bug fixes had to be applied twice.

## Fix

- **`Worker.RefreshChat`**: New method for synchronous, on-demand
refresh of a single chat. Uses the same `Refresher.Refresh` pipeline as
the background `tick()`. Called by the HTTP handler for instant
response.

- **`resolveChatGitAccessToken`**: Uses
`dbauthz.AsSystemRestricted(ctx)` specifically for `GetExternalAuthLink`
and `RefreshToken` calls. This is scoped to just those DB operations
rather than broadening the chatd RBAC role.

- **Removed**: `refreshChatDiffStatus`, `shouldRefreshChatDiffStatus`,
`resolveChatDiffStatusWithOptions` (all replaced by the single
`RefreshChat` path).

## Tests

Added 4 tests for `Worker.RefreshChat`:
- `TestRefreshChat_Success`: full refresh + upsert + publish
- `TestRefreshChat_NoPR`: no PR exists yet, nil result
- `TestRefreshChat_RefreshError`: provider resolution fails
- `TestRefreshChat_UpsertError`: refresh succeeds but DB write fails

## Why tests didn't catch the original bug

- Worker tests used mock stores (no dbauthz) and fake token resolvers
(hardcoded lambdas)
- No integration test exercised `AsChatd` -> `resolveChatGitAccessToken`
-> `GetExternalAuthLink` through dbauthz
This commit is contained in:
Kyle Carberry
2026-03-11 16:34:46 +00:00
committed by GitHub
parent d39f69f4c2
commit 77d53d2955
4 changed files with 356 additions and 97 deletions
+20 -94
View File
@@ -1138,15 +1138,6 @@ func chatWorkspaceAuditStatus(err error) int {
func (api *API) resolveChatDiffStatus(
ctx context.Context,
chat database.Chat,
) (*database.ChatDiffStatus, error) {
return api.resolveChatDiffStatusWithOptions(ctx, chat, false)
}
//nolint:revive // Boolean forces cache refresh bypass.
func (api *API) resolveChatDiffStatusWithOptions(
ctx context.Context,
chat database.Chat,
forceRefresh bool,
) (*database.ChatDiffStatus, error) {
status, found, err := api.getCachedChatDiffStatus(ctx, chat.ID)
if err != nil {
@@ -1172,26 +1163,25 @@ func (api *API) resolveChatDiffStatusWithOptions(
if !found {
return nil, nil //nolint:nilnil // Callers handle nil status explicitly.
}
if reference.PullRequestURL == "" {
return &status, nil
}
if !shouldRefreshChatDiffStatus(status, now, forceRefresh) {
if !chatDiffStatusIsStale(status, now) {
return &status, nil
}
refreshed, err := api.refreshChatDiffStatus(
ctx,
chat.OwnerID,
chat.ID,
reference.PullRequestURL,
// Use the same refresh pipeline as the background worker
// so both paths share identical provider/token resolution.
refreshed, err := api.gitSyncWorker.RefreshChat(
ctx, status, chat.OwnerID,
)
if err == nil && refreshed != nil {
return refreshed, nil
}
if err == nil {
return &refreshed, nil
// No PR exists yet; return what we have.
return &status, nil
}
api.Logger.Warn(ctx, "failed to refresh chat diff status",
slog.F("chat_id", chat.ID),
slog.F("pull_request_url", reference.PullRequestURL),
slog.Error(err),
)
@@ -1207,14 +1197,6 @@ func (api *API) resolveChatDiffStatusWithOptions(
return &backoffStatus, nil
}
//nolint:revive // Boolean forces cache refresh bypass.
func shouldRefreshChatDiffStatus(status database.ChatDiffStatus, now time.Time, forceRefresh bool) bool {
if forceRefresh {
return true
}
return chatDiffStatusIsStale(status, now)
}
func (api *API) resolveChatDiffContents(
ctx context.Context,
chat database.Chat,
@@ -1488,68 +1470,6 @@ func chatDiffStatusIsStale(status database.ChatDiffStatus, now time.Time) bool {
return !status.StaleAt.After(now)
}
func (api *API) refreshChatDiffStatus(
ctx context.Context,
chatOwnerID uuid.UUID,
chatID uuid.UUID,
pullRequestURL string,
) (database.ChatDiffStatus, error) {
// Find a provider that can handle this PR URL.
var gp gitprovider.Provider
var ref gitprovider.PRRef
for _, extAuth := range api.ExternalAuthConfigs {
p := extAuth.Git(api.HTTPClient)
if p == nil {
continue
}
if parsed, ok := p.ParsePullRequestURL(pullRequestURL); ok {
gp = p
ref = parsed
break
}
}
if gp == nil {
return database.ChatDiffStatus{}, xerrors.Errorf("no git provider found for PR URL %q", pullRequestURL)
}
origin := gp.BuildRepositoryURL(ref.Owner, ref.Repo)
token, err := api.resolveChatGitAccessToken(ctx, chatOwnerID, origin)
if err != nil {
return database.ChatDiffStatus{}, xerrors.Errorf("resolve git access token: %w", err)
} else if token == nil {
return database.ChatDiffStatus{}, xerrors.New("nil git access token")
}
status, err := gp.FetchPullRequestStatus(ctx, *token, ref)
if err != nil {
return database.ChatDiffStatus{}, err
}
refreshedAt := time.Now().UTC()
refreshedStatus, err := api.Database.UpsertChatDiffStatus(
ctx,
database.UpsertChatDiffStatusParams{
ChatID: chatID,
Url: sql.NullString{String: pullRequestURL, Valid: true},
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,
RefreshedAt: refreshedAt,
StaleAt: refreshedAt.Add(chatDiffStatusTTL),
},
)
if err != nil {
return database.ChatDiffStatus{}, xerrors.Errorf("upsert chat diff status: %w", err)
}
return refreshedStatus, nil
}
func (api *API) resolveChatGitAccessToken(
ctx context.Context,
userID uuid.UUID,
@@ -1565,7 +1485,9 @@ func (api *API) resolveChatGitAccessToken(
if config.Regex == nil || !config.Regex.MatchString(origin) {
continue
}
link, err := api.Database.GetExternalAuthLink(ctx,
//nolint:gocritic // System access needed to read external auth
// links when called from the gitsync worker (chatd context).
link, err := api.Database.GetExternalAuthLink(dbauthz.AsSystemRestricted(ctx),
database.GetExternalAuthLinkParams{
ProviderID: config.ID,
UserID: userID,
@@ -1574,7 +1496,8 @@ func (api *API) resolveChatGitAccessToken(
if err != nil {
continue
}
refreshed, refreshErr := config.RefreshToken(ctx, api.Database, link)
//nolint:gocritic // System context carried through for token refresh.
refreshed, refreshErr := config.RefreshToken(dbauthz.AsSystemRestricted(ctx), api.Database, link)
if refreshErr == nil {
link = refreshed
}
@@ -1602,8 +1525,10 @@ func (api *API) resolveChatGitAccessToken(
}
seen[providerID] = struct{}{}
//nolint:gocritic // System access needed to read external auth
// links when called from the gitsync worker (chatd context).
link, err := api.Database.GetExternalAuthLink(
ctx,
dbauthz.AsSystemRestricted(ctx),
database.GetExternalAuthLinkParams{
ProviderID: providerID,
UserID: userID,
@@ -1617,7 +1542,8 @@ func (api *API) resolveChatGitAccessToken(
// the same code path used by provisionerdserver when handing
// tokens to provisioners.
if cfg, ok := configs[providerID]; ok {
refreshed, refreshErr := cfg.RefreshToken(ctx, api.Database, link)
//nolint:gocritic // System context carried through for token refresh.
refreshed, refreshErr := cfg.RefreshToken(dbauthz.AsSystemRestricted(ctx), api.Database, link)
if refreshErr != nil {
api.Logger.Debug(ctx, "failed to refresh external auth token for chat diff",
slog.F("provider_id", providerID),