Skip to content

Commit 1516068

Browse files
committed
feat: improve search UX (#54)
* add text highlighting * pass on padding style * add tests and quote validation * apply validation throughout * wip * add more info on empty search * tweak * refactor for separation of concerns * tweaks --------- Co-authored-by: zx <[email protected]>
1 parent 50a4608 commit 1516068

31 files changed

+1044
-289
lines changed

packages/core/src/github/schema.validation.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,42 @@ export const repoUserInputSchema = z.object({
2424
)
2525
.transform((name) => name.toLowerCase()),
2626
});
27+
28+
// Shared error message
29+
export const INVALID_REPO_MESSAGE = "Please enter a valid GitHub repository";
30+
31+
// Utility function to extract owner and repo, with validation
32+
export const validateAndExtractGithubOwnerAndRepo = (
33+
input: string,
34+
ctx?: z.RefinementCtx,
35+
) => {
36+
// Normalize the input to handle both URL and owner/repo format
37+
const normalizedInput = input.includes("github.com")
38+
? new URL(
39+
input.startsWith("http") ? input : `https://${input}`,
40+
).pathname.slice(1)
41+
: input;
42+
// Split and filter out empty strings
43+
const parts = normalizedInput.split("/").filter(Boolean);
44+
// Validate we have exactly owner and repo
45+
if (parts.length !== 2) {
46+
if (ctx) {
47+
ctx.addIssue({
48+
code: z.ZodIssueCode.custom,
49+
message: INVALID_REPO_MESSAGE,
50+
});
51+
}
52+
return null;
53+
}
54+
const [owner, repo] = parts;
55+
// Validate against schema
56+
const result = repoUserInputSchema.safeParse({ owner, repo });
57+
if (!result.success) {
58+
if (ctx) {
59+
result.error.errors.forEach((err) => ctx.addIssue(err));
60+
}
61+
return null;
62+
}
63+
64+
return result.data;
65+
};

packages/core/src/repo.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const Repo = {
4040
.select({
4141
id: repos.id,
4242
isPrivate: repos.isPrivate,
43+
initStatus: repos.initStatus,
4344
})
4445
.from(repos)
4546
.where(and(eq(repos.ownerLogin, owner), eq(repos.name, name)));
@@ -52,6 +53,7 @@ export const Repo = {
5253
exists: true,
5354
id: result.id,
5455
isPrivate: result.isPrivate,
56+
initStatus: result.initStatus,
5557
} as const;
5658
},
5759

packages/core/src/semsearch/db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { usersToRepos } from "@/db/schema/entities/user-to-repo.sql";
1616
import { lower } from "@/db/utils/general";
1717
import { jsonAggBuildObjectFromJoin, jsonContains } from "@/db/utils/json";
1818

19-
import type { SearchParams } from "./schema";
19+
import type { SearchParams } from "./schema.output";
2020
import { parseSearchQuery } from "./util";
2121

2222
export function getBaseSelect() {

packages/core/src/semsearch/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import {
2222
calculateRecencyScore,
2323
calculateSimilarityScore,
2424
} from "./ranking";
25-
import type { SearchParams, SearchResult } from "./schema";
26-
import { parseSearchQuery } from "./util";
25+
import { getInputForEmbedding } from "./schema.input";
26+
import type { SearchParams, SearchResult } from "./schema.output";
2727

2828
export async function routeSearch(
2929
params: SearchParams,
@@ -58,12 +58,12 @@ async function getCountsAndEmbedding(
5858
db: DbClient,
5959
openai: OpenAIClient,
6060
) {
61-
const parsedSearchQuery = parseSearchQuery(params.query);
61+
const input = getInputForEmbedding(params.query);
6262
const [filteredIssueCount, embedding] = await Promise.all([
6363
getFilteredIssuesExactCount(params, db),
6464
createEmbedding(
6565
{
66-
input: parsedSearchQuery.remainingQuery ?? params.query,
66+
input: input ?? params.query,
6767
},
6868
openai,
6969
),
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { operatorQuoteSchema, searchQuerySchema } from "./schema.input";
4+
5+
describe("operatorQuoteSchema", () => {
6+
it("should pass when quoted operators have quotes", () => {
7+
const validQueries = [
8+
'title:"hello world"',
9+
'body:"some description"',
10+
'label:"bug"',
11+
'collection:"my collection"',
12+
// Mixed with unquoted operators
13+
'title:"hello" author:coder',
14+
'state:open body:"detailed explanation"',
15+
// Multiple quoted operators
16+
'title:"hello" body:"world"',
17+
// With other content
18+
'title:"hello world" some other text',
19+
];
20+
21+
validQueries.forEach((query) => {
22+
expect(() => operatorQuoteSchema.parse(query)).not.toThrow();
23+
});
24+
});
25+
26+
it("should fail when quoted operators lack quotes", () => {
27+
const invalidQueries = [
28+
"title:hello world",
29+
"body:some description",
30+
"label:bug",
31+
"collection:my collection",
32+
// Mixed cases
33+
'title:"valid" body:invalid',
34+
'title:invalid body:"valid"',
35+
// With other content
36+
"title:hello some other text",
37+
];
38+
39+
invalidQueries.forEach((query) => {
40+
expect(() => operatorQuoteSchema.parse(query)).toThrow(/requires quotes/);
41+
});
42+
});
43+
44+
it("should pass when unquoted operators are used without quotes", () => {
45+
const validQueries = [
46+
"author:coder",
47+
"state:open",
48+
"repo:semhub",
49+
"org:coder",
50+
// Mixed with quoted operators
51+
'author:coder title:"hello"',
52+
// Multiple unquoted operators
53+
"state:open author:coder",
54+
];
55+
56+
validQueries.forEach((query) => {
57+
expect(() => operatorQuoteSchema.parse(query)).not.toThrow();
58+
});
59+
});
60+
});
61+
62+
describe("searchQuerySchema", () => {
63+
describe("multiple operator validation", () => {
64+
it("should fail when multiple instances of unique operators are used", () => {
65+
const invalidQueries = [
66+
"state:open state:closed",
67+
"repo:a repo:b",
68+
"author:x author:y",
69+
"org:a org:b",
70+
];
71+
72+
invalidQueries.forEach((query) => {
73+
expect(() => searchQuerySchema.parse(query)).toThrow(/at most one/);
74+
});
75+
});
76+
});
77+
78+
describe("empty query validation", () => {
79+
it("should fail when no substantive query is provided", () => {
80+
const emptyQueries = [
81+
"",
82+
" ",
83+
"state:open", // only filter, no search content
84+
];
85+
86+
emptyQueries.forEach((query) => {
87+
expect(() => searchQuerySchema.parse(query)).toThrow(
88+
/substantive query/,
89+
);
90+
});
91+
});
92+
93+
it("should pass when substantive query is provided", () => {
94+
const validQueries = [
95+
"hello world",
96+
'title:"hello"',
97+
'body:"description"',
98+
"state:open hello",
99+
'"exact match"',
100+
];
101+
102+
validQueries.forEach((query) => {
103+
expect(() => searchQuerySchema.parse(query)).not.toThrow();
104+
});
105+
});
106+
});
107+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { z } from "zod";
2+
3+
import { SEARCH_OPERATORS } from "../constants/search.constant";
4+
import { parseSearchQuery } from "./util";
5+
6+
export const operatorQuoteSchema = z.string().superRefine((query, ctx) => {
7+
// Check for unquoted operators that require quotes
8+
SEARCH_OPERATORS.forEach(({ operator, enclosedInQuotes }) => {
9+
if (enclosedInQuotes) {
10+
const unquotedPattern = new RegExp(`${operator}:([^"\\s]+)(?=\\s|$)`);
11+
if (unquotedPattern.test(query)) {
12+
ctx.addIssue({
13+
code: z.ZodIssueCode.custom,
14+
message: `The ${operator} operator requires quotes (") around its value`,
15+
});
16+
}
17+
}
18+
});
19+
});
20+
21+
export const searchQuerySchema = z.string().superRefine((query, ctx) => {
22+
const input = getInputForEmbedding(query);
23+
if (!input) {
24+
ctx.addIssue({
25+
code: z.ZodIssueCode.custom,
26+
message: "Please provide a substantive query",
27+
});
28+
}
29+
30+
// Run operator quote validation
31+
const quoteValidation = operatorQuoteSchema.safeParse(query);
32+
if (!quoteValidation.success) {
33+
quoteValidation.error.issues.forEach((issue) => ctx.addIssue(issue));
34+
}
35+
36+
const { stateQueries, repoQueries, authorQueries, ownerQueries } =
37+
parseSearchQuery(query);
38+
39+
if (stateQueries.length > 1) {
40+
ctx.addIssue({
41+
code: z.ZodIssueCode.custom,
42+
message: "Please filter by at most one state",
43+
});
44+
}
45+
if (repoQueries.length > 1) {
46+
ctx.addIssue({
47+
code: z.ZodIssueCode.custom,
48+
message: "Please filter by at most one repo",
49+
});
50+
}
51+
if (repoQueries.length === 1 && ownerQueries.length === 0) {
52+
ctx.addIssue({
53+
code: z.ZodIssueCode.custom,
54+
message: "Please specify an org for the repo",
55+
});
56+
}
57+
if (authorQueries.length > 1) {
58+
ctx.addIssue({
59+
code: z.ZodIssueCode.custom,
60+
message: "Please filter by at most one author",
61+
});
62+
}
63+
if (ownerQueries.length > 1) {
64+
ctx.addIssue({
65+
code: z.ZodIssueCode.custom,
66+
message: "Please filter by at most one org",
67+
});
68+
}
69+
});
70+
71+
export function getInputForEmbedding(query: string) {
72+
const {
73+
remainingQuery,
74+
bodyQueries,
75+
substringQueries,
76+
titleQueries,
77+
labelQueries,
78+
} = parseSearchQuery(query);
79+
if (
80+
remainingQuery.length === 0 &&
81+
bodyQueries.length === 0 &&
82+
substringQueries.length === 0 &&
83+
titleQueries.length === 0
84+
) {
85+
// not enough to construct an embedding
86+
return null;
87+
}
88+
return [
89+
...titleQueries.map((title) => `title:${title}`),
90+
...labelQueries.map((label) => `labelled as ${label}`),
91+
...bodyQueries,
92+
...substringQueries,
93+
remainingQuery,
94+
].join(",");
95+
}

packages/core/src/semsearch/util.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,12 @@ export function modifyUserQuery(query: string) {
112112
// Normalize spaces - replace multiple spaces with single space
113113
return modifiedQuery.replace(/\s+/g, " ").trim();
114114
}
115+
export function extractOwnerAndRepo(query: string) {
116+
const { ownerQueries, repoQueries } = parseSearchQuery(query);
117+
const owner = ownerQueries[0];
118+
const repo = repoQueries[0];
119+
if (!owner || !repo) {
120+
return null;
121+
}
122+
return { owner, repo };
123+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type FieldApi } from "@tanstack/react-form";
2+
import { AlertCircleIcon } from "lucide-react";
3+
4+
interface ValidationErrorsProps {
5+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6+
field: FieldApi<any, any, any, any, any>;
7+
error: string | null;
8+
}
9+
10+
export function ValidationErrors({ field, error }: ValidationErrorsProps) {
11+
const validationError =
12+
field.state.meta.isTouched && field.state.meta.errors.length
13+
? field.state.meta.errors
14+
.filter((err: unknown): err is string => typeof err === "string")
15+
.join(", ")
16+
: null;
17+
18+
const errors = [validationError, error].filter(Boolean);
19+
const displayError = errors.length > 0 ? errors.join(", ") : null;
20+
21+
return displayError ? (
22+
<div className="flex items-center gap-2 text-sm text-red-500">
23+
<AlertCircleIcon className="size-4" />
24+
<span>{displayError}</span>
25+
</div>
26+
) : null;
27+
}

packages/web/src/components/embed/EmbedBadgeInput.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AlertCircleIcon, PlusIcon } from "lucide-react";
22
import { useState } from "react";
33

44
import { repoSchema } from "@/core/github/schema.rest";
5+
import { validateAndExtractGithubOwnerAndRepo } from "@/core/github/schema.validation";
56
import { useDebounce } from "@/hooks/useDebounce";
67
import {
78
Dialog,
@@ -12,15 +13,12 @@ import {
1213
} from "@/components/ui/dialog";
1314
import { Input } from "@/components/ui/input";
1415
import { ShineButton } from "@/components/ui/shine-button";
16+
import { githubRepoFormSchema } from "@/components/repos/form-schema";
1517
import {
1618
RepoPreview,
1719
RepoPreviewProps,
1820
RepoPreviewSkeleton,
1921
} from "@/components/repos/RepoPreview";
20-
import {
21-
githubRepoFormSchema,
22-
validateAndExtractGithubOwnerAndRepo,
23-
} from "@/components/repos/subscribe";
2422

2523
import {
2624
CopyButton,

0 commit comments

Comments
 (0)