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:
Cian Johnston
2026-05-21 10:18:55 +01:00
committed by GitHub
co-authored by Danielle Maywood Jaayden Halko
parent 46e93e6325
commit b7525a9b40
11 changed files with 680 additions and 25 deletions
+19 -1
View File
@@ -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
}
+128
View File
@@ -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 {