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:
Cian Johnston
2026-05-21 10:18:55 +01:00
committed by GitHub
parent 46e93e6325
commit b7525a9b40
11 changed files with 680 additions and 25 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "Search query. Supports archived:bool and diff_url:\u003curl\u003e terms (quote URLs).",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, and diff_url:\u003curl\u003e (quote URLs). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"name": "q",
"in": "query"
},
+1 -1
View File
@@ -59,7 +59,7 @@
"parameters": [
{
"type": "string",
"description": "Search query. Supports archived:bool and diff_url:\u003curl\u003e terms (quote URLs).",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, and diff_url:\u003curl\u003e (quote URLs). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"name": "q",
"in": "query"
},
+3
View File
@@ -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,
)
+236
View File
@@ -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()
+46 -2
View File
@@ -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,11 +7869,11 @@ 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 {
@@ -7846,6 +7884,9 @@ type GetChatsParams struct {
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"`
}
@@ -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,
)
+38
View File
@@ -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
+4 -1
View File
@@ -312,7 +312,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 archived:bool and diff_url:<url> terms (quote URLs)."
// @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, and diff_url:<url> (quote URLs). Bare terms are not supported; use title:<value> for title filtering."
// @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]
@@ -370,6 +370,9 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
AfterID: paginationParams.AfterID,
LabelFilter: labelFilter,
DiffURL: searchParams.DiffURL,
TitleQuery: searchParams.TitleQuery,
HasUnread: searchParams.HasUnread,
PullRequestStatuses: searchParams.PullRequestStatuses,
// #nosec G115 - Pagination offsets are small and fit in int32
OffsetOpt: int32(paginationParams.Offset),
// #nosec G115 - Pagination limits are small and fit in int32
+185
View File
@@ -890,6 +890,26 @@ func TestPostChats_ClientType(t *testing.T) {
func TestListChats(t *testing.T) {
t.Parallel()
sortedChatIDs := func(ids []uuid.UUID) []uuid.UUID {
out := append([]uuid.UUID(nil), ids...)
slices.SortFunc(out, func(a, b uuid.UUID) int {
return strings.Compare(a.String(), b.String())
})
return out
}
requireRootIDs := func(t *testing.T, chats []codersdk.Chat, want ...uuid.UUID) []uuid.UUID {
t.Helper()
got := make([]uuid.UUID, 0, len(chats))
for _, chat := range chats {
require.Nil(t, chat.ParentChatID, "list should only return root chats")
got = append(got, chat.ID)
}
require.Equal(t, sortedChatIDs(want), sortedChatIDs(got))
return got
}
t.Run("Success", func(t *testing.T) {
t.Parallel()
@@ -1539,6 +1559,171 @@ func TestListChats(t *testing.T) {
require.Equal(t, archivedWithPR.ID, chats[0].ID)
})
})
t.Run("TitleSearch", func(t *testing.T) {
t.Parallel()
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
// Verify that the title: filter is wired through the endpoint.
// Exhaustive ILIKE behavior is tested in TestGetChatsFilter (Title/* subtests).
alpha := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "alpha project",
})
_ = dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "beta unrelated",
})
ctx := testutil.Context(t, testutil.WaitLong)
t.Run("SingleWord", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{Query: "title:alpha"})
require.NoError(t, err)
requireRootIDs(t, chats, alpha.ID)
})
t.Run("MultiWord", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{Query: `title:"alpha project"`})
require.NoError(t, err)
requireRootIDs(t, chats, alpha.ID)
})
t.Run("BareTermsRejected", func(t *testing.T) {
_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{Query: "bare words"})
requireSDKError(t, err, http.StatusBadRequest)
})
})
t.Run("PRStatusFilter", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
// Verify that pr_status filter is wired through the endpoint.
// Exhaustive query logic is tested in TestGetChatsFilter (PRStatus/* subtests).
createChatWithPR := func(title, prURL, prState string, prDraft bool) database.Chat {
t.Helper()
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: title,
Status: database.ChatStatusCompleted,
})
refreshedAt := time.Now().UTC().Truncate(time.Second)
staleAt := refreshedAt.Add(time.Hour)
_, err := db.UpsertChatDiffStatusReference(
dbauthz.AsSystemRestricted(ctx),
database.UpsertChatDiffStatusReferenceParams{
ChatID: chat.ID,
Url: sql.NullString{String: prURL, Valid: true},
GitBranch: "feature/test",
GitRemoteOrigin: "git@github.com:coder/coder.git",
StaleAt: staleAt,
},
)
require.NoError(t, err)
_, err = db.UpsertChatDiffStatus(
dbauthz.AsSystemRestricted(ctx),
database.UpsertChatDiffStatusParams{
ChatID: chat.ID,
Url: sql.NullString{String: prURL, Valid: true},
PullRequestState: sql.NullString{String: prState, Valid: true},
PullRequestDraft: prDraft,
RefreshedAt: refreshedAt,
StaleAt: staleAt,
},
)
require.NoError(t, err)
return chat
}
draftChat := createChatWithPR("draft pr", "https://github.com/coder/coder/pull/301", "open", true)
_ = createChatWithPR("open pr", "https://github.com/coder/coder/pull/302", "open", false)
t.Run("MatchesDraft", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{Query: "pr_status:draft"})
require.NoError(t, err)
requireRootIDs(t, chats, draftChat.ID)
})
t.Run("InvalidPRStatus", func(t *testing.T) {
_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{Query: "pr_status:bogus"})
requireSDKError(t, err, http.StatusBadRequest)
})
})
t.Run("UnreadFilter", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
// Verify that has_unread:true filter is wired through the endpoint.
// Exhaustive query logic is tested in TestGetChatsFilter (Unread/* subtests).
unreadChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "unread chat",
Status: database.ChatStatusCompleted,
})
_, err := db.InsertChatMessages(dbauthz.AsSystemRestricted(ctx), database.InsertChatMessagesParams{
ChatID: unreadChat.ID,
CreatedBy: []uuid.UUID{user.UserID},
ModelConfigID: []uuid.UUID{modelConfig.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)
// Create a second chat with NO unread messages to prove filtering works.
_ = dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "read chat",
Status: database.ChatStatusCompleted,
})
t.Run("MatchesUnread", func(t *testing.T) {
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{Query: "has_unread:true"})
require.NoError(t, err)
requireRootIDs(t, chats, unreadChat.ID)
})
t.Run("InvalidHasUnread", func(t *testing.T) {
_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{Query: "has_unread:bogus"})
requireSDKError(t, err, http.StatusBadRequest)
})
})
}
func TestListChatModels(t *testing.T) {
+19 -1
View File
@@ -543,7 +543,13 @@ func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UU
// Chats parses a search query for chats.
//
// Supported query parameters:
// - archived: boolean (default: false, excludes archived chats unless explicitly set)
// - title: case-insensitive title substring match via ILIKE (bare terms
// are rejected; use title:<value> for title filtering)
// - archived: boolean (default: false, excludes archived chats unless
// explicitly set)
// - has_unread: nullable boolean (filter by unread message status)
// - pr_status: repeated or comma-separated list of draft, open,
// merged, closed
// - diff_url: string (matches chats whose linked diff URL equals the
// given value, case-insensitively; URLs typically contain ':' so
// they must be quoted, e.g. q=diff_url:"https://github.com/o/r/pull/1")
@@ -570,6 +576,16 @@ func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
parser := httpapi.NewQueryParamParser()
filter.Archived = parser.NullableBoolean(values, filter.Archived, "archived")
filter.HasUnread = parser.NullableBoolean(values, filter.HasUnread, "has_unread")
filter.PullRequestStatuses = httpapi.ParseCustomList(parser, values, nil, "pr_status", func(v string) (string, error) {
normalizedPRStatus := strings.ToLower(strings.TrimSpace(v))
switch normalizedPRStatus {
case "draft", "open", "merged", "closed":
return normalizedPRStatus, nil
default:
return "", xerrors.Errorf("%q is not a valid value", v)
}
})
if diffURL := parser.String(values, "", "diff_url"); diffURL != "" {
if err := validateDiffURL(diffURL); err != nil {
parser.Errors = append(parser.Errors, codersdk.ValidationError{
@@ -581,6 +597,8 @@ func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
}
}
filter.TitleQuery = parser.String(values, "", "title")
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
+128
View File
@@ -1257,6 +1257,96 @@ func TestSearchChats(t *testing.T) {
Archived: sql.NullBool{Bool: false, Valid: true},
},
},
{
Name: "HasUnreadTrue",
Query: "has_unread:true",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
HasUnread: sql.NullBool{Bool: true, Valid: true},
},
},
{
Name: "HasUnreadFalse",
Query: "has_unread:false",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
HasUnread: sql.NullBool{Bool: false, Valid: true},
},
},
{
Name: "HasUnreadInvalid",
Query: "has_unread:bogus",
ExpectedErrorContains: "has_unread",
},
{
Name: "PRStatusDraft",
Query: "pr_status:draft",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PullRequestStatuses: []string{"draft"},
},
},
{
Name: "PRStatusOpen",
Query: "pr_status:open",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PullRequestStatuses: []string{"open"},
},
},
{
Name: "PRStatusMerged",
Query: "pr_status:merged",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PullRequestStatuses: []string{"merged"},
},
},
{
Name: "PRStatusClosed",
Query: "pr_status:closed",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PullRequestStatuses: []string{"closed"},
},
},
{
Name: "PRStatusMultipleRepeated",
Query: "pr_status:draft pr_status:merged",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PullRequestStatuses: []string{"draft", "merged"},
},
},
{
Name: "PRStatusMultipleCSV",
Query: "pr_status:draft,closed",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PullRequestStatuses: []string{"draft", "closed"},
},
},
{
Name: "PRStatusValueCaseInsensitive",
Query: "pr_status:DRAFT",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PullRequestStatuses: []string{"draft"},
},
},
{
Name: "PRStatusInvalid",
Query: "pr_status:review",
ExpectedErrorContains: "pr_status",
},
{
Name: "PRStatusWithArchived",
Query: "archived:true pr_status:open",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
PullRequestStatuses: []string{"open"},
},
},
{
Name: "ExtraParam",
Query: "archived:true invalid:param",
@@ -1336,6 +1426,44 @@ func TestSearchChats(t *testing.T) {
Query: `diff_url:"http://%41:8080/"`,
ExpectedErrorContains: "not a valid URL",
},
{
Name: "TitleSearch",
Query: `title:"hello world"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
TitleQuery: "hello world",
},
},
{
Name: "TitleSearchWithArchived",
Query: `title:"my chat" archived:true`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
TitleQuery: "my chat",
},
},
{
Name: "TitleSearchSingleWord",
Query: "title:deploy",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
TitleQuery: "deploy",
},
},
{
Name: "TitleSearchWithDiffURL",
Query: `title:deploy diff_url:"https://github.com/coder/coder/pull/456"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
TitleQuery: "deploy",
DiffURL: sql.NullString{String: "https://github.com/coder/coder/pull/456", Valid: true},
},
},
{
Name: "BareTermsRejected",
Query: "some random words",
ExpectedErrorContains: `unsupported search term: "some random words"`,
},
}
for _, c := range testCases {
+2 -2
View File
@@ -18,8 +18,8 @@ Experimental: this endpoint is subject to change.
### Parameters
| Name | In | Type | Required | Description |
|---------|-------|--------|----------|-----------------------------------------------------------------------------|
| `q` | query | string | false | Search query. Supports archived:bool and diff_url:<url> terms (quote URLs). |
|---------|-------|--------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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, and diff_url:<url> (quote URLs). Bare terms are not supported; use title:<value> for title filtering. |
| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). |
### Example responses