mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add full text search over chat messages (#27126)
Closes CODAGT-721 Closes CODAGT-722 Closes CODAGT-723 Closes CODAGT-724 Closes CODAGT-725 This PR adds the database and API pieces necessary to support full-text chat message search. - Adds required chat schema for full-text search - Adds dbpurge job to populate search_tsv in the background - Adds `search` parameter to GetChats query - Adds `search` filter to `searchquery.Chats` - Wires chat search filter into chats API > Implemented by Coder Agents, reviewed and tested by a human.
This commit is contained in:
@@ -519,6 +519,8 @@ func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UU
|
||||
// ownership scope; created_by_me returns only chats the caller owns,
|
||||
// shared_with_me returns only chats shared with the caller, all returns
|
||||
// both)
|
||||
// - search: full-text search over chat content; mutually exclusive
|
||||
// with title, pr_title, and pr
|
||||
func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
|
||||
filter := database.GetChatsParams{
|
||||
// Default to hiding archived chats and chats not owned by the caller.
|
||||
@@ -606,6 +608,30 @@ func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
|
||||
}
|
||||
}
|
||||
|
||||
if values.Has("search") {
|
||||
parser.RequiredNotEmpty("search")
|
||||
if search := parser.String(values, "", "search"); search != "" {
|
||||
var conflicts []string
|
||||
if filter.TitleQuery != "" {
|
||||
conflicts = append(conflicts, `"title"`)
|
||||
}
|
||||
if filter.PrTitleQuery != "" {
|
||||
conflicts = append(conflicts, `"pr_title"`)
|
||||
}
|
||||
if filter.PrNumber != 0 {
|
||||
conflicts = append(conflicts, `"pr"`)
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
||||
Field: "search",
|
||||
Detail: fmt.Sprintf(`"search" cannot be combined with %s`, strings.Join(conflicts, ", ")),
|
||||
})
|
||||
} else {
|
||||
filter.Search = search
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parser.ErrorExcessParams(values)
|
||||
return filter, parser.Errors
|
||||
}
|
||||
|
||||
@@ -1238,6 +1238,8 @@ func TestSearchChats(t *testing.T) {
|
||||
Query string
|
||||
Expected database.GetChatsParams
|
||||
ExpectedErrorContains string
|
||||
// When non-zero, asserts the exact number of validation errors.
|
||||
ExpectedErrorCount int
|
||||
}{
|
||||
{
|
||||
Name: "Empty",
|
||||
@@ -1597,6 +1599,90 @@ func TestSearchChats(t *testing.T) {
|
||||
Query: "some random words",
|
||||
ExpectedErrorContains: `unsupported search term: "some random words"`,
|
||||
},
|
||||
{
|
||||
Name: "Search",
|
||||
Query: "search:foo",
|
||||
Expected: database.GetChatsParams{
|
||||
Archived: sql.NullBool{Bool: false, Valid: true},
|
||||
OwnedOnly: true,
|
||||
Search: "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SearchQuoted",
|
||||
Query: `search:"foo bar"`,
|
||||
Expected: database.GetChatsParams{
|
||||
Archived: sql.NullBool{Bool: false, Valid: true},
|
||||
OwnedOnly: true,
|
||||
Search: "foo bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SearchWithStructuralFilters",
|
||||
Query: `repo:coder/coder archived:true search:"foo bar"`,
|
||||
Expected: database.GetChatsParams{
|
||||
Archived: sql.NullBool{Bool: true, Valid: true},
|
||||
OwnedOnly: true,
|
||||
RepoQuery: "coder/coder",
|
||||
Search: "foo bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SearchWithAllStructuralFilters",
|
||||
Query: `search:foo archived:true repo:coder/coder diff_url:"https://github.com/coder/coder/pull/1" has_unread:true pr_status:open source:created_by_me`,
|
||||
Expected: database.GetChatsParams{
|
||||
Archived: sql.NullBool{Bool: true, Valid: true},
|
||||
OwnedOnly: true,
|
||||
RepoQuery: "coder/coder",
|
||||
DiffURL: sql.NullString{String: "https://github.com/coder/coder/pull/1", Valid: true},
|
||||
HasUnread: sql.NullBool{Bool: true, Valid: true},
|
||||
PullRequestStatuses: []string{"open"},
|
||||
Search: "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SearchRepeated",
|
||||
Query: "search:foo search:bar",
|
||||
ExpectedErrorContains: `search: Query param "search" provided more than once`,
|
||||
ExpectedErrorCount: 1,
|
||||
},
|
||||
{
|
||||
Name: "SearchConflictsWithTitle",
|
||||
Query: "search:foo title:bar",
|
||||
ExpectedErrorContains: `search: "search" cannot be combined with "title"`,
|
||||
},
|
||||
{
|
||||
Name: "SearchConflictsWithPrTitle",
|
||||
Query: "search:foo pr_title:bar",
|
||||
ExpectedErrorContains: `search: "search" cannot be combined with "pr_title"`,
|
||||
},
|
||||
{
|
||||
Name: "SearchConflictsWithPr",
|
||||
Query: "search:foo pr:12",
|
||||
ExpectedErrorContains: `search: "search" cannot be combined with "pr"`,
|
||||
},
|
||||
{
|
||||
Name: "SearchConflictsOrderIndependent",
|
||||
Query: "title:bar search:foo",
|
||||
ExpectedErrorContains: `search: "search" cannot be combined with "title"`,
|
||||
},
|
||||
{
|
||||
Name: "SearchConflictsWithMultiple",
|
||||
Query: "search:foo title:bar pr:12",
|
||||
ExpectedErrorContains: `search: "search" cannot be combined with "title", "pr"`,
|
||||
},
|
||||
{
|
||||
// The tokenizer rejects trailing colons before search validation runs.
|
||||
Name: "SearchBareKey",
|
||||
Query: "search:",
|
||||
ExpectedErrorContains: "cannot start or end with ':'",
|
||||
},
|
||||
{
|
||||
Name: "SearchEmptyQuoted",
|
||||
Query: `search:""`,
|
||||
ExpectedErrorContains: `search: Query param "search" is required and cannot be empty`,
|
||||
ExpectedErrorCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range testCases {
|
||||
@@ -1606,6 +1692,9 @@ func TestSearchChats(t *testing.T) {
|
||||
values, errs := searchquery.Chats(c.Query)
|
||||
if c.ExpectedErrorContains != "" {
|
||||
require.True(t, len(errs) > 0, "expect some errors")
|
||||
if c.ExpectedErrorCount > 0 {
|
||||
require.Len(t, errs, c.ExpectedErrorCount, "expected exact error count")
|
||||
}
|
||||
var s strings.Builder
|
||||
for _, err := range errs {
|
||||
_, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail))
|
||||
|
||||
Reference in New Issue
Block a user