mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: wire chat search box to full-text search (#27973)
This commit is contained in:
Generated
+1
-1
@@ -78,7 +78,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"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, ` + "`" + `source:\u003ccreated_by_me\\|shared_with_me\u003e` + "`" + `, ` + "`" + `diff_url:\u003curl\u003e` + "`" + ` (quote values containing colons), ` + "`" + `pr:\u003cnumber\u003e` + "`" + ` (exact PR number match), ` + "`" + `repo:\u003cowner/repo\u003e` + "`" + ` (case-insensitive substring match against git remote origin or URL), ` + "`" + `pr_title:\u003ctext\u003e` + "`" + ` (case-insensitive PR title substring), ` + "`" + `search:\u003ctext\u003e` + "`" + ` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr). Bare terms are not supported; use ` + "`" + `title:\u003cvalue\u003e` + "`" + ` or ` + "`" + `search:\u003cvalue\u003e` + "`" + `.",
|
||||
"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, ` + "`" + `source:\u003ccreated_by_me\\|shared_with_me\u003e` + "`" + `, ` + "`" + `diff_url:\u003curl\u003e` + "`" + ` (quote values containing colons), ` + "`" + `pr:\u003cnumber\u003e` + "`" + ` (exact PR number match), ` + "`" + `repo:\u003cowner/repo\u003e` + "`" + ` (case-insensitive substring match against git remote origin or URL), ` + "`" + `pr_title:\u003ctext\u003e` + "`" + ` (case-insensitive PR title substring), ` + "`" + `search:\u003ctext\u003e` + "`" + ` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use ` + "`" + `title:\u003cvalue\u003e` + "`" + ` or ` + "`" + `search:\u003cvalue\u003e` + "`" + `.",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
|
||||
Generated
+1
-1
@@ -59,7 +59,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"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, `source:\u003ccreated_by_me\\|shared_with_me\u003e`, `diff_url:\u003curl\u003e` (quote values containing colons), `pr:\u003cnumber\u003e` (exact PR number match), `repo:\u003cowner/repo\u003e` (case-insensitive substring match against git remote origin or URL), `pr_title:\u003ctext\u003e` (case-insensitive PR title substring), `search:\u003ctext\u003e` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr). Bare terms are not supported; use `title:\u003cvalue\u003e` or `search:\u003cvalue\u003e`.",
|
||||
"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, `source:\u003ccreated_by_me\\|shared_with_me\u003e`, `diff_url:\u003curl\u003e` (quote values containing colons), `pr:\u003cnumber\u003e` (exact PR number match), `repo:\u003cowner/repo\u003e` (case-insensitive substring match against git remote origin or URL), `pr_title:\u003ctext\u003e` (case-insensitive PR title substring), `search:\u003ctext\u003e` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use `title:\u003cvalue\u003e` or `search:\u003cvalue\u003e`.",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
|
||||
@@ -1889,13 +1889,6 @@ func (q *querier) CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.Con
|
||||
return q.db.CalculateAIBridgeInterceptionsTelemetrySummary(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return q.db.ChatSearchQueryIsEmpty(ctx, search)
|
||||
}
|
||||
|
||||
func (q *querier) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
|
||||
empty := database.ClaimPrebuiltWorkspaceRow{}
|
||||
|
||||
|
||||
@@ -962,10 +962,6 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), int32(100)).Return(int64(0), nil).AnyTimes()
|
||||
check.Args(int32(100)).Asserts(rbac.ResourceChat, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("ChatSearchQueryIsEmpty", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().ChatSearchQueryIsEmpty(gomock.Any(), "!!!").Return(true, nil).AnyTimes()
|
||||
check.Args("!!!").Asserts(rbac.ResourceChat, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetChatRetentionDays", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(30), nil).AnyTimes()
|
||||
check.Args().Asserts()
|
||||
|
||||
-8
@@ -265,14 +265,6 @@ func (m queryMetricsStore) CalculateAIBridgeInterceptionsTelemetrySummary(ctx co
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.ChatSearchQueryIsEmpty(ctx, search)
|
||||
m.queryLatencies.WithLabelValues("ChatSearchQueryIsEmpty").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ChatSearchQueryIsEmpty").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.ClaimPrebuiltWorkspace(ctx, arg)
|
||||
|
||||
Generated
-15
@@ -337,21 +337,6 @@ func (mr *MockStoreMockRecorder) CalculateAIBridgeInterceptionsTelemetrySummary(
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CalculateAIBridgeInterceptionsTelemetrySummary", reflect.TypeOf((*MockStore)(nil).CalculateAIBridgeInterceptionsTelemetrySummary), ctx, arg)
|
||||
}
|
||||
|
||||
// ChatSearchQueryIsEmpty mocks base method.
|
||||
func (m *MockStore) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ChatSearchQueryIsEmpty", ctx, search)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ChatSearchQueryIsEmpty indicates an expected call of ChatSearchQueryIsEmpty.
|
||||
func (mr *MockStoreMockRecorder) ChatSearchQueryIsEmpty(ctx, search any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChatSearchQueryIsEmpty", reflect.TypeOf((*MockStore)(nil).ChatSearchQueryIsEmpty), ctx, search)
|
||||
}
|
||||
|
||||
// ClaimPrebuiltWorkspace mocks base method.
|
||||
func (m *MockStore) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
-3
@@ -86,9 +86,6 @@ type sqlcQuerier interface {
|
||||
// Calculates the telemetry summary for a given provider, model, and client
|
||||
// combination for telemetry reporting.
|
||||
CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.Context, arg CalculateAIBridgeInterceptionsTelemetrySummaryParams) (CalculateAIBridgeInterceptionsTelemetrySummaryRow, error)
|
||||
// Reports whether search text tokenizes to an empty tsquery (e.g. '!!!').
|
||||
// Used to reject input that would silently match nothing.
|
||||
ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error)
|
||||
ClaimPrebuiltWorkspace(ctx context.Context, arg ClaimPrebuiltWorkspaceParams) (ClaimPrebuiltWorkspaceRow, error)
|
||||
CleanTailnetCoordinators(ctx context.Context) error
|
||||
CleanTailnetLostPeers(ctx context.Context) error
|
||||
|
||||
Generated
-13
@@ -6993,19 +6993,6 @@ func (q *sqlQuerier) BatchUpsertChatHeartbeats(ctx context.Context, arg BatchUps
|
||||
return err
|
||||
}
|
||||
|
||||
const chatSearchQueryIsEmpty = `-- name: ChatSearchQueryIsEmpty :one
|
||||
SELECT numnode(websearch_to_tsquery('simple', $1::text)) = 0 AS is_empty
|
||||
`
|
||||
|
||||
// Reports whether search text tokenizes to an empty tsquery (e.g. '!!!').
|
||||
// Used to reject input that would silently match nothing.
|
||||
func (q *sqlQuerier) ChatSearchQueryIsEmpty(ctx context.Context, search string) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, chatSearchQueryIsEmpty, search)
|
||||
var is_empty bool
|
||||
err := row.Scan(&is_empty)
|
||||
return is_empty, err
|
||||
}
|
||||
|
||||
const countChatQueuedMessages = `-- name: CountChatQueuedMessages :one
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM chat_queued_messages
|
||||
|
||||
@@ -335,11 +335,6 @@ SET search_tsv = COALESCE(
|
||||
''::tsvector)
|
||||
FROM batch WHERE cm.id = batch.id;
|
||||
|
||||
-- name: ChatSearchQueryIsEmpty :one
|
||||
-- Reports whether search text tokenizes to an empty tsquery (e.g. '!!!').
|
||||
-- Used to reject input that would silently match nothing.
|
||||
SELECT numnode(websearch_to_tsquery('simple', @search::text)) = 0 AS is_empty;
|
||||
|
||||
-- name: GetChatByID :one
|
||||
SELECT *
|
||||
FROM chats_expanded
|
||||
|
||||
+1
-23
@@ -357,7 +357,7 @@ func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Chats
|
||||
// @Produce json
|
||||
// @Param q query string false "Search query. Supports `title:<substring>` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:<draft\|open\|merged\|closed>` as repeated or comma-separated values, `source:<created_by_me\|shared_with_me>`, `diff_url:<url>` (quote values containing colons), `pr:<number>` (exact PR number match), `repo:<owner/repo>` (case-insensitive substring match against git remote origin or URL), `pr_title:<text>` (case-insensitive PR title substring), `search:<text>` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr). Bare terms are not supported; use `title:<value>` or `search:<value>`."
|
||||
// @Param q query string false "Search query. Supports `title:<substring>` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:<draft\|open\|merged\|closed>` as repeated or comma-separated values, `source:<created_by_me\|shared_with_me>`, `diff_url:<url>` (quote values containing colons), `pr:<number>` (exact PR number match), `repo:<owner/repo>` (case-insensitive substring match against git remote origin or URL), `pr_title:<text>` (case-insensitive PR title substring), `search:<text>` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use `title:<value>` or `search:<value>`."
|
||||
// @Param label query string false "Filter by label as key:value. Repeat for multiple (AND logic)."
|
||||
// @Success 200 {array} codersdk.Chat
|
||||
// @Router /api/experimental/chats [get]
|
||||
@@ -381,28 +381,6 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reject text that tokenizes to nothing; it would silently match no rows.
|
||||
if searchParams.Search != "" {
|
||||
isEmpty, err := api.Database.ChatSearchQueryIsEmpty(ctx, searchParams.Search)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to validate search query.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if isEmpty {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid chat search query.",
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "search",
|
||||
Detail: "Search query contains no searchable words.",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var labelFilter pqtype.NullRawMessage
|
||||
if labelParams := r.URL.Query()["label"]; len(labelParams) > 0 {
|
||||
labelMap := make(map[string]string, len(labelParams))
|
||||
|
||||
@@ -2125,17 +2125,27 @@ func TestListChats_Search(t *testing.T) {
|
||||
require.NotContains(t, ids, noMatch.ID)
|
||||
})
|
||||
|
||||
t.Run("NoSearchableWordsReturns400", func(t *testing.T) {
|
||||
t.Run("NoSearchableWordsReturnsEmpty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, client, _, _, _ := setup(t)
|
||||
ctx, client, db, firstUser, modelConfig := setup(t)
|
||||
|
||||
_, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
|
||||
// "or" is a real lexeme (an operator only between operands), so
|
||||
// search:"or" matches the control chat; search:"!!!" has no lexemes and
|
||||
// matches nothing.
|
||||
control := createChat(t, db, firstUser, modelConfig.ID, "fix this or that")
|
||||
backfillSearchTsv(ctx, t, db)
|
||||
|
||||
chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{
|
||||
Query: `search:"or"`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, chatIDs(chats), control.ID)
|
||||
|
||||
chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{
|
||||
Query: `search:"!!!"`,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "search", sdkErr.Validations[0].Field)
|
||||
require.Contains(t, sdkErr.Validations[0].Detail, "no searchable words")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, chats)
|
||||
})
|
||||
|
||||
t.Run("ComposesWithRepoFilterAndArchivedDefault", func(t *testing.T) {
|
||||
|
||||
@@ -1276,6 +1276,55 @@ func TestSearchTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchChatsFrontendEmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// These query shapes must match the emitters in
|
||||
// site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/searchQuery.ts
|
||||
// and site/src/api/queries/chats.ts.
|
||||
testCases := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{name: "SearchSingleWord", query: `search:"fix"`},
|
||||
{name: "SearchMultipleWords", query: `search:"fix auth"`},
|
||||
{name: "SearchColon", query: `search:"fix:lint"`},
|
||||
{name: "SearchURL", query: `search:"http://example.com"`},
|
||||
{name: "SearchUnicode", query: `search:"日本語"`},
|
||||
{name: "SearchOperators", query: `search:"fix race OR deadlock -timeout"`},
|
||||
{name: "SearchPunctuationOnly", query: `search:"!!!"`},
|
||||
{name: "SearchOperatorWord", query: `search:"or"`},
|
||||
{name: "HasUnread", query: "has_unread:true"},
|
||||
{name: "Archived", query: "archived:true"},
|
||||
{name: "PRStatuses", query: "pr_status:open,merged"},
|
||||
{name: "DiffURL", query: `diff_url:"https://github.com/coder/coder/pull/1"`},
|
||||
{name: "FilterAndSearch", query: `has_unread:true search:"fix auth"`},
|
||||
{name: "SidebarDefault", query: "archived:false"},
|
||||
{name: "SidebarUnread", query: "archived:false has_unread:true"},
|
||||
{
|
||||
name: "SidebarFiltered",
|
||||
query: "archived:false pr_status:draft,closed source:created_by_me,shared_with_me",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, errs := searchquery.Chats(testCase.query)
|
||||
require.Empty(t, errs)
|
||||
})
|
||||
}
|
||||
|
||||
rejectedQueries := []string{"pr_status:banana", "has_unread:maybe"}
|
||||
for _, query := range rejectedQueries {
|
||||
t.Run("Rejects"+query, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, errs := searchquery.Chats(query)
|
||||
require.NotEmpty(t, errs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchChats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user