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:
Cian Johnston
2026-07-16 15:21:57 +01:00
committed by GitHub
parent 9862f10484
commit f7481c5d08
26 changed files with 1756 additions and 35 deletions
+26
View File
@@ -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
}
+89
View File
@@ -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))