From 15ada66e14e04286f9d8dddab6e268aa56d7c6f2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 22 May 2026 13:58:07 +0100 Subject: [PATCH] feat: add pr, repo, pr_title chat search filters (#25569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relates to CODAGT-432 Adds three new search filters to the chat list endpoint (`GET /api/experimental/chats/`): - `pr:` - exact PR number match - `repo:` - substring match against git remote origin or URL - `pr_title:` - case-insensitive PR title substring match Includes SQL filter clauses (EXISTS against `chat_diff_statuses`), parser with validation, handler wiring, unit tests, swagger annotation update, and a new search syntax documentation page. > 🤖 Generated with [Coder Agents](https://coder.com/agents) --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/database/modelqueries.go | 3 + coderd/database/querier_test.go | 69 +++++++++++++++++++++- coderd/database/queries.sql.go | 43 +++++++++++++- coderd/database/queries/chats.sql | 33 +++++++++++ coderd/exp_chats.go | 5 +- coderd/searchquery/search.go | 19 ++++++ coderd/searchquery/search_test.go | 49 +++++++++++++++ docs/ai-coder/agents/chat-search-syntax.md | 59 ++++++++++++++++++ docs/manifest.json | 6 ++ docs/reference/api/chats.md | 8 +-- 12 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 docs/ai-coder/agents/chat-search-syntax.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4dc64c8ff0..d7e4b6e2b8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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, and diff_url:\u003curl\u003e (quote URLs). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.", + "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, 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). 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 c821b8530d..d28f8bb476 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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, and diff_url:\u003curl\u003e (quote URLs). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.", + "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, 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). 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 2b6eb5297b..f33c047c94 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -778,6 +778,9 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, arg.TitleQuery, arg.HasUnread, pq.Array(arg.PullRequestStatuses), + arg.PrNumber, + arg.RepoQuery, + arg.PrTitleQuery, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c874ebcbad..f181e2e94b 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -14118,6 +14118,37 @@ func TestGetChatsFilter(t *testing.T) { require.NoError(t, err) } + linkPRFull := func(chatID uuid.UUID, url, state string, draft bool, prNumber int32, gitRemoteOrigin string, prTitle string) { + t.Helper() + now := time.Now() + // First set the git remote origin via the reference upsert. + if gitRemoteOrigin != "" { + _, err := store.UpsertChatDiffStatusReference(ctx, database.UpsertChatDiffStatusReferenceParams{ + ChatID: chatID, + Url: sql.NullString{String: url, Valid: url != ""}, + GitBranch: "main", + GitRemoteOrigin: gitRemoteOrigin, + StaleAt: now.Add(time.Hour), + }) + require.NoError(t, err) + } + // Then set PR metadata via the status upsert. + _, err := store.UpsertChatDiffStatus(ctx, database.UpsertChatDiffStatusParams{ + ChatID: chatID, + Url: sql.NullString{String: url, Valid: url != ""}, + PullRequestState: sql.NullString{String: state, Valid: state != ""}, + PullRequestTitle: prTitle, + PullRequestDraft: draft, + PrNumber: sql.NullInt32{Int32: prNumber, Valid: prNumber > 0}, + 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{ @@ -14199,12 +14230,25 @@ func TestGetChatsFilter(t *testing.T) { linkPR(childWithDraftPR.ID, "https://github.com/coder/coder/pull/1005", "open", true) makeUnread(childWithDraftPR.ID) + // Chats with specific PR numbers and repos for new filter tests. + // Use "acme/widget" and "acme/other-repo" origins to avoid overlapping + // with the "coder/coder" URLs in the earlier PR fixtures. + prNumberChat := createRoot("pr number 42 chat") + linkPRFull(prNumberChat.ID, "https://github.com/acme/widget/pull/42", "open", false, 42, "https://github.com/acme/widget.git", "Fix authentication bug") + + repoChat := createRoot("repo filter chat") + linkPRFull(repoChat.ID, "https://github.com/acme/other-repo/pull/7", "merged", false, 7, "https://github.com/acme/other-repo.git", "Add feature X") + + prTitleChat := createRoot("pr title filter chat") + linkPRFull(prTitleChat.ID, "https://github.com/acme/widget/pull/99", "open", false, 99, "https://github.com/acme/widget.git", "Deploy new dashboard") + // 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, + prNumberChat.ID, repoChat.ID, prTitleChat.ID, } // --- test cases --- @@ -14228,15 +14272,32 @@ func TestGetChatsFilter(t *testing.T) { // 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/Open", database.GetChatsParams{PullRequestStatuses: []string{"open"}}, []uuid.UUID{openPR.ID, prNumberChat.ID, prTitleChat.ID}}, + {"PRStatus/Merged", database.GetChatsParams{PullRequestStatuses: []string{"merged"}}, []uuid.UUID{mergedPR.ID, repoChat.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}}, + {"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, prNumberChat.ID, repoChat.ID, prTitleChat.ID}}, + + // PR number filter. + {"PRNumber/ExactMatch", database.GetChatsParams{PrNumber: 42}, []uuid.UUID{prNumberChat.ID}}, + {"PRNumber/NoMatch", database.GetChatsParams{PrNumber: 999}, nil}, + {"PRNumber/ZeroIsNoOp", database.GetChatsParams{PrNumber: 0}, allRootIDs}, + + // Repo filter. + {"Repo/SubstringMatch", database.GetChatsParams{RepoQuery: "acme/widget"}, []uuid.UUID{prNumberChat.ID, prTitleChat.ID}}, + {"Repo/DifferentRepo", database.GetChatsParams{RepoQuery: "acme/other-repo"}, []uuid.UUID{repoChat.ID}}, + {"Repo/NoMatch", database.GetChatsParams{RepoQuery: "nonexistent/repo"}, nil}, + {"Repo/CaseInsensitive", database.GetChatsParams{RepoQuery: "ACME/WIDGET"}, []uuid.UUID{prNumberChat.ID, prTitleChat.ID}}, + {"Repo/MatchesViaURL", database.GetChatsParams{RepoQuery: "coder/coder"}, []uuid.UUID{draftPR.ID, openPR.ID, mergedPR.ID, closedPR.ID}}, + + // PR title filter. + {"PRTitle/SubstringMatch", database.GetChatsParams{PrTitleQuery: "auth"}, []uuid.UUID{prNumberChat.ID}}, + {"PRTitle/CaseInsensitive", database.GetChatsParams{PrTitleQuery: "DEPLOY"}, []uuid.UUID{prTitleChat.ID}}, + {"PRTitle/NoMatch", database.GetChatsParams{PrTitleQuery: "nonexistent title"}, nil}, // Composed filters. {"Composed/TitleAndPRStatus", database.GetChatsParams{TitleQuery: "draft", PullRequestStatuses: []string{"draft"}}, []uuid.UUID{draftPR.ID}}, @@ -14244,6 +14305,8 @@ func TestGetChatsFilter(t *testing.T) { {"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}}, + {"Composed/PRNumberAndStatus", database.GetChatsParams{PrNumber: 42, PullRequestStatuses: []string{"closed"}}, nil}, + {"Composed/RepoAndPRTitle", database.GetChatsParams{RepoQuery: "acme/widget", PrTitleQuery: "auth"}, []uuid.UUID{prNumberChat.ID}}, } for _, tt := range tests { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 08d6e33d1d..58f3bd5fc7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7638,6 +7638,39 @@ WHERE ) ELSE true END + -- Filter by PR number (exact match on chat's diff status). + AND CASE + WHEN $11::int != 0 THEN EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND cds.pr_number = $11 + ) + ELSE true + END + -- Filter by repository (substring match on remote origin or PR URL). + AND CASE + WHEN $12::text != '' THEN EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND ( + cds.git_remote_origin ILIKE '%' || $12 || '%' + OR cds.url ILIKE '%' || $12 || '%' + ) + ) + ELSE true + END + -- Filter by pull request title (case-insensitive substring). + AND CASE + WHEN $13::text != '' THEN EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND cds.pull_request_title ILIKE '%' || $13 || '%' + ) + 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 @@ -7654,11 +7687,11 @@ ORDER BY -chats_expanded.pin_order DESC, chats_expanded.updated_at DESC, chats_expanded.id DESC -OFFSET $11 +OFFSET $14 LIMIT -- The chat list is unbounded and expected to grow large. -- Default to 50 to prevent accidental excessively large queries. - COALESCE(NULLIF($12 :: int, 0), 50) + COALESCE(NULLIF($15 :: int, 0), 50) ` type GetChatsParams struct { @@ -7672,6 +7705,9 @@ type GetChatsParams struct { 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"` + PrNumber int32 `db:"pr_number" json:"pr_number"` + RepoQuery string `db:"repo_query" json:"repo_query"` + PrTitleQuery string `db:"pr_title_query" json:"pr_title_query"` OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } @@ -7693,6 +7729,9 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha arg.TitleQuery, arg.HasUnread, pq.Array(arg.PullRequestStatuses), + arg.PrNumber, + arg.RepoQuery, + arg.PrTitleQuery, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index a52caf8bab..963eec817b 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -569,6 +569,39 @@ WHERE ) ELSE true END + -- Filter by PR number (exact match on chat's diff status). + AND CASE + WHEN @pr_number::int != 0 THEN EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND cds.pr_number = @pr_number + ) + ELSE true + END + -- Filter by repository (substring match on remote origin or PR URL). + AND CASE + WHEN @repo_query::text != '' THEN EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND ( + cds.git_remote_origin ILIKE '%' || @repo_query || '%' + OR cds.url ILIKE '%' || @repo_query || '%' + ) + ) + ELSE true + END + -- Filter by pull request title (case-insensitive substring). + AND CASE + WHEN @pr_title_query::text != '' THEN EXISTS ( + SELECT 1 + FROM chat_diff_statuses cds + WHERE cds.chat_id = chats_expanded.id + AND cds.pull_request_title ILIKE '%' || @pr_title_query || '%' + ) + 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 2fba5db055..01b1ae386f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -311,7 +311,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: (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 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, diff_url: (quote values containing colons), pr: (exact PR number match), repo: (case-insensitive substring match against git remote origin or URL), pr_title: (case-insensitive PR title substring). 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] @@ -372,6 +372,9 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { TitleQuery: searchParams.TitleQuery, HasUnread: searchParams.HasUnread, PullRequestStatuses: searchParams.PullRequestStatuses, + PrNumber: searchParams.PrNumber, + RepoQuery: searchParams.RepoQuery, + PrTitleQuery: searchParams.PrTitleQuery, // #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/searchquery/search.go b/coderd/searchquery/search.go index 4e42e3bd1c..1e8c69d15a 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "net/url" + "strconv" "strings" "time" @@ -553,6 +554,9 @@ func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UU // - 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") +// - pr: positive integer (exact PR number match) +// - repo: string (case-insensitive substring match against git remote origin or URL) +// - pr_title: string (case-insensitive PR title substring match) func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) { filter := database.GetChatsParams{ // Default to hiding archived chats. @@ -598,6 +602,21 @@ func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) { } filter.TitleQuery = parser.String(values, "", "title") + filter.PrTitleQuery = parser.String(values, "", "pr_title") + filter.RepoQuery = parser.String(values, "", "repo") + + // pr: requires a positive integer. + if prStr := parser.String(values, "", "pr"); prStr != "" { + n, err := strconv.ParseInt(prStr, 10, 32) + if err != nil || n <= 0 { + parser.Errors = append(parser.Errors, codersdk.ValidationError{ + Field: "pr", + Detail: fmt.Sprintf("%q is not a valid positive integer", prStr), + }) + } else { + filter.PrNumber = int32(n) + } + } parser.ErrorExcessParams(values) return filter, parser.Errors diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index c1471afe91..5081eb8cd2 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1459,6 +1459,55 @@ func TestSearchChats(t *testing.T) { DiffURL: sql.NullString{String: "https://github.com/coder/coder/pull/456", Valid: true}, }, }, + { + Name: "PrNumber", + Query: "pr:42", + Expected: database.GetChatsParams{ + Archived: sql.NullBool{Bool: false, Valid: true}, + PrNumber: 42, + }, + }, + { + Name: "PrNumberInvalid", + Query: "pr:abc", + ExpectedErrorContains: "pr", + }, + { + Name: "PrNumberZero", + Query: "pr:0", + ExpectedErrorContains: "pr", + }, + { + Name: "PrNumberNegative", + Query: "pr:-1", + ExpectedErrorContains: "pr", + }, + { + Name: "RepoQuery", + Query: "repo:coder/coder", + Expected: database.GetChatsParams{ + Archived: sql.NullBool{Bool: false, Valid: true}, + RepoQuery: "coder/coder", + }, + }, + { + Name: "PrTitleQuery", + Query: `pr_title:"fix auth bug"`, + Expected: database.GetChatsParams{ + Archived: sql.NullBool{Bool: false, Valid: true}, + PrTitleQuery: "fix auth bug", + }, + }, + { + Name: "CombinedPRRepoTitle", + Query: "pr:99 repo:coder/coder pr_title:deploy", + Expected: database.GetChatsParams{ + Archived: sql.NullBool{Bool: false, Valid: true}, + PrNumber: 99, + RepoQuery: "coder/coder", + PrTitleQuery: "deploy", + }, + }, { Name: "BareTermsRejected", Query: "some random words", diff --git a/docs/ai-coder/agents/chat-search-syntax.md b/docs/ai-coder/agents/chat-search-syntax.md new file mode 100644 index 0000000000..4551c2fd25 --- /dev/null +++ b/docs/ai-coder/agents/chat-search-syntax.md @@ -0,0 +1,59 @@ +# Conversation Search Syntax + +The chat list endpoint accepts a `q` query parameter for filtering +conversations. All filters use `key:value` syntax. Bare search terms +are rejected; use `title:` for title filtering. + +## Filters + +| Key | Values | Description | +|--------------|-------------------------------------|----------------------------------------------------------------------------------------------------| +| `title` | substring | Case-insensitive substring match. Quote multi-word values. | +| `archived` | `true`, `false` | Filter by archived state. Default: `false`. | +| `has_unread` | `true`, `false` | Conversations with unread assistant messages. | +| `pr_status` | `draft`, `open`, `merged`, `closed` | Linked pull request state. Comma-separated for OR. | +| `diff_url` | URL | Match by associated diff URL. Quote values containing colons. | +| `pr` | positive integer | Exact PR number match. | +| `repo` | substring | Case-insensitive substring match against git remote origin or URL. Quote values containing colons. | +| `pr_title` | substring | Case-insensitive PR title substring match. Quote multi-word values. | + +Multiple filters in one query combine with AND logic. + +## Examples + +```sh +# Title substring (case-insensitive) +?q=title:deploy + +# Multi-word title (URL-encode the space or use +) +?q=title:my+project + +# Unread conversations +?q=has_unread:true + +# Conversations with open or draft PRs +?q=pr_status:open,draft + +# Filter by diff URL (quote values containing colons) +?q=diff_url:"https://github.com/coder/coder/pull/123" + +# Combine filters +?q=title:refactor+has_unread:true+pr_status:merged + +# Conversations linked to PR #42 +?q=pr:42 + +# Conversations for a specific repository +?q=repo:coder/coder + +# Conversations with a specific PR title +?q=pr_title:"fix auth bug" +``` + +## Notes + +- `title:`, `repo:`, and `pr_title:` use ILIKE matching. `%` and `_` act as wildcards. +- `pr_status:draft` means the PR is open **and** marked as a draft. + `pr_status:open` means the PR is open and not a draft. +- Conversations without a linked diff status are excluded when `pr_status`, `pr`, `repo`, or `pr_title` is set. The `repo:` filter also matches chats tracking a branch with no PR. +- Unrecognized keys or bare terms return HTTP 400 with a validation error. diff --git a/docs/manifest.json b/docs/manifest.json index b781df758b..9d8fb71f8e 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -995,6 +995,12 @@ "path": "./ai-coder/agents/getting-started.md", "state": ["beta"] }, + { + "title": "Search Syntax", + "description": "Filter conversations by title, status, and linked pull requests", + "path": "./ai-coder/agents/chat-search-syntax.md", + "state": ["beta"] + }, { "title": "Architecture", "description": "How the agent in the control plane communicates with workspaces", diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index c69ab0ccb4..758f6641f5 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 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). | +| 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, diff_url: (quote values containing colons), pr: (exact PR number match), repo: (case-insensitive substring match against git remote origin or URL), pr_title: (case-insensitive PR title substring). 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