fix(linear): match issue identifier (ENG-123) in search (#1340)
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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}}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ function FilterControls({
|
||||
<div className="relative flex-1 min-w-[280px]">
|
||||
<IconSearch className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search title or description"
|
||||
placeholder="Search by ID, title, or description"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
|
||||
Reference in New Issue
Block a user