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:
Generated
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user