Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit831a117unknown_key

feat(BLOCK-J): J23 issue/PR search query DSL

feat(BLOCK-J): J23 issue/PR search query DSL

Adds a GitHub-style search DSL on the issue list: ?q=is:open
label:bug author:alice -label:wontfix "race condition"
sort:updated-desc. Pure parser + matcher + sorter in
src/lib/issue-query.ts (59 tests, 116 expects, never throws).
Unknown qualifiers fall back to free text; sort values outside the
allow-list fall back to default. Issues route joins labels in a
single query and renders an inline search bar plus a collapsible
syntax cheat-sheet; tab counts stay over all issues so filters
don't collapse them.
Claude committed on April 15, 2026Parent: 7c0203e
4 files changed+91515831a11764686b07c01ce024586a1f611c707d9b5
4 changed files+915−15
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
144144| Stale issue detector | ✅ | J20 — `GET /:owner/:repo/issues/stale[?period=30d\|60d\|90d\|180d]` lists every open issue whose `updated_at` is older than the selected threshold, sorted oldest-first with per-issue `daysSinceUpdate` + comment count + age-colour (>=90d amber, >=180d red). Four age-bucket cards show the distribution (`30-60`, `60-90`, `90-180`, `180+`). Pure `filterStale` / `bucketByStaleness` / `buildStaleReport` in `src/lib/stale-issues.ts` accept both `Date` and ISO inputs and gracefully drop unparseable timestamps. softAuth read-only; private repos 404 for non-owner viewers. Linked from the Issues toolbar via a "Stale" button. Non-destructive — we only surface staleness, never auto-close. |
145145| CODEOWNERS validator | ✅ | J21 — `GET /:owner/:repo/codeowners` lints the CODEOWNERS file (standard locations: root / `.github/` / `docs/`) on the default branch against known users + teams and surface-level syntax. Pure `lexCodeowners` (line-aware, CRLF tolerant, inline-comment strip, detects pattern-with-no-owners), `classifyOwnerToken` (user / team / email / invalid), `isPlausiblePattern` (bracket balance, whitespace), `validateCodeowners` (async, takes `OwnerResolver`) in `src/lib/codeowners-lint.ts`. Findings are anchored to 1-indexed line numbers with an `error\|warning\|info` severity + stable `code` taxonomy (`empty_file\|no_owners\|empty_pattern\|bad_pattern_syntax\|bad_owner_format\|unknown_user\|unknown_team\|duplicate_pattern\|duplicate_owner\|missing_catchall`). UI shows four summary cards (rules / errors / warnings / infos), a findings list, and the file body with line numbers. Resolver memoises per-request. Non-destructive — report only. |
146146| Code review suggestion blocks | ✅ | J22 — `` ```suggestion `` fenced blocks inside PR comments are detected (pure `extractSuggestions` in `src/lib/code-suggestions.ts`) and rendered on the PR page with a "Commit suggestion" button when the viewer is the PR author, comment author, or repo owner. `POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion` reads the file on the PR's head branch, runs pure `applySuggestionToContent` (preserves CRLF/LF line endings + trailing-newline presence, validates line range + detects no-op), and commits via git plumbing (`hash-object` → `ls-tree -r` rewrite → `mktree` → `commit-tree` → `update-ref`) with a `Co-authored-by:` trailer crediting the applier. `requireAuth`; private repos 404 for non-owners; closed PRs redirect silently. |
147| Issue/PR search query DSL | ✅ | J23 — GitHub-style search DSL on the issue list. `GET /:owner/:repo/issues?q=…` accepts `is:open\|closed`, `author:<user>`, `label:<name>` (repeatable, AND), `-label:<name>` (exclude), `no:label`, `milestone:"<title>"`, `sort:created-desc\|created-asc\|updated-desc\|updated-asc\|comments-desc`, and any other text → case-insensitive substring match against title+body. Pure `tokenise` / `parseIssueQuery` / `matchIssue` / `sortIssues` / `applyQuery` / `formatIssueQuery` in `src/lib/issue-query.ts` — never throws; unknown qualifiers fall back to text; `sort:` values not in the allow-list fall back to default. The issues route joins labels, applies the DSL in-memory, and renders an inline search bar + collapsible syntax cheat-sheet + live match count. Tab pill counts remain over all issues so filters don't collapse them. |
147148| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
148149| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
149150| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
526527- `src/routes/codeowners-lint.tsx` (Block J21) — serves `GET /:owner/:repo/codeowners`. softAuth; private repos 404 for non-owner viewers. Walks the three standard paths (`CODEOWNERS`, `.github/CODEOWNERS`, `docs/CODEOWNERS`) on the default branch, builds a per-request memoising resolver over `users.username` + `organizations.slug` + `teams.slug`, calls `validateCodeowners`. Renders four summary cards (rules / errors / warnings / info), a findings list with line + severity + code + message, and the file body with line numbers. Gracefully empty-states when no CODEOWNERS file is found, with a one-click link to create `.github/CODEOWNERS` in the web editor.
527528- `src/lib/code-suggestions.ts` (Block J22) — pure suggestion extractor + applier. Exports `detectLineEnding` (CRLF wins if any present), `splitLines`, `extractSuggestions(body)` (parses ``` ```suggestion ``` `` fences with CommonMark-parity rules: indented openers up to 3 spaces, 3+ backtick fences, case-sensitive language token, skips unterminated), `applySuggestionToContent({content,startLine,endLine,suggestion})` (1-indexed inclusive range; preserves original EOL + trailing-newline presence; returns `{ok,reason}` taxonomy `bad_range\|line_out_of_bounds\|empty_content\|no_change`), `applyNthSuggestion` convenience. `__internal` re-exports for tests.
528529- `src/routes/code-suggestions.tsx` (Block J22) — serves `POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion`. `requireAuth`. Authorises applier as owner / PR author / comment author. Validates PR is open, comment has `file_path` + `line_number`, suggestion index is in range. Reads the file on the PR's head branch, applies the pure transformer, then commits via git plumbing (`hash-object` → `ls-tree -r` rewrite → `mktree` → `commit-tree -p` head → `update-ref refs/heads/<head>`). Commit message: `Apply suggestion from #N\n\nCo-authored-by: …`. Redirects to the PR with a comment anchor on success. `src/routes/pulls.tsx` PR detail page imports `extractSuggestions` and renders a "Commit suggestion" button (per-block when multiple) below the comment body for eligible viewers.
530- `src/lib/issue-query.ts` (Block J23) — pure GitHub-style search DSL. Exports `IssueState`/`IssueSort` types, `DEFAULT_SORT='created-desc'`, `tokenise(raw)` (whitespace-splitting with `"double"`-quoted-span support; trailing unterminated quote flushes on EOF), `parseIssueQuery(raw)` (handles `is:open\|closed`, `author:`, `label:` (repeatable, AND), `-label:` (exclude), `no:label`, `milestone:`, `sort:` allow-list; unknown qualifiers fall back to free text; never throws; tolerates null/undefined/non-string; key matching is case-insensitive). `matchIssue(issue, q)` applies each filter dimension (case-insensitive label/author/milestone; case-insensitive substring match of `q.text` against `${title}\n${body}`). `sortIssues(list, sort)` returns a new array (never mutates); unparseable dates treated as epoch 0; missing `commentCount` treated as 0. `applyQuery(raw, issues)` one-shot parse+filter+sort. `formatIssueQuery(q)` reverse-formatter with whitespace-triggered quoting. `__internal` re-exports for tests.
531- `src/routes/issues.tsx` (Block J23 integration) — issue list route accepts `?q=<DSL>`. If `q` sets `is:`, the DSL overrides the tab-based state filter. Labels joined via single `IN (…)` Drizzle query and threaded into both the DSL matcher and inline label pills. Search bar + collapsible syntax cheat-sheet + live match-count rendered above the tab pills. Tab open/closed counts remain over all issues (not filtered) so DSL use doesn't collapse them.
529532
530533### 4.7 Views (locked contracts)
531534- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
560563```bash
561564bun install
562565bun dev # hot reload
563bun test # 965 tests currently pass
566bun test # 1127 tests currently pass
564567bun run db:migrate
565568```
566569
Addedsrc/__tests__/issue-query.test.ts+473−0View fileUnifiedSplit
1/**
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});
Addedsrc/lib/issue-query.ts+274−0View fileUnifiedSplit
1/**
2 * Block J23 — Issue/PR search query DSL.
3 *
4 * A pure parser + matcher for GitHub-style query strings like:
5 *
6 * is:open label:bug author:alice "race condition"
7 * is:closed no:label sort:updated-desc
8 * milestone:"v1.0" label:frontend label:regression
9 *
10 * Supported qualifiers:
11 * is:open | is:closed → `state` filter
12 * author:<username> → PR/issue author
13 * label:<name> → repeatable; AND across labels
14 * -label:<name> → repeatable; excludes label
15 * no:label → zero labels
16 * milestone:<title> → milestone title
17 * sort:<field> → `created-desc`, `created-asc`,
18 * `updated-desc`, `updated-asc`,
19 * `comments-desc`
20 *
21 * Anything that doesn't look like a `key:value` qualifier (including
22 * quoted strings) is joined into `text` for substring matching against
23 * the issue title + body. The DSL is strictly local — the route maps it
24 * to a Drizzle WHERE where possible and applies the rest in JS.
25 *
26 * Input sanitisation:
27 * - Unknown qualifiers are silently dropped (never throw).
28 * - `label:` / `milestone:` values with spaces must be quoted.
29 * - `sort:` values not in the allow-list fall back to default.
30 */
31
32export type IssueState = "open" | "closed";
33
34export type IssueSort =
35 | "created-desc"
36 | "created-asc"
37 | "updated-desc"
38 | "updated-asc"
39 | "comments-desc";
40
41export const DEFAULT_SORT: IssueSort = "created-desc";
42
43const VALID_SORTS = new Set<IssueSort>([
44 "created-desc",
45 "created-asc",
46 "updated-desc",
47 "updated-asc",
48 "comments-desc",
49]);
50
51export interface IssueQuery {
52 /** Raw free-text remaining after qualifiers are stripped. */
53 text: string;
54 is?: IssueState;
55 author?: string;
56 /** AND-matched. */
57 labels: string[];
58 /** Labels to exclude. */
59 excludeLabels: string[];
60 /** `no:label` requested zero-label issues. */
61 noLabel: boolean;
62 milestone?: string;
63 sort: IssueSort;
64}
65
66/**
67 * Token a query string, respecting `"double"`-quoted spans. Returns
68 * tokens preserving their original text (minus the surrounding quotes).
69 */
70export function tokenise(raw: string): string[] {
71 const out: string[] = [];
72 let buf = "";
73 let inQuote = false;
74 for (let i = 0; i < raw.length; i++) {
75 const ch = raw[i];
76 if (ch === '"') {
77 inQuote = !inQuote;
78 continue;
79 }
80 if (/\s/.test(ch) && !inQuote) {
81 if (buf) {
82 out.push(buf);
83 buf = "";
84 }
85 continue;
86 }
87 buf += ch;
88 }
89 if (buf) out.push(buf);
90 return out;
91}
92
93/**
94 * Parse a raw query string into a structured `IssueQuery`. Never throws.
95 */
96export function parseIssueQuery(raw: string | null | undefined): IssueQuery {
97 const q: IssueQuery = {
98 text: "",
99 labels: [],
100 excludeLabels: [],
101 noLabel: false,
102 sort: DEFAULT_SORT,
103 };
104 if (!raw || typeof raw !== "string") return q;
105 const tokens = tokenise(raw.trim());
106 const textParts: string[] = [];
107 for (const tok of tokens) {
108 // Negative label: -label:name
109 const negLabel = tok.match(/^-label:(.+)$/);
110 if (negLabel) {
111 q.excludeLabels.push(negLabel[1]);
112 continue;
113 }
114 const colonIdx = tok.indexOf(":");
115 if (colonIdx <= 0 || colonIdx === tok.length - 1) {
116 textParts.push(tok);
117 continue;
118 }
119 const key = tok.slice(0, colonIdx).toLowerCase();
120 const value = tok.slice(colonIdx + 1);
121 switch (key) {
122 case "is":
123 if (value === "open" || value === "closed") q.is = value;
124 break;
125 case "author":
126 if (value) q.author = value;
127 break;
128 case "label":
129 if (value) q.labels.push(value);
130 break;
131 case "milestone":
132 if (value) q.milestone = value;
133 break;
134 case "no":
135 if (value === "label") q.noLabel = true;
136 break;
137 case "sort":
138 if (VALID_SORTS.has(value as IssueSort)) {
139 q.sort = value as IssueSort;
140 }
141 break;
142 default:
143 textParts.push(tok);
144 break;
145 }
146 }
147 q.text = textParts.join(" ").trim();
148 return q;
149}
150
151/** An issue shape (subset) the matcher can evaluate. */
152export interface QueryableIssue {
153 title: string;
154 body: string | null;
155 state: string;
156 authorName: string;
157 labelNames: string[];
158 milestoneTitle?: string | null;
159 createdAt: Date | string;
160 updatedAt: Date | string;
161 commentCount?: number;
162}
163
164/**
165 * Does `issue` match `query`? Text substring match is case-insensitive
166 * and whole-query (not per-term): match if the collapsed text appears in
167 * `${title} ${body}`. Label/author/milestone matching is case-insensitive.
168 */
169export function matchIssue(issue: QueryableIssue, q: IssueQuery): boolean {
170 if (q.is && issue.state !== q.is) return false;
171 if (q.author && issue.authorName.toLowerCase() !== q.author.toLowerCase())
172 return false;
173 if (q.milestone) {
174 const m = (issue.milestoneTitle || "").toLowerCase();
175 if (m !== q.milestone.toLowerCase()) return false;
176 }
177 if (q.noLabel && issue.labelNames.length > 0) return false;
178 if (q.labels.length > 0) {
179 const have = new Set(issue.labelNames.map((l) => l.toLowerCase()));
180 for (const want of q.labels) {
181 if (!have.has(want.toLowerCase())) return false;
182 }
183 }
184 if (q.excludeLabels.length > 0) {
185 const have = new Set(issue.labelNames.map((l) => l.toLowerCase()));
186 for (const bad of q.excludeLabels) {
187 if (have.has(bad.toLowerCase())) return false;
188 }
189 }
190 if (q.text) {
191 const hay = `${issue.title}\n${issue.body || ""}`.toLowerCase();
192 if (!hay.includes(q.text.toLowerCase())) return false;
193 }
194 return true;
195}
196
197function toMs(v: Date | string): number {
198 if (v instanceof Date) return v.getTime();
199 const t = Date.parse(v);
200 return Number.isFinite(t) ? t : 0;
201}
202
203/** Pure sort. Returns a new array; does not mutate input. */
204export function sortIssues<T extends QueryableIssue>(
205 list: T[],
206 sort: IssueSort
207): T[] {
208 const out = [...list];
209 switch (sort) {
210 case "created-desc":
211 out.sort((a, b) => toMs(b.createdAt) - toMs(a.createdAt));
212 break;
213 case "created-asc":
214 out.sort((a, b) => toMs(a.createdAt) - toMs(b.createdAt));
215 break;
216 case "updated-desc":
217 out.sort((a, b) => toMs(b.updatedAt) - toMs(a.updatedAt));
218 break;
219 case "updated-asc":
220 out.sort((a, b) => toMs(a.updatedAt) - toMs(b.updatedAt));
221 break;
222 case "comments-desc":
223 out.sort((a, b) => (b.commentCount || 0) - (a.commentCount || 0));
224 break;
225 }
226 return out;
227}
228
229/** One-shot: parse + filter + sort. */
230export function applyQuery<T extends QueryableIssue>(
231 raw: string | null | undefined,
232 issues: T[]
233): { query: IssueQuery; matches: T[] } {
234 const q = parseIssueQuery(raw);
235 const filtered = issues.filter((i) => matchIssue(i, q));
236 const sorted = sortIssues(filtered, q.sort);
237 return { query: q, matches: sorted };
238}
239
240/**
241 * Turn a structured query back into a canonical query string. Useful for
242 * rebuilding the input field after server-side filtering.
243 */
244export function formatIssueQuery(q: IssueQuery): string {
245 const parts: string[] = [];
246 if (q.is) parts.push(`is:${q.is}`);
247 if (q.author) parts.push(`author:${q.author}`);
248 for (const l of q.labels) parts.push(formatValuePair("label", l));
249 for (const l of q.excludeLabels) parts.push(`-label:${quoteIfNeeded(l)}`);
250 if (q.noLabel) parts.push("no:label");
251 if (q.milestone) parts.push(formatValuePair("milestone", q.milestone));
252 if (q.sort !== DEFAULT_SORT) parts.push(`sort:${q.sort}`);
253 if (q.text) parts.push(quoteIfNeeded(q.text));
254 return parts.join(" ");
255}
256
257function formatValuePair(key: string, value: string): string {
258 return `${key}:${quoteIfNeeded(value)}`;
259}
260
261function quoteIfNeeded(s: string): string {
262 return /\s/.test(s) ? `"${s}"` : s;
263}
264
265export const __internal = {
266 VALID_SORTS,
267 DEFAULT_SORT,
268 tokenise,
269 parseIssueQuery,
270 matchIssue,
271 sortIssues,
272 applyQuery,
273 formatIssueQuery,
274};
Modifiedsrc/routes/issues.tsx+164−14View fileUnifiedSplit
2424 type IssueTemplate,
2525} from "../lib/issue-templates";
2626import { renderMarkdown } from "../lib/markdown";
27import {
28 applyQuery,
29 formatIssueQuery,
30 parseIssueQuery,
31 type QueryableIssue,
32} from "../lib/issue-query";
2733import { softAuth, requireAuth } from "../middleware/auth";
2834import type { AuthEnv } from "../middleware/auth";
2935import { html } from "hono/html";
5561issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
5662 const { owner: ownerName, repo: repoName } = c.req.param();
5763 const user = c.get("user");
58 const state = c.req.query("state") || "open";
64 const stateParam = c.req.query("state") || "open";
65 const rawQuery = c.req.query("q") || "";
5966
6067 const resolved = await resolveRepo(ownerName, repoName);
6168 if (!resolved) {
7178
7279 const { repo } = resolved;
7380
74 const issueList = await db
81 // J23 — the DSL may override `is:` from the query string. If `is:` is set
82 // in `q`, it wins; otherwise the tab-based state controls the filter.
83 const parsedQuery = parseIssueQuery(rawQuery);
84 const effectiveState = parsedQuery.is ?? stateParam;
85
86 // Fetch issues matching the basic state filter, joined with author.
87 const issueRows = await db
7588 .select({
7689 issue: issues,
7790 author: { username: users.username },
7992 .from(issues)
8093 .innerJoin(users, eq(issues.authorId, users.id))
8194 .where(
82 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
95 and(
96 eq(issues.repositoryId, repo.id),
97 eq(issues.state, effectiveState)
98 )
8399 )
84100 .orderBy(desc(issues.createdAt));
85101
86 // Count open/closed
102 // Fetch labels for the fetched issues (single query). Used both for
103 // DSL filtering and for an inline render hint.
104 const issueIds = issueRows.map((r) => r.issue.id);
105 const labelRows =
106 issueIds.length === 0
107 ? []
108 : await db
109 .select({
110 issueId: issueLabels.issueId,
111 name: labels.name,
112 color: labels.color,
113 })
114 .from(issueLabels)
115 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
116 .where(
117 sql`${issueLabels.issueId} IN (${sql.join(
118 issueIds.map((id) => sql`${id}`),
119 sql`, `
120 )})`
121 );
122
123 const labelsByIssue = new Map<string, { name: string; color: string }[]>();
124 for (const row of labelRows) {
125 const arr = labelsByIssue.get(row.issueId) ?? [];
126 arr.push({ name: row.name, color: row.color });
127 labelsByIssue.set(row.issueId, arr);
128 }
129
130 // Build queryable shape for the DSL matcher.
131 type Row = (typeof issueRows)[number];
132 type RowWithLabels = Row & { labelNames: string[]; colorByLabel: Map<string, string> };
133 const enriched: RowWithLabels[] = issueRows.map((r) => {
134 const ls = labelsByIssue.get(r.issue.id) ?? [];
135 const colorByLabel = new Map<string, string>();
136 for (const l of ls) colorByLabel.set(l.name, l.color);
137 return { ...r, labelNames: ls.map((l) => l.name), colorByLabel };
138 });
139
140 // Apply the DSL if a query was provided. Otherwise pass-through.
141 let display: RowWithLabels[];
142 if (rawQuery.trim()) {
143 const queryable: (QueryableIssue & { __row: RowWithLabels })[] = enriched.map(
144 (r) => ({
145 title: r.issue.title,
146 body: r.issue.body,
147 state: r.issue.state,
148 authorName: r.author.username,
149 labelNames: r.labelNames,
150 milestoneTitle: null,
151 createdAt: r.issue.createdAt,
152 updatedAt: r.issue.updatedAt,
153 commentCount: 0,
154 __row: r,
155 })
156 );
157 const { matches } = applyQuery(rawQuery, queryable);
158 display = matches.map((m) => m.__row);
159 } else {
160 display = enriched;
161 }
162
163 // Count open/closed for the tab pills — always over ALL issues in the
164 // repo, independent of `q`, so the counters don't collapse when filters
165 // narrow the list.
87166 const [counts] = await db
88167 .select({
89168 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
92171 .from(issues)
93172 .where(eq(issues.repositoryId, repo.id));
94173
95 return c.html(
174 const qsForTab = (s: string) => {
175 const parts: string[] = [`state=${s}`];
176 if (rawQuery) parts.push(`q=${encodeURIComponent(rawQuery)}`);
177 return parts.join("&");
178 };
179
180 return (
181 c.html(
96182 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
97183 <RepoHeader owner={ownerName} repo={repoName} />
98184 <IssueNav owner={ownerName} repo={repoName} active="issues" />
185
186 {/* J23 — DSL search bar. */}
187 <form
188 method="GET"
189 action={`/${ownerName}/${repoName}/issues`}
190 style="margin-bottom: 12px; display: flex; gap: 8px"
191 >
192 <input type="hidden" name="state" value={effectiveState} />
193 <input
194 type="text"
195 name="q"
196 value={rawQuery}
197 placeholder='is:open label:bug author:alice "race condition"'
198 style="flex: 1; padding: 6px 10px; font-size: 13px; font-family: var(--font-mono)"
199 />
200 <button type="submit" class="btn" style="padding: 4px 12px">
201 Search
202 </button>
203 {rawQuery && (
204 <a
205 href={`/${ownerName}/${repoName}/issues?state=${effectiveState}`}
206 class="btn"
207 style="padding: 4px 12px; font-size: 13px"
208 >
209 Clear
210 </a>
211 )}
212 </form>
213 <details style="margin-bottom: 12px; color: var(--text-muted); font-size: 12px">
214 <summary style="cursor: pointer">Search syntax</summary>
215 <div style="margin-top: 6px; line-height: 1.6">
216 <code>is:open</code> / <code>is:closed</code> •{" "}
217 <code>author:&lt;user&gt;</code> •{" "}
218 <code>label:&lt;name&gt;</code> (repeatable, AND) •{" "}
219 <code>-label:&lt;name&gt;</code> to exclude •{" "}
220 <code>no:label</code> •{" "}
221 <code>milestone:"v1.0"</code> •{" "}
222 <code>sort:</code>
223 <code>created-desc</code>|<code>created-asc</code>|
224 <code>updated-desc</code>|<code>updated-asc</code>|
225 <code>comments-desc</code> • any other text is a case-insensitive
226 substring match against title+body.
227 </div>
228 </details>
229
99230 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
100231 <div class="issue-tabs">
101232 <a
102 href={`/${ownerName}/${repoName}/issues?state=open`}
103 class={state === "open" ? "active" : ""}
233 href={`/${ownerName}/${repoName}/issues?${qsForTab("open")}`}
234 class={effectiveState === "open" ? "active" : ""}
104235 >
105236 {counts?.open ?? 0} Open
106237 </a>
107238 <a
108 href={`/${ownerName}/${repoName}/issues?state=closed`}
109 class={state === "closed" ? "active" : ""}
239 href={`/${ownerName}/${repoName}/issues?${qsForTab("closed")}`}
240 class={effectiveState === "closed" ? "active" : ""}
110241 >
111242 {counts?.closed ?? 0} Closed
112243 </a>
113244 </div>
114245 <div style="display: flex; gap: 8px; align-items: center">
246 {rawQuery && (
247 <span style="color: var(--text-muted); font-size: 12px">
248 {display.length} match{display.length === 1 ? "" : "es"}
249 </span>
250 )}
115251 <a
116252 href={`/${ownerName}/${repoName}/issues/stale`}
117253 class="btn"
129265 )}
130266 </div>
131267 </div>
132 {issueList.length === 0 ? (
268 {display.length === 0 ? (
133269 <div class="empty-state">
134270 <p>
135 No {state} issues.
136 {state === "closed" && (
271 {rawQuery
272 ? `No issues match ${JSON.stringify(formatIssueQuery(parsedQuery))}.`
273 : `No ${effectiveState} issues.`}
274 {!rawQuery && effectiveState === "closed" && (
137275 <span>
138276 {" "}
139277 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
145283 </div>
146284 ) : (
147285 <div class="issue-list">
148 {issueList.map(({ issue, author }) => (
286 {display.map(({ issue, author, labelNames, colorByLabel }) => (
149287 <div class="issue-item">
150288 <div
151289 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
152290 >
153291 {issue.state === "open" ? "\u25CB" : "\u2713"}
154292 </div>
155 <div>
293 <div style="min-width: 0; flex: 1">
156294 <div class="issue-title">
157295 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
158296 {issue.title}
159297 </a>
298 {labelNames.length > 0 && (
299 <span style="margin-left: 8px">
300 {labelNames.map((name) => (
301 <span
302 style={`display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px; margin-right: 4px; background: ${colorByLabel.get(name) ?? "var(--bg-secondary)"}; color: #fff; border: 1px solid var(--border)`}
303 >
304 {name}
305 </span>
306 ))}
307 </span>
308 )}
160309 </div>
161310 <div class="issue-meta">
162311 #{issue.number} opened by {author.username}{" "}
168317 </div>
169318 )}
170319 </Layout>
320 )
171321 );
172322});
173323
174324