From fc01fa37cb820b625c405cd9979d13122e499c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Thu, 11 Jun 2026 19:15:52 +0100 Subject: [PATCH] fix(linear): match issue identifier (ENG-123) in search (#1340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(linear): match issue identifier (ENG-123) in search Linear's `searchableContent` filter never matches the issue identifier (team key + number), so a query like `ENG-123` from the Linear view returned no results. When the query parses as a `TEAM-NUM` identifier, OR-combine the content match with `{team.key.eq, number.eq}` so the exact issue surfaces while other filters keep AND-joining. Lowercase inputs (`eng-123`) are normalized to the upper-case team key. Fixes #1339 Co-Authored-By: Claude Opus 4.7 * fix(linear): mention ID in search box placeholder Reflect the backend's new identifier-aware search by hinting at it in the search input on `/linear`. Co-Authored-By: Claude Opus 4.7 * docs(linear): explain why identifier search keeps both OR branches claude[bot] flagged the searchableContent branch in the identifier OR as functionally dead and the surrounding comment as a sentence fragment. The branch is intentional — it catches cross-references like "duplicate of ENG-123" pasted into another issue's title or description, which the exact team+number branch would miss. Rewrite the comment to explain that, fixing the fragment in the same change. * fix(linear): use canonical identifier form in searchableContent OR branch greptile flagged that the `searchableContent.contains` branch in the identifier OR was sent verbatim from user input. When the user typed `eng-123`, Linear's case-sensitive `contains` filter would miss cross-references written in the canonical form `ENG-123`. Normalise the contains value to the same upper-cased identifier we use for the exact `team+number` branch, and lock that behaviour in `TestBuildIssueFilter_IdentifierLowercase`. * fix(linear): use containsIgnoreCase for searchableContent @carlosflorencio flagged that canonicalising the identifier OR branch to upper case meant lowercase literal references (e.g. "see eng-123" pasted into a description) no longer matched. The previous round had swapped the other way at greptile's request to fix uppercase references. Both directions are right — the underlying issue is that Linear's `contains` filter is case-sensitive. Switch the whole `searchableContent` filter (identifier OR branch and the non-identifier free-text path) to `containsIgnoreCase`, so casing of the user input and of the indexed text no longer matters in either direction. Free-text search is now case-insensitive too, which is the intuitive default for a search box. --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- .../backend/internal/linear/graphql_client.go | 50 ++++++++- .../internal/linear/graphql_client_test.go | 105 ++++++++++++++++++ apps/web/app/linear/linear-page-client.tsx | 2 +- 3 files changed, 154 insertions(+), 3 deletions(-) diff --git a/apps/backend/internal/linear/graphql_client.go b/apps/backend/internal/linear/graphql_client.go index 76b2686a33..03753457b4 100644 --- a/apps/backend/internal/linear/graphql_client.go +++ b/apps/backend/internal/linear/graphql_client.go @@ -9,6 +9,8 @@ import ( "io" "math" "net/http" + "regexp" + "strconv" "strings" "time" ) @@ -504,6 +506,31 @@ func (c *GraphQLClient) SearchIssues(ctx context.Context, filter SearchFilter, p return out, nil } +// issueIdentifierRe matches a Linear-style ticket identifier like ENG-123. +// Team keys per Linear are uppercase alphanumerics + underscore, starting with +// a letter. +var issueIdentifierRe = regexp.MustCompile(`^([A-Z][A-Z0-9_]*)-(\d+)$`) + +// parseIssueIdentifier returns the team key and issue number when q looks like +// a Linear identifier (e.g. "ENG-123"). Input is trimmed and upper-cased so +// "eng-123" works too. ok=false means q is not an identifier and callers +// should treat it as a free-text query. +func parseIssueIdentifier(q string) (string, int, bool) { + q = strings.ToUpper(strings.TrimSpace(q)) + if q == "" { + return "", 0, false + } + m := issueIdentifierRe.FindStringSubmatch(q) + if m == nil { + return "", 0, false + } + n, err := strconv.Atoi(m[2]) + if err != nil { + return "", 0, false + } + return m[1], n, true +} + // buildIssueFilter translates our SearchFilter into Linear's IssueFilter input. // Empty fields are dropped so we don't send `{ "team": null }` and similar // (which Linear treats as a typed null and rejects). @@ -511,8 +538,27 @@ func buildIssueFilter(f SearchFilter) map[string]interface{} { out := map[string]interface{}{} if q := strings.TrimSpace(f.Query); q != "" { // Linear has no top-level free-text field, but `searchableContent` - // matches across title and description. - out["searchableContent"] = map[string]interface{}{"contains": q} + // matches across title and description. When the query looks like a + // ticket identifier (ENG-123) we OR in an exact team+number branch, + // because `searchableContent` never indexes the identifier itself — + // without the extra branch, searching by ID would return zero hits + // for the target ticket. We keep the `searchableContent` branch in + // the OR so cross-references like "duplicate of ENG-123" pasted into + // another issue's title or description still surface. + // `containsIgnoreCase` so identifier cross-references ("see ENG-123") + // match regardless of the user's input casing, and so free-text + // queries behave intuitively case-insensitively. + if teamKey, num, ok := parseIssueIdentifier(q); ok { + out["or"] = []map[string]interface{}{ + {"searchableContent": map[string]interface{}{"containsIgnoreCase": q}}, + { + "team": map[string]interface{}{"key": map[string]interface{}{"eq": teamKey}}, + "number": map[string]interface{}{"eq": num}, + }, + } + } else { + out["searchableContent"] = map[string]interface{}{"containsIgnoreCase": q} + } } if f.TeamKey != "" { out["team"] = map[string]interface{}{"key": map[string]interface{}{"eq": f.TeamKey}} diff --git a/apps/backend/internal/linear/graphql_client_test.go b/apps/backend/internal/linear/graphql_client_test.go index 8b59c060d5..f03a4d9ff5 100644 --- a/apps/backend/internal/linear/graphql_client_test.go +++ b/apps/backend/internal/linear/graphql_client_test.go @@ -302,3 +302,108 @@ func TestBuildIssueFilter_PriorityZero(t *testing.T) { t.Errorf("priorities=[0] should map to in:[0] (No priority), got %+v", prio) } } + +func TestParseIssueIdentifier(t *testing.T) { + cases := []struct { + in string + wantKey string + wantNum int + wantOK bool + }{ + {"ENG-123", "ENG", 123, true}, + {"eng-123", "ENG", 123, true}, + {"ENG_1-2", "ENG_1", 2, true}, + {" eng-1 ", "ENG", 1, true}, + {"ENG-007", "ENG", 7, true}, + {"", "", 0, false}, + {"ENG-", "", 0, false}, + {"-123", "", 0, false}, + {"ENG-abc", "", 0, false}, + {"ENG-12-34", "", 0, false}, + {"1-2", "", 0, false}, + {"auth bug", "", 0, false}, + } + for _, tc := range cases { + gotKey, gotNum, gotOK := parseIssueIdentifier(tc.in) + if gotOK != tc.wantOK || gotKey != tc.wantKey || gotNum != tc.wantNum { + t.Errorf("parseIssueIdentifier(%q) = (%q,%d,%v), want (%q,%d,%v)", + tc.in, gotKey, gotNum, gotOK, tc.wantKey, tc.wantNum, tc.wantOK) + } + } +} + +func TestBuildIssueFilter_IdentifierQuery(t *testing.T) { + got := buildIssueFilter(SearchFilter{Query: "ENG-123"}) + if _, ok := got["searchableContent"]; ok { + t.Error("identifier query should not set top-level searchableContent") + } + or, ok := got["or"].([]map[string]interface{}) + if !ok || len(or) != 2 { + t.Fatalf("expected or with 2 branches, got %+v", got["or"]) + } + sc, _ := or[0]["searchableContent"].(map[string]interface{}) + if sc["containsIgnoreCase"] != "ENG-123" { + t.Errorf("first OR branch should match searchableContent.containsIgnoreCase on raw query, got %+v", or[0]) + } + team, _ := or[1]["team"].(map[string]interface{}) + teamKey, _ := team["key"].(map[string]interface{}) + if teamKey["eq"] != "ENG" { + t.Errorf("second OR branch team.key.eq mismatch: %+v", or[1]) + } + num, _ := or[1]["number"].(map[string]interface{}) + if num["eq"] != 123 { + t.Errorf("second OR branch number.eq mismatch: %+v", or[1]) + } +} + +func TestBuildIssueFilter_IdentifierLowercase(t *testing.T) { + got := buildIssueFilter(SearchFilter{Query: "eng-123"}) + or, ok := got["or"].([]map[string]interface{}) + if !ok || len(or) != 2 { + t.Fatalf("expected or with 2 branches, got %+v", got["or"]) + } + sc, _ := or[0]["searchableContent"].(map[string]interface{}) + if sc["containsIgnoreCase"] != "eng-123" { + t.Errorf("searchableContent branch should preserve raw query under containsIgnoreCase, got %+v", sc) + } + team, _ := or[1]["team"].(map[string]interface{}) + teamKey, _ := team["key"].(map[string]interface{}) + if teamKey["eq"] != "ENG" { + t.Errorf("expected upper-cased team key ENG, got %+v", teamKey) + } +} + +func TestBuildIssueFilter_IdentifierComposesWithFilters(t *testing.T) { + got := buildIssueFilter(SearchFilter{ + Query: "ENG-123", + TeamKey: "ENG", + Assigned: "me", + StateIDs: []string{"s1"}, + }) + if _, ok := got["or"]; !ok { + t.Error("expected or branch alongside other filters") + } + if _, ok := got["team"]; !ok { + t.Error("expected top-level team filter to remain AND-joined") + } + if _, ok := got["assignee"]; !ok { + t.Error("expected top-level assignee filter to remain AND-joined") + } + if _, ok := got["state"]; !ok { + t.Error("expected top-level state filter to remain AND-joined") + } +} + +func TestBuildIssueFilter_NonIdentifierUnchanged(t *testing.T) { + got := buildIssueFilter(SearchFilter{Query: "auth bug"}) + sc, ok := got["searchableContent"].(map[string]interface{}) + if !ok { + t.Fatalf("expected top-level searchableContent for non-identifier query, got %+v", got) + } + if sc["containsIgnoreCase"] != "auth bug" { + t.Errorf("searchableContent.containsIgnoreCase mismatch: %+v", sc) + } + if _, ok := got["or"]; ok { + t.Error("non-identifier query must not produce an or branch") + } +} diff --git a/apps/web/app/linear/linear-page-client.tsx b/apps/web/app/linear/linear-page-client.tsx index ab00928540..5daee89593 100644 --- a/apps/web/app/linear/linear-page-client.tsx +++ b/apps/web/app/linear/linear-page-client.tsx @@ -280,7 +280,7 @@ function FilterControls({
setQuery(e.target.value)} className="pl-8"