Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

issue-query.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

issue-query.test.tsBlame473 lines · 1 contributor
831a117Claude1/**
2 * Block J23 — Issue/PR search query DSL. Pure parser + matcher tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 tokenise,
8 parseIssueQuery,
9 matchIssue,
10 sortIssues,
11 applyQuery,
12 formatIssueQuery,
13 DEFAULT_SORT,
14 __internal,
15 type IssueQuery,
16 type QueryableIssue,
17} from "../lib/issue-query";
18
19function mkIssue(overrides: Partial<QueryableIssue> = {}): QueryableIssue {
20 return {
21 title: "Hello world",
22 body: "Body text",
23 state: "open",
24 authorName: "alice",
25 labelNames: [],
26 milestoneTitle: null,
27 createdAt: new Date("2025-01-01T00:00:00Z"),
28 updatedAt: new Date("2025-01-02T00:00:00Z"),
29 commentCount: 0,
30 ...overrides,
31 };
32}
33
34describe("issue-query — tokenise", () => {
35 it("splits on whitespace", () => {
36 expect(tokenise("a b c")).toEqual(["a", "b", "c"]);
37 });
38 it("collapses multiple whitespace", () => {
39 expect(tokenise("a b\t\tc")).toEqual(["a", "b", "c"]);
40 });
41 it("respects double-quoted spans", () => {
42 expect(tokenise('foo "bar baz" qux')).toEqual(["foo", "bar baz", "qux"]);
43 });
44 it("attaches quoted value to a key:", () => {
45 expect(tokenise('label:"help wanted"')).toEqual(["label:help wanted"]);
46 });
47 it("returns [] for empty input", () => {
48 expect(tokenise("")).toEqual([]);
49 expect(tokenise(" ")).toEqual([]);
50 });
51 it("tolerates trailing unterminated quote", () => {
52 // Missing closing quote — buf flushes at EOF.
53 expect(tokenise('foo "bar')).toEqual(["foo", "bar"]);
54 });
55});
56
57describe("issue-query — parseIssueQuery", () => {
58 it("returns default shape for empty input", () => {
59 const q = parseIssueQuery("");
60 expect(q.text).toBe("");
61 expect(q.labels).toEqual([]);
62 expect(q.excludeLabels).toEqual([]);
63 expect(q.noLabel).toBe(false);
64 expect(q.sort).toBe(DEFAULT_SORT);
65 expect(q.is).toBeUndefined();
66 expect(q.author).toBeUndefined();
67 expect(q.milestone).toBeUndefined();
68 });
69
70 it("returns default for null/undefined", () => {
71 expect(parseIssueQuery(null).text).toBe("");
72 expect(parseIssueQuery(undefined).text).toBe("");
73 });
74
75 it("returns default for non-string input", () => {
76 expect(parseIssueQuery(123 as unknown as string).text).toBe("");
77 });
78
79 it("parses is:open and is:closed", () => {
80 expect(parseIssueQuery("is:open").is).toBe("open");
81 expect(parseIssueQuery("is:closed").is).toBe("closed");
82 });
83
84 it("ignores bogus is: values", () => {
85 expect(parseIssueQuery("is:draft").is).toBeUndefined();
86 });
87
88 it("parses author:", () => {
89 expect(parseIssueQuery("author:alice").author).toBe("alice");
90 });
91
92 it("parses multiple label: (AND)", () => {
93 const q = parseIssueQuery("label:bug label:frontend");
94 expect(q.labels).toEqual(["bug", "frontend"]);
95 });
96
97 it("parses -label: as excludeLabels", () => {
98 const q = parseIssueQuery("-label:wontfix -label:duplicate");
99 expect(q.excludeLabels).toEqual(["wontfix", "duplicate"]);
100 });
101
102 it("parses no:label", () => {
103 expect(parseIssueQuery("no:label").noLabel).toBe(true);
104 });
105
106 it("ignores no: with other values", () => {
107 expect(parseIssueQuery("no:milestone").noLabel).toBe(false);
108 });
109
110 it("parses milestone: with quotes", () => {
111 const q = parseIssueQuery('milestone:"v1.0 rc"');
112 expect(q.milestone).toBe("v1.0 rc");
113 });
114
115 it("accepts allow-listed sort values", () => {
116 expect(parseIssueQuery("sort:created-desc").sort).toBe("created-desc");
117 expect(parseIssueQuery("sort:created-asc").sort).toBe("created-asc");
118 expect(parseIssueQuery("sort:updated-desc").sort).toBe("updated-desc");
119 expect(parseIssueQuery("sort:updated-asc").sort).toBe("updated-asc");
120 expect(parseIssueQuery("sort:comments-desc").sort).toBe("comments-desc");
121 });
122
123 it("falls back to default sort for unknown sort:", () => {
124 expect(parseIssueQuery("sort:garbage").sort).toBe(DEFAULT_SORT);
125 });
126
127 it("joins unmatched tokens into text", () => {
128 const q = parseIssueQuery('race condition');
129 expect(q.text).toBe("race condition");
130 });
131
132 it("treats unknown qualifiers as text", () => {
133 const q = parseIssueQuery("weird:thing hello");
134 expect(q.text).toContain("weird:thing");
135 expect(q.text).toContain("hello");
136 });
137
138 it("is case-insensitive on qualifier keys", () => {
139 expect(parseIssueQuery("IS:open").is).toBe("open");
140 expect(parseIssueQuery("Author:bob").author).toBe("bob");
141 });
142
143 it("supports quoted text phrase as free text", () => {
144 const q = parseIssueQuery('"race condition"');
145 expect(q.text).toBe("race condition");
146 });
147
148 it("parses a complex real-world query", () => {
149 const q = parseIssueQuery(
150 'is:open label:bug -label:wontfix author:alice milestone:"v1.0" sort:updated-desc "null pointer"'
151 );
152 expect(q.is).toBe("open");
153 expect(q.labels).toEqual(["bug"]);
154 expect(q.excludeLabels).toEqual(["wontfix"]);
155 expect(q.author).toBe("alice");
156 expect(q.milestone).toBe("v1.0");
157 expect(q.sort).toBe("updated-desc");
158 expect(q.text).toBe("null pointer");
159 });
160
161 it("drops empty values (key: with no value)", () => {
162 const q = parseIssueQuery("label: author:");
163 expect(q.labels).toEqual([]);
164 expect(q.author).toBeUndefined();
165 });
166
167 it("never throws on weird input", () => {
168 expect(() => parseIssueQuery(":::")).not.toThrow();
169 expect(() => parseIssueQuery(":foo")).not.toThrow();
170 expect(() => parseIssueQuery('"""')).not.toThrow();
171 });
172});
173
174describe("issue-query — matchIssue", () => {
175 it("matches when no filters set", () => {
176 const q = parseIssueQuery("");
177 expect(matchIssue(mkIssue(), q)).toBe(true);
178 });
179
180 it("filters by is:open / is:closed", () => {
181 const q = parseIssueQuery("is:closed");
182 expect(matchIssue(mkIssue({ state: "open" }), q)).toBe(false);
183 expect(matchIssue(mkIssue({ state: "closed" }), q)).toBe(true);
184 });
185
186 it("filters by author (case-insensitive)", () => {
187 const q = parseIssueQuery("author:ALICE");
188 expect(matchIssue(mkIssue({ authorName: "alice" }), q)).toBe(true);
189 expect(matchIssue(mkIssue({ authorName: "bob" }), q)).toBe(false);
190 });
191
192 it("filters by milestone (case-insensitive)", () => {
193 const q = parseIssueQuery('milestone:"V1.0"');
194 expect(matchIssue(mkIssue({ milestoneTitle: "v1.0" }), q)).toBe(true);
195 expect(matchIssue(mkIssue({ milestoneTitle: "v2.0" }), q)).toBe(false);
196 expect(matchIssue(mkIssue({ milestoneTitle: null }), q)).toBe(false);
197 });
198
199 it("no:label excludes labelled issues", () => {
200 const q = parseIssueQuery("no:label");
201 expect(matchIssue(mkIssue({ labelNames: [] }), q)).toBe(true);
202 expect(matchIssue(mkIssue({ labelNames: ["bug"] }), q)).toBe(false);
203 });
204
205 it("label: requires all labels (AND)", () => {
206 const q = parseIssueQuery("label:bug label:frontend");
207 expect(matchIssue(mkIssue({ labelNames: ["bug", "frontend"] }), q)).toBe(true);
208 expect(matchIssue(mkIssue({ labelNames: ["bug"] }), q)).toBe(false);
209 expect(matchIssue(mkIssue({ labelNames: ["Bug", "FrontEnd"] }), q)).toBe(true);
210 });
211
212 it("-label: excludes matched labels", () => {
213 const q = parseIssueQuery("-label:wontfix");
214 expect(matchIssue(mkIssue({ labelNames: ["bug"] }), q)).toBe(true);
215 expect(matchIssue(mkIssue({ labelNames: ["bug", "wontfix"] }), q)).toBe(false);
216 expect(matchIssue(mkIssue({ labelNames: ["WontFix"] }), q)).toBe(false);
217 });
218
219 it("text substring matches title or body (case-insensitive)", () => {
220 const q = parseIssueQuery("NULL pointer");
221 expect(
222 matchIssue(mkIssue({ title: "Got a null pointer crash", body: null }), q)
223 ).toBe(true);
224 expect(
225 matchIssue(mkIssue({ title: "ok", body: "null pointer here" }), q)
226 ).toBe(true);
227 expect(matchIssue(mkIssue({ title: "ok", body: null }), q)).toBe(false);
228 });
229
230 it("text tolerates null body", () => {
231 const q = parseIssueQuery("hello");
232 expect(matchIssue(mkIssue({ title: "hello", body: null }), q)).toBe(true);
233 });
234
235 it("composes multiple filters", () => {
236 const q = parseIssueQuery(
237 "is:open author:alice label:bug -label:wontfix crash"
238 );
239 expect(
240 matchIssue(
241 mkIssue({
242 state: "open",
243 authorName: "alice",
244 labelNames: ["bug"],
245 title: "App crash on startup",
246 }),
247 q
248 )
249 ).toBe(true);
250 expect(
251 matchIssue(
252 mkIssue({
253 state: "open",
254 authorName: "alice",
255 labelNames: ["bug", "wontfix"],
256 title: "App crash on startup",
257 }),
258 q
259 )
260 ).toBe(false);
261 });
262});
263
264describe("issue-query — sortIssues", () => {
265 const a = mkIssue({
266 title: "A",
267 createdAt: new Date("2025-01-01T00:00:00Z"),
268 updatedAt: new Date("2025-02-01T00:00:00Z"),
269 commentCount: 5,
270 });
271 const b = mkIssue({
272 title: "B",
273 createdAt: new Date("2025-01-02T00:00:00Z"),
274 updatedAt: new Date("2025-01-15T00:00:00Z"),
275 commentCount: 10,
276 });
277 const c = mkIssue({
278 title: "C",
279 createdAt: new Date("2025-01-03T00:00:00Z"),
280 updatedAt: new Date("2025-01-01T00:00:00Z"),
281 commentCount: 1,
282 });
283
284 it("created-desc (newest first)", () => {
285 const out = sortIssues([a, b, c], "created-desc");
286 expect(out.map((i) => i.title)).toEqual(["C", "B", "A"]);
287 });
288
289 it("created-asc (oldest first)", () => {
290 const out = sortIssues([b, a, c], "created-asc");
291 expect(out.map((i) => i.title)).toEqual(["A", "B", "C"]);
292 });
293
294 it("updated-desc", () => {
295 const out = sortIssues([a, b, c], "updated-desc");
296 expect(out.map((i) => i.title)).toEqual(["A", "B", "C"]);
297 });
298
299 it("updated-asc", () => {
300 const out = sortIssues([a, b, c], "updated-asc");
301 expect(out.map((i) => i.title)).toEqual(["C", "B", "A"]);
302 });
303
304 it("comments-desc", () => {
305 const out = sortIssues([a, b, c], "comments-desc");
306 expect(out.map((i) => i.title)).toEqual(["B", "A", "C"]);
307 });
308
309 it("does not mutate the input array", () => {
310 const list = [a, b, c];
311 const snapshot = [...list];
312 sortIssues(list, "created-asc");
313 expect(list).toEqual(snapshot);
314 });
315
316 it("handles string dates", () => {
317 const sa = mkIssue({ title: "a", createdAt: "2025-01-01T00:00:00Z" });
318 const sb = mkIssue({ title: "b", createdAt: "2025-01-02T00:00:00Z" });
319 const out = sortIssues([sa, sb], "created-desc");
320 expect(out.map((i) => i.title)).toEqual(["b", "a"]);
321 });
322
323 it("treats unparseable dates as 0", () => {
324 const bad = mkIssue({ title: "bad", createdAt: "not-a-date" });
325 const good = mkIssue({ title: "good", createdAt: "2025-01-01T00:00:00Z" });
326 const out = sortIssues([bad, good], "created-desc");
327 expect(out[0].title).toBe("good");
328 });
329
330 it("treats missing commentCount as 0", () => {
331 const i1 = mkIssue({ title: "i1", commentCount: undefined });
332 const i2 = mkIssue({ title: "i2", commentCount: 3 });
333 const out = sortIssues([i1, i2], "comments-desc");
334 expect(out[0].title).toBe("i2");
335 });
336});
337
338describe("issue-query — applyQuery", () => {
339 const issues: QueryableIssue[] = [
340 mkIssue({
341 title: "Memory leak in scheduler",
342 state: "open",
343 authorName: "alice",
344 labelNames: ["bug", "performance"],
345 createdAt: new Date("2025-02-01T00:00:00Z"),
346 }),
347 mkIssue({
348 title: "Typo in README",
349 state: "closed",
350 authorName: "bob",
351 labelNames: ["docs"],
352 createdAt: new Date("2025-01-15T00:00:00Z"),
353 }),
354 mkIssue({
355 title: "Add dark mode",
356 state: "open",
357 authorName: "carol",
358 labelNames: [],
359 createdAt: new Date("2025-03-01T00:00:00Z"),
360 }),
361 ];
362
363 it("filters + sorts end-to-end", () => {
364 const { query, matches } = applyQuery("is:open sort:created-asc", issues);
365 expect(query.is).toBe("open");
366 expect(matches.map((i) => i.title)).toEqual([
367 "Memory leak in scheduler",
368 "Add dark mode",
369 ]);
370 });
371
372 it("combines text + labels + state", () => {
373 const { matches } = applyQuery("is:open label:bug leak", issues);
374 expect(matches).toHaveLength(1);
375 expect(matches[0].title).toBe("Memory leak in scheduler");
376 });
377
378 it("no:label returns unlabelled issues only", () => {
379 const { matches } = applyQuery("no:label", issues);
380 expect(matches).toHaveLength(1);
381 expect(matches[0].title).toBe("Add dark mode");
382 });
383
384 it("empty query returns all, default sort applied", () => {
385 const { matches } = applyQuery("", issues);
386 expect(matches).toHaveLength(3);
387 // default sort is created-desc
388 expect(matches[0].title).toBe("Add dark mode");
389 });
390
391 it("does not mutate the input list", () => {
392 const snapshot = [...issues];
393 applyQuery("sort:created-asc", issues);
394 expect(issues).toEqual(snapshot);
395 });
396});
397
398describe("issue-query — formatIssueQuery", () => {
399 it("emits empty string for default shape", () => {
400 const empty: IssueQuery = {
401 text: "",
402 labels: [],
403 excludeLabels: [],
404 noLabel: false,
405 sort: DEFAULT_SORT,
406 };
407 expect(formatIssueQuery(empty)).toBe("");
408 });
409
410 it("round-trips a simple query", () => {
411 const q = parseIssueQuery("is:open label:bug author:alice");
412 const s = formatIssueQuery(q);
413 expect(s).toContain("is:open");
414 expect(s).toContain("label:bug");
415 expect(s).toContain("author:alice");
416 });
417
418 it("quotes values with whitespace", () => {
419 const q = parseIssueQuery('milestone:"v1.0 rc" label:"help wanted"');
420 const s = formatIssueQuery(q);
421 expect(s).toContain('milestone:"v1.0 rc"');
422 expect(s).toContain('label:"help wanted"');
423 });
424
425 it("quotes text with whitespace", () => {
426 const q = parseIssueQuery('"race condition"');
427 const s = formatIssueQuery(q);
428 expect(s).toContain('"race condition"');
429 });
430
431 it("omits default sort", () => {
432 const q = parseIssueQuery("");
433 expect(formatIssueQuery(q)).not.toContain("sort:");
434 });
435
436 it("includes non-default sort", () => {
437 const q = parseIssueQuery("sort:updated-desc");
438 expect(formatIssueQuery(q)).toContain("sort:updated-desc");
439 });
440
441 it("emits no:label when set", () => {
442 const q = parseIssueQuery("no:label");
443 expect(formatIssueQuery(q)).toContain("no:label");
444 });
445
446 it("round-trips complex query → parse → format (structural equality)", () => {
447 const original = parseIssueQuery(
448 'is:open label:bug -label:wontfix author:alice milestone:"v1.0" sort:updated-desc crash'
449 );
450 const s = formatIssueQuery(original);
451 const reparsed = parseIssueQuery(s);
452 expect(reparsed.is).toBe(original.is);
453 expect(reparsed.author).toBe(original.author);
454 expect(reparsed.milestone).toBe(original.milestone);
455 expect(reparsed.labels).toEqual(original.labels);
456 expect(reparsed.excludeLabels).toEqual(original.excludeLabels);
457 expect(reparsed.noLabel).toBe(original.noLabel);
458 expect(reparsed.sort).toBe(original.sort);
459 expect(reparsed.text).toBe(original.text);
460 });
461});
462
463describe("issue-query — __internal parity", () => {
464 it("re-exports helpers", () => {
465 expect(__internal.tokenise).toBe(tokenise);
466 expect(__internal.parseIssueQuery).toBe(parseIssueQuery);
467 expect(__internal.matchIssue).toBe(matchIssue);
468 expect(__internal.sortIssues).toBe(sortIssues);
469 expect(__internal.applyQuery).toBe(applyQuery);
470 expect(__internal.formatIssueQuery).toBe(formatIssueQuery);
471 expect(__internal.DEFAULT_SORT).toBe(DEFAULT_SORT);
472 });
473});