Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(coderd): wire chat search filter into chats API
- Wires the `search` filter through to `GetChats`
- Adds a preflight query to check if the tokenized query evaluates
  to an empty tsquery, returns an 400 error if so.
- Adds corresponding API tests.
  • Loading branch information
johnstcn committed Jul 9, 2026
commit a558b88d8131e105577cfe9bf989feda53a4123c
2 changes: 1 addition & 1 deletion coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,14 @@ func (q *querier) CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.Con
return q.db.CalculateAIBridgeInterceptionsTelemetrySummary(ctx, arg)
}

func (q *querier) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
// Pure function, no rows. Gates on chat read to match the listing caller.
Comment thread
johnstcn marked this conversation as resolved.
Outdated
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil {
return false, err
}
return q.db.ChatSearchQueryIsEmpty(ctx, search)
}

func (q *querier) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
empty := database.ClaimPrebuiltWorkspaceRow{}

Expand Down
4 changes: 4 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), int32(100)).Return(int64(0), nil).AnyTimes()
check.Args(int32(100)).Asserts(rbac.ResourceChat, policy.ActionUpdate)
}))
s.Run("ChatSearchQueryIsEmpty", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().ChatSearchQueryIsEmpty(gomock.Any(), "!!!").Return(true, nil).AnyTimes()
check.Args("!!!").Asserts(rbac.ResourceChat, policy.ActionRead)
}))
s.Run("GetChatRetentionDays", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(30), nil).AnyTimes()
check.Args().Asserts()
Expand Down
8 changes: 8 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coderd/database/queries/chats.sql
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,11 @@ SET search_tsv = COALESCE(
''::tsvector)
FROM batch WHERE cm.id = batch.id;

-- name: ChatSearchQueryIsEmpty :one
-- Reports whether search text tokenizes to an empty tsquery (e.g. '!!!').
-- Used to reject input that would silently match nothing.
SELECT numnode(websearch_to_tsquery('simple', @search::text)) = 0 AS is_empty;

-- name: GetChatByID :one
SELECT *
FROM chats_expanded
Expand Down
25 changes: 24 additions & 1 deletion coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param q query string false "Search query. Supports title:<substring> (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft\|open\|merged\|closed> as repeated or comma-separated values, source:<created_by_me\|shared_with_me>, diff_url:<url> (quote values containing colons), pr:<number> (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title:<text> (case-insensitive PR title substring). Bare terms are not supported; use title:<value> for title filtering."
// @Param q query string false "Search query. Supports title:<substring> (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft\|open\|merged\|closed> as repeated or comma-separated values, source:<created_by_me\|shared_with_me>, diff_url:<url> (quote values containing colons), pr:<number> (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title:<text> (case-insensitive PR title substring), search:<text> (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr). Bare terms are not supported; use title:<value> or search:<value>."
// @Param label query string false "Filter by label as key:value. Repeat for multiple (AND logic)."
// @Success 200 {array} codersdk.Chat
// @Router /api/experimental/chats [get]
Expand All @@ -357,6 +357,28 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
return
}

// Reject text that tokenizes to nothing; it would silently match no rows.
if searchParams.Search != "" {
isEmpty, err := api.Database.ChatSearchQueryIsEmpty(ctx, searchParams.Search)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate search query.",
Detail: err.Error(),
})
return
}
if isEmpty {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat search query.",
Validations: []codersdk.ValidationError{{
Field: "search",
Detail: "Search query contains no searchable words.",
}},
})
return
}
}

var labelFilter pqtype.NullRawMessage
if labelParams := r.URL.Query()["label"]; len(labelParams) > 0 {
labelMap := make(map[string]string, len(labelParams))
Expand Down Expand Up @@ -416,6 +438,7 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
PrNumber: searchParams.PrNumber,
RepoQuery: searchParams.RepoQuery,
PrTitleQuery: searchParams.PrTitleQuery,
Search: searchParams.Search,
// #nosec G115 - Pagination offsets are small and fit in int32
OffsetOpt: int32(paginationParams.Offset),
// #nosec G115 - Pagination limits are small and fit in int32
Expand Down
165 changes: 165 additions & 0 deletions coderd/exp_chats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1926,6 +1926,171 @@ func TestListChatModels(t *testing.T) {
})
}

func TestListChats_Search(t *testing.T) {
t.Parallel()

setup := func(t *testing.T) (context.Context, *codersdk.ExperimentalClient, database.Store, codersdk.CreateFirstUserResponse, codersdk.ChatModelConfig) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
return ctx, client, db, firstUser, modelConfig
}

createChat := func(t *testing.T, db database.Store, firstUser codersdk.CreateFirstUserResponse, modelConfigID uuid.UUID, title string) database.Chat {
t.Helper()
return dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfigID,
Title: title,
Status: database.ChatStatusCompleted,
})
}

insertMessage := func(t *testing.T, db database.Store, firstUser codersdk.CreateFirstUserResponse, modelConfigID, chatID uuid.UUID, text string) {
t.Helper()
content, err := json.Marshal([]map[string]string{{"type": "text", "text": text}})
require.NoError(t, err)
dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chatID,
CreatedBy: uuid.NullUUID{UUID: firstUser.UserID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: content, Valid: true},
})
}

backfillSearchTsv := func(ctx context.Context, t *testing.T, db database.Store) {
t.Helper()
_, err := db.BackfillChatMessagesSearchTsv(dbauthz.AsSystemRestricted(ctx), 1000)
require.NoError(t, err)
}

chatIDs := func(chats []codersdk.Chat) map[uuid.UUID]struct{} {
ids := make(map[uuid.UUID]struct{}, len(chats))
for _, chat := range chats {
ids[chat.ID] = struct{}{}
}
return ids
}

t.Run("MatchesTitleAndMessageBody", func(t *testing.T) {
t.Parallel()
ctx, client, db, firstUser, modelConfig := setup(t)

titleMatch := createChat(t, db, firstUser, modelConfig.ID, "kubernetes upgrade notes")
bodyMatch := createChat(t, db, firstUser, modelConfig.ID, "plain title")
insertMessage(t, db, firstUser, modelConfig.ID, bodyMatch.ID, "restart the kubernetes cluster")
noMatch := createChat(t, db, firstUser, modelConfig.ID, "unrelated chat")
insertMessage(t, db, firstUser, modelConfig.ID, noMatch.ID, "terraform apply failure")
backfillSearchTsv(ctx, t, db)

chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"kubernetes"`,
})
require.NoError(t, err)
ids := chatIDs(chats)
require.Contains(t, ids, titleMatch.ID)
require.Contains(t, ids, bodyMatch.ID)
require.NotContains(t, ids, noMatch.ID)
})

t.Run("NoSearchableWordsReturns400", func(t *testing.T) {
t.Parallel()
ctx, client, _, _, _ := setup(t)

_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:"!!!"`,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "search", sdkErr.Validations[0].Field)
require.Contains(t, sdkErr.Validations[0].Detail, "no searchable words")
})

t.Run("ComposesWithRepoFilterAndArchivedDefault", func(t *testing.T) {
t.Parallel()
ctx, client, db, firstUser, modelConfig := setup(t)

linkRepo := func(chatID uuid.UUID, remote string) {
t.Helper()
_, err := db.UpsertChatDiffStatusReference(
dbauthz.AsSystemRestricted(ctx),
database.UpsertChatDiffStatusReferenceParams{
ChatID: chatID,
GitBranch: "main",
GitRemoteOrigin: remote,
StaleAt: time.Now().UTC().Add(time.Hour),
},
)
require.NoError(t, err)
}

bothMatch := createChat(t, db, firstUser, modelConfig.ID, "kubernetes in coder repo")
linkRepo(bothMatch.ID, "git@github.com:acme/widget.git")
searchOnly := createChat(t, db, firstUser, modelConfig.ID, "kubernetes elsewhere")
linkRepo(searchOnly.ID, "git@github.com:acme/other.git")
repoOnly := createChat(t, db, firstUser, modelConfig.ID, "plain title")
linkRepo(repoOnly.ID, "git@github.com:acme/widget.git")
// Matches via message body, not title, so composition also covers
// search_tsv.
bodyMatch := createChat(t, db, firstUser, modelConfig.ID, "quiet title")
linkRepo(bodyMatch.ID, "git@github.com:acme/widget.git")
insertMessage(t, db, firstUser, modelConfig.ID, bodyMatch.ID, "kubernetes rollout stuck")
bodyMatchWrongRepo := createChat(t, db, firstUser, modelConfig.ID, "quiet title two")
linkRepo(bodyMatchWrongRepo.ID, "git@github.com:acme/other.git")
insertMessage(t, db, firstUser, modelConfig.ID, bodyMatchWrongRepo.ID, "kubernetes rollout stuck")
archivedMatch := createChat(t, db, firstUser, modelConfig.ID, "kubernetes archived")
linkRepo(archivedMatch.ID, "git@github.com:acme/widget.git")
_, err := db.ArchiveChatByID(dbauthz.AsSystemRestricted(ctx), archivedMatch.ID)
require.NoError(t, err)
backfillSearchTsv(ctx, t, db)

chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `repo:widget search:"kubernetes"`,
})
require.NoError(t, err)
ids := chatIDs(chats)
require.Contains(t, ids, bothMatch.ID)
require.Contains(t, ids, bodyMatch.ID)
require.NotContains(t, ids, bodyMatchWrongRepo.ID)
require.NotContains(t, ids, searchOnly.ID)
require.NotContains(t, ids, repoOnly.ID)
// Archived chats stay hidden unless archived:true is requested.
require.NotContains(t, ids, archivedMatch.ID)
})

t.Run("NoSearchTermUnchanged", func(t *testing.T) {
t.Parallel()
ctx, client, db, firstUser, modelConfig := setup(t)

chat := createChat(t, db, firstUser, modelConfig.ID, "kubernetes upgrade notes")
other := createChat(t, db, firstUser, modelConfig.ID, "unrelated chat")

chats, err := client.ListChats(ctx, nil)
require.NoError(t, err)
ids := chatIDs(chats)
require.Contains(t, ids, chat.ID)
require.Contains(t, ids, other.ID)
})

t.Run("MutualExclusionWithTitleReturns400", func(t *testing.T) {
t.Parallel()
ctx, client, _, _, _ := setup(t)

_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
Query: `search:alpha title:beta`,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "search", sdkErr.Validations[0].Field)
require.Contains(t, sdkErr.Validations[0].Detail, `"title"`)
})
}

func TestWatchChats(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading