mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add search and filter support to chats endpoint (#25391)
Fixes https://linear.app/codercom/issue/CODAGT-432 Adds structured search/filter capabilities to the `GET /api/experimental/chats/` endpoint via the `q` query parameter. All filters use explicit `key:value` syntax; bare terms are rejected to reserve them for potential future full-text search. > Generated by Coder Agents Co-authored-by: Danielle Maywood <danielle@themaywoods.com> Co-authored-by: Jaayden Halko <jaayden.halko@gmail.com>
This commit is contained in:
@@ -775,6 +775,9 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
|
||||
arg.AfterID,
|
||||
arg.LabelFilter,
|
||||
arg.DiffURL,
|
||||
arg.TitleQuery,
|
||||
arg.HasUnread,
|
||||
pq.Array(arg.PullRequestStatuses),
|
||||
arg.OffsetOpt,
|
||||
arg.LimitOpt,
|
||||
)
|
||||
|
||||
@@ -13948,6 +13948,242 @@ func TestDeleteChatDebugDataByChatIDStartedBeforeFiltersNewerRuns(t *testing.T)
|
||||
require.Equal(t, newRun.ID, remaining.ID)
|
||||
}
|
||||
|
||||
func TestGetChatsFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store, _ := dbtestutil.NewDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
org := dbgen.Organization(t, store, database.Organization{})
|
||||
user := dbgen.User(t, store, database.User{})
|
||||
dbgen.OrganizationMember(t, store, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
|
||||
|
||||
_, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
APIKey: "test-key",
|
||||
Enabled: true,
|
||||
CentralApiKeyEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
Provider: "openai",
|
||||
Model: "test-model-" + uuid.NewString(),
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 80,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
createRoot := func(title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := store.InsertChat(ctx, database.InsertChatParams{
|
||||
OrganizationID: org.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: title,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
createChild := func(root database.Chat, title string) database.Chat {
|
||||
t.Helper()
|
||||
chat, err := store.InsertChat(ctx, database.InsertChatParams{
|
||||
OrganizationID: org.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: title,
|
||||
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
||||
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
linkPR := func(chatID uuid.UUID, url, state string, draft bool) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
_, err := store.UpsertChatDiffStatus(ctx, database.UpsertChatDiffStatusParams{
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: url, Valid: true},
|
||||
PullRequestState: sql.NullString{String: state, Valid: true},
|
||||
PullRequestTitle: "PR " + state,
|
||||
PullRequestDraft: draft,
|
||||
Additions: 1,
|
||||
Deletions: 1,
|
||||
ChangedFiles: 1,
|
||||
RefreshedAt: now,
|
||||
StaleAt: now.Add(time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
makeUnread := func(chatID uuid.UUID) {
|
||||
t.Helper()
|
||||
_, err := store.InsertChatMessages(ctx, database.InsertChatMessagesParams{
|
||||
ChatID: chatID,
|
||||
CreatedBy: []uuid.UUID{user.ID},
|
||||
ModelConfigID: []uuid.UUID{modelCfg.ID},
|
||||
Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant},
|
||||
Content: []string{`[{"type":"text","text":"hello"}]`},
|
||||
ContentVersion: []int16{0},
|
||||
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
|
||||
InputTokens: []int64{0},
|
||||
OutputTokens: []int64{0},
|
||||
TotalTokens: []int64{0},
|
||||
ReasoningTokens: []int64{0},
|
||||
CacheCreationTokens: []int64{0},
|
||||
CacheReadTokens: []int64{0},
|
||||
ContextLimit: []int64{0},
|
||||
Compressed: []bool{false},
|
||||
TotalCostMicros: []int64{0},
|
||||
RuntimeMs: []int64{0},
|
||||
ProviderResponseID: []string{""},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
markRead := func(chatID uuid.UUID) {
|
||||
t.Helper()
|
||||
lastMsg, err := store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = store.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
|
||||
ID: chatID,
|
||||
LastReadMessageID: lastMsg.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// --- fixtures ---
|
||||
|
||||
// Title-only chats (no PR, no unread).
|
||||
alphaProject := createRoot("alpha project")
|
||||
betaProject := createRoot("beta project")
|
||||
gammaUnrelated := createRoot("gamma unrelated")
|
||||
percentComplete := createRoot("100% complete")
|
||||
thousandOne := createRoot("1001 things")
|
||||
underscoreConfig := createRoot("user_name config")
|
||||
hyphenConfig := createRoot("user-name config")
|
||||
|
||||
// PR-linked chats.
|
||||
draftPR := createRoot("draft pr chat")
|
||||
linkPR(draftPR.ID, "https://github.com/coder/coder/pull/1001", "open", true)
|
||||
makeUnread(draftPR.ID) // also unread
|
||||
|
||||
openPR := createRoot("open pr chat")
|
||||
linkPR(openPR.ID, "https://github.com/coder/coder/pull/1002", "open", false)
|
||||
|
||||
mergedPR := createRoot("merged pr chat")
|
||||
linkPR(mergedPR.ID, "https://github.com/coder/coder/pull/1003", "merged", false)
|
||||
|
||||
closedPR := createRoot("closed pr chat")
|
||||
linkPR(closedPR.ID, "https://github.com/coder/coder/pull/1004", "closed", false)
|
||||
|
||||
// Unread chat without PR.
|
||||
unreadNoPR := createRoot("unread no pr")
|
||||
makeUnread(unreadNoPR.ID)
|
||||
|
||||
// Read chat (message exists but marked read).
|
||||
readChat := createRoot("read chat")
|
||||
makeUnread(readChat.ID)
|
||||
markRead(readChat.ID)
|
||||
|
||||
// Child with draft PR (must not surface its parent).
|
||||
childParent := createRoot("child parent")
|
||||
makeUnread(childParent.ID)
|
||||
markRead(childParent.ID)
|
||||
childWithDraftPR := createChild(childParent, "child draft pr")
|
||||
linkPR(childWithDraftPR.ID, "https://github.com/coder/coder/pull/1005", "open", true)
|
||||
makeUnread(childWithDraftPR.ID)
|
||||
|
||||
// All root chat IDs (for "returns everything" baseline).
|
||||
allRootIDs := []uuid.UUID{
|
||||
alphaProject.ID, betaProject.ID, gammaUnrelated.ID,
|
||||
percentComplete.ID, thousandOne.ID, underscoreConfig.ID, hyphenConfig.ID,
|
||||
draftPR.ID, openPR.ID, mergedPR.ID, closedPR.ID,
|
||||
unreadNoPR.ID, readChat.ID, childParent.ID,
|
||||
}
|
||||
|
||||
// --- test cases ---
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params database.GetChatsParams
|
||||
want []uuid.UUID
|
||||
}{
|
||||
// Title filter.
|
||||
{"Title/SubstringMatch", database.GetChatsParams{TitleQuery: "project"}, []uuid.UUID{alphaProject.ID, betaProject.ID}},
|
||||
{"Title/SingleResult", database.GetChatsParams{TitleQuery: "gamma"}, []uuid.UUID{gammaUnrelated.ID}},
|
||||
{"Title/CaseInsensitive", database.GetChatsParams{TitleQuery: "ALPHA"}, []uuid.UUID{alphaProject.ID}},
|
||||
{"Title/MultiWord", database.GetChatsParams{TitleQuery: "alpha project"}, []uuid.UUID{alphaProject.ID}},
|
||||
{"Title/NoMatch", database.GetChatsParams{TitleQuery: "nonexistent"}, nil},
|
||||
{"Title/EmptyReturnsAll", database.GetChatsParams{TitleQuery: ""}, allRootIDs},
|
||||
// % acts as wildcard since we don't escape ILIKE metacharacters.
|
||||
{"Title/PercentWildcard", database.GetChatsParams{TitleQuery: "100%"}, []uuid.UUID{percentComplete.ID, thousandOne.ID}},
|
||||
// _ acts as single-char wildcard.
|
||||
{"Title/UnderscoreWildcard", database.GetChatsParams{TitleQuery: "user_name"}, []uuid.UUID{underscoreConfig.ID, hyphenConfig.ID}},
|
||||
|
||||
// PR status filter.
|
||||
{"PRStatus/Draft", database.GetChatsParams{PullRequestStatuses: []string{"draft"}}, []uuid.UUID{draftPR.ID}},
|
||||
{"PRStatus/Open", database.GetChatsParams{PullRequestStatuses: []string{"open"}}, []uuid.UUID{openPR.ID}},
|
||||
{"PRStatus/Merged", database.GetChatsParams{PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedPR.ID}},
|
||||
{"PRStatus/Closed", database.GetChatsParams{PullRequestStatuses: []string{"closed"}}, []uuid.UUID{closedPR.ID}},
|
||||
{"PRStatus/MultiStatus", database.GetChatsParams{PullRequestStatuses: []string{"draft", "closed"}}, []uuid.UUID{draftPR.ID, closedPR.ID}},
|
||||
|
||||
// Unread filter.
|
||||
{"Unread/MatchesUnread", database.GetChatsParams{HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{draftPR.ID, unreadNoPR.ID}},
|
||||
// HasUnread=false returns chats without unread messages.
|
||||
{"Unread/ExcludesRead", database.GetChatsParams{HasUnread: sql.NullBool{Bool: false, Valid: true}}, []uuid.UUID{alphaProject.ID, betaProject.ID, gammaUnrelated.ID, percentComplete.ID, thousandOne.ID, underscoreConfig.ID, hyphenConfig.ID, openPR.ID, mergedPR.ID, closedPR.ID, readChat.ID, childParent.ID}},
|
||||
|
||||
// Composed filters.
|
||||
{"Composed/TitleAndPRStatus", database.GetChatsParams{TitleQuery: "draft", PullRequestStatuses: []string{"draft"}}, []uuid.UUID{draftPR.ID}},
|
||||
{"Composed/TitleAndUnread", database.GetChatsParams{TitleQuery: "draft pr", HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{draftPR.ID}},
|
||||
{"Composed/PRStatusAndUnread", database.GetChatsParams{PullRequestStatuses: []string{"draft"}, HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{draftPR.ID}},
|
||||
{"Composed/AllFilters", database.GetChatsParams{TitleQuery: "draft", PullRequestStatuses: []string{"draft"}, HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{draftPR.ID}},
|
||||
{"Composed/TitleNarrowsUnread", database.GetChatsParams{TitleQuery: "no pr", HasUnread: sql.NullBool{Bool: true, Valid: true}}, []uuid.UUID{unreadNoPR.ID}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Always scope to this user.
|
||||
params := tt.params
|
||||
params.OwnedOnly = true
|
||||
params.ViewerID = user.ID
|
||||
|
||||
rows, err := store.GetChats(ctx, params)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := make([]uuid.UUID, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
got = append(got, row.Chat.ID)
|
||||
}
|
||||
|
||||
if tt.want == nil {
|
||||
require.Empty(t, got)
|
||||
} else {
|
||||
require.ElementsMatch(t, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatHasUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -7815,6 +7815,44 @@ WHERE
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by title substring (case-insensitive). Applied when the
|
||||
-- caller provides a non-empty title_query.
|
||||
AND CASE
|
||||
WHEN $8 :: text != '' THEN chats_expanded.title ILIKE '%' || $8 || '%'
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $9::boolean IS NOT NULL THEN (
|
||||
EXISTS (
|
||||
SELECT 1 FROM chat_messages cm
|
||||
WHERE cm.chat_id = chats_expanded.id
|
||||
AND cm.role = 'assistant'
|
||||
AND cm.deleted = false
|
||||
AND cm.id > COALESCE(chats_expanded.last_read_message_id, 0)
|
||||
)
|
||||
) = $9::boolean
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by pull request status. Unlike the diff_url filter above,
|
||||
-- this intentionally checks only the root chat's own diff status.
|
||||
-- Child chats share the same workspace and git branch as their
|
||||
-- parent, so gitsync populates identical PR state on both; traversing
|
||||
-- descendants would be redundant.
|
||||
AND CASE
|
||||
WHEN COALESCE(array_length($10::text[], 1), 0) > 0 THEN EXISTS (
|
||||
SELECT 1
|
||||
FROM chat_diff_statuses cds
|
||||
WHERE cds.chat_id = chats_expanded.id
|
||||
AND (
|
||||
CASE
|
||||
WHEN cds.pull_request_state = 'open' AND cds.pull_request_draft THEN 'draft'
|
||||
WHEN cds.pull_request_state = 'open' THEN 'open'
|
||||
ELSE cds.pull_request_state
|
||||
END
|
||||
) = ANY($10::text[])
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
-- Paginate over root chats only. Children are fetched
|
||||
-- separately via GetChildChatsByParentIDs and embedded under
|
||||
-- each parent. Other callers that need the full set should
|
||||
@@ -7831,23 +7869,26 @@ ORDER BY
|
||||
-chats_expanded.pin_order DESC,
|
||||
chats_expanded.updated_at DESC,
|
||||
chats_expanded.id DESC
|
||||
OFFSET $8
|
||||
OFFSET $11
|
||||
LIMIT
|
||||
-- The chat list is unbounded and expected to grow large.
|
||||
-- Default to 50 to prevent accidental excessively large queries.
|
||||
COALESCE(NULLIF($9 :: int, 0), 50)
|
||||
COALESCE(NULLIF($12 :: int, 0), 50)
|
||||
`
|
||||
|
||||
type GetChatsParams struct {
|
||||
OwnedOnly bool `db:"owned_only" json:"owned_only"`
|
||||
ViewerID uuid.UUID `db:"viewer_id" json:"viewer_id"`
|
||||
SharedOnly bool `db:"shared_only" json:"shared_only"`
|
||||
Archived sql.NullBool `db:"archived" json:"archived"`
|
||||
AfterID uuid.UUID `db:"after_id" json:"after_id"`
|
||||
LabelFilter pqtype.NullRawMessage `db:"label_filter" json:"label_filter"`
|
||||
DiffURL sql.NullString `db:"diff_url" json:"diff_url"`
|
||||
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
|
||||
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
||||
OwnedOnly bool `db:"owned_only" json:"owned_only"`
|
||||
ViewerID uuid.UUID `db:"viewer_id" json:"viewer_id"`
|
||||
SharedOnly bool `db:"shared_only" json:"shared_only"`
|
||||
Archived sql.NullBool `db:"archived" json:"archived"`
|
||||
AfterID uuid.UUID `db:"after_id" json:"after_id"`
|
||||
LabelFilter pqtype.NullRawMessage `db:"label_filter" json:"label_filter"`
|
||||
DiffURL sql.NullString `db:"diff_url" json:"diff_url"`
|
||||
TitleQuery string `db:"title_query" json:"title_query"`
|
||||
HasUnread sql.NullBool `db:"has_unread" json:"has_unread"`
|
||||
PullRequestStatuses []string `db:"pull_request_statuses" json:"pull_request_statuses"`
|
||||
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
|
||||
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
||||
}
|
||||
|
||||
type GetChatsRow struct {
|
||||
@@ -7864,6 +7905,9 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
|
||||
arg.AfterID,
|
||||
arg.LabelFilter,
|
||||
arg.DiffURL,
|
||||
arg.TitleQuery,
|
||||
arg.HasUnread,
|
||||
pq.Array(arg.PullRequestStatuses),
|
||||
arg.OffsetOpt,
|
||||
arg.LimitOpt,
|
||||
)
|
||||
|
||||
@@ -531,6 +531,44 @@ WHERE
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by title substring (case-insensitive). Applied when the
|
||||
-- caller provides a non-empty title_query.
|
||||
AND CASE
|
||||
WHEN @title_query :: text != '' THEN chats_expanded.title ILIKE '%' || @title_query || '%'
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN sqlc.narg('has_unread')::boolean IS NOT NULL THEN (
|
||||
EXISTS (
|
||||
SELECT 1 FROM chat_messages cm
|
||||
WHERE cm.chat_id = chats_expanded.id
|
||||
AND cm.role = 'assistant'
|
||||
AND cm.deleted = false
|
||||
AND cm.id > COALESCE(chats_expanded.last_read_message_id, 0)
|
||||
)
|
||||
) = sqlc.narg('has_unread')::boolean
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by pull request status. Unlike the diff_url filter above,
|
||||
-- this intentionally checks only the root chat's own diff status.
|
||||
-- Child chats share the same workspace and git branch as their
|
||||
-- parent, so gitsync populates identical PR state on both; traversing
|
||||
-- descendants would be redundant.
|
||||
AND CASE
|
||||
WHEN COALESCE(array_length(@pull_request_statuses::text[], 1), 0) > 0 THEN EXISTS (
|
||||
SELECT 1
|
||||
FROM chat_diff_statuses cds
|
||||
WHERE cds.chat_id = chats_expanded.id
|
||||
AND (
|
||||
CASE
|
||||
WHEN cds.pull_request_state = 'open' AND cds.pull_request_draft THEN 'draft'
|
||||
WHEN cds.pull_request_state = 'open' THEN 'open'
|
||||
ELSE cds.pull_request_state
|
||||
END
|
||||
) = ANY(@pull_request_statuses::text[])
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
-- Paginate over root chats only. Children are fetched
|
||||
-- separately via GetChildChatsByParentIDs and embedded under
|
||||
-- each parent. Other callers that need the full set should
|
||||
|
||||
Reference in New Issue
Block a user