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.
| 831a117 | 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 | ||
| 32 | export type IssueState = "open" | "closed"; | |
| 33 | ||
| 34 | export type IssueSort = | |
| 35 | | "created-desc" | |
| 36 | | "created-asc" | |
| 37 | | "updated-desc" | |
| 38 | | "updated-asc" | |
| 39 | | "comments-desc"; | |
| 40 | ||
| 41 | export const DEFAULT_SORT: IssueSort = "created-desc"; | |
| 42 | ||
| 43 | const VALID_SORTS = new Set<IssueSort>([ | |
| 44 | "created-desc", | |
| 45 | "created-asc", | |
| 46 | "updated-desc", | |
| 47 | "updated-asc", | |
| 48 | "comments-desc", | |
| 49 | ]); | |
| 50 | ||
| 51 | export 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 | */ | |
| 70 | export 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 | */ | |
| 96 | export 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. */ | |
| 152 | export 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 | */ | |
| 169 | export 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 | ||
| 197 | function 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. */ | |
| 204 | export 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. */ | |
| 230 | export 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 | */ | |
| 244 | export 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 | ||
| 257 | function formatValuePair(key: string, value: string): string { | |
| 258 | return `${key}:${quoteIfNeeded(value)}`; | |
| 259 | } | |
| 260 | ||
| 261 | function quoteIfNeeded(s: string): string { | |
| 262 | return /\s/.test(s) ? `"${s}"` : s; | |
| 263 | } | |
| 264 | ||
| 265 | export const __internal = { | |
| 266 | VALID_SORTS, | |
| 267 | DEFAULT_SORT, | |
| 268 | tokenise, | |
| 269 | parseIssueQuery, | |
| 270 | matchIssue, | |
| 271 | sortIssues, | |
| 272 | applyQuery, | |
| 273 | formatIssueQuery, | |
| 274 | }; |