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.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.tsBlame274 lines · 1 contributor
831a117Claude1/**
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};