diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 918b3f1ffc..9b520ca8ce 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5812c20e05..5baa2bd081 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" }, diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 76f1934c62..2b6eb5297b 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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, ) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 4e7a702b4f..8898cacd4c 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4505fe0e35..6ff1d2dc76 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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, ) diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 329a819881..7377f9d8b1 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -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 diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 9ad3bd863c..1a8aa649d5 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -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: terms (quote URLs)." +// @Param q query string false "Search query. Supports title: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status: as repeated or comma-separated values, and diff_url: (quote URLs). Bare terms are not supported; use title: 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] @@ -364,12 +364,15 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { } params := database.GetChatsParams{ - OwnedOnly: true, - ViewerID: apiKey.UserID, - Archived: searchParams.Archived, - AfterID: paginationParams.AfterID, - LabelFilter: labelFilter, - DiffURL: searchParams.DiffURL, + OwnedOnly: true, + ViewerID: apiKey.UserID, + Archived: searchParams.Archived, + 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 diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 23cb181ed9..757860b19d 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -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) { diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index f594856d8c..4e42e3bd1c 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -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: 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 } diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 05b801c158..c1471afe91 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -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 { diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 8619fdbff6..8f862c8029 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -17,10 +17,10 @@ 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: terms (quote URLs). | -| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | +| Name | In | Type | Required | Description | +|---------|-------|--------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query. Supports title: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status: as repeated or comma-separated values, and diff_url: (quote URLs). Bare terms are not supported; use title: for title filtering. | +| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | ### Example responses