orca/src/shared/task-query.ts

306 lines
7.6 KiB
TypeScript

export type ParsedTaskQuery = {
scope: 'all' | 'issue' | 'pr'
state: 'open' | 'closed' | 'all' | 'merged' | null
draft: boolean
assignee: string | null
author: string | null
reviewRequested: string | null
reviewedBy: string | null
labels: string[]
freeText: string
}
type SearchQueryToken = {
value: string
raw: string
}
function tokenizeSearchQueryWithRaw(rawQuery: string): SearchQueryToken[] {
const tokens: SearchQueryToken[] = []
let value = ''
let raw = ''
let quote: '"' | "'" | null = null
const flush = (): void => {
if (value || raw) {
tokens.push({ value, raw })
value = ''
raw = ''
}
}
for (let i = 0; i < rawQuery.length; i += 1) {
const char = rawQuery[i]
if (/\s/.test(char) && quote === null) {
flush()
continue
}
raw += char
if ((char === '"' || char === "'") && quote === null) {
quote = char
continue
}
if (char === quote) {
quote = null
continue
}
value += char
}
flush()
return tokens
}
export function tokenizeSearchQuery(rawQuery: string): string[] {
return tokenizeSearchQueryWithRaw(rawQuery).map((token) => token.value)
}
export function parseTaskQuery(rawQuery: string): ParsedTaskQuery {
const query: ParsedTaskQuery = {
scope: 'all',
state: null,
draft: false,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: ''
}
const freeTextTokens: string[] = []
let sawIssueScope = false
let sawPRScope = false
for (const { value: token, raw } of tokenizeSearchQueryWithRaw(rawQuery.trim())) {
const normalized = token.toLowerCase()
if (normalized === 'is:issue') {
sawIssueScope = true
query.scope = sawPRScope ? 'all' : 'issue'
continue
}
if (normalized === 'is:pr' || normalized === 'is:pull-request') {
sawPRScope = true
query.scope = sawIssueScope ? 'all' : 'pr'
continue
}
if (normalized === 'is:open') {
query.state = 'open'
continue
}
if (normalized === 'is:closed') {
query.state = 'closed'
continue
}
if (normalized === 'is:merged') {
query.state = 'merged'
continue
}
if (normalized === 'is:draft') {
query.scope = 'pr'
query.state = 'open'
query.draft = true
continue
}
const [rawKey, ...rest] = token.split(':')
const value = rest.join(':').trim()
const key = rawKey.toLowerCase()
if (!value) {
freeTextTokens.push(raw)
continue
}
if (key === 'assignee') {
query.assignee = value
continue
}
if (key === 'author') {
query.author = value
continue
}
if (key === 'review-requested') {
query.scope = 'pr'
query.reviewRequested = value
continue
}
if (key === 'reviewed-by') {
query.scope = 'pr'
query.reviewedBy = value
continue
}
if (key === 'label') {
query.labels.push(value)
continue
}
const normalizedValue = value.toLowerCase()
if (
key === 'state' &&
(normalizedValue === 'open' ||
normalizedValue === 'closed' ||
normalizedValue === 'merged' ||
normalizedValue === 'all')
) {
query.state = normalizedValue
continue
}
// Why: unknown qualifiers and exact phrases are passed through to GitHub
// search as-is; reserializing stripped quotes changes search semantics.
freeTextTokens.push(raw)
}
if (query.draft) {
query.scope = 'pr'
query.state = 'open'
} else if (
query.state === 'merged' ||
query.reviewRequested !== null ||
query.reviewedBy !== null
) {
query.scope = 'pr'
}
query.freeText = freeTextTokens.join(' ').trim()
return query
}
function quoteIfNeeded(value: string): string {
return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value
}
/**
* Serialize a ParsedTaskQuery back to a raw search string. Round-trips with
* parseTaskQuery for the qualifiers it understands; freeText is appended last.
*/
export function serializeTaskQuery(q: ParsedTaskQuery): string {
const parts: string[] = []
if (q.scope === 'pr') {
parts.push('is:pr')
} else if (q.scope === 'issue') {
parts.push('is:issue')
}
if (q.state === 'open') {
parts.push('is:open')
} else if (q.state === 'closed') {
parts.push('is:closed')
} else if (q.state === 'merged') {
parts.push('is:merged')
} else if (q.state === 'all') {
parts.push('state:all')
}
if (q.draft) {
parts.push('is:draft')
}
if (q.author) {
parts.push(`author:${quoteIfNeeded(q.author)}`)
}
if (q.assignee) {
parts.push(`assignee:${quoteIfNeeded(q.assignee)}`)
}
if (q.reviewRequested) {
parts.push(`review-requested:${quoteIfNeeded(q.reviewRequested)}`)
}
if (q.reviewedBy) {
parts.push(`reviewed-by:${quoteIfNeeded(q.reviewedBy)}`)
}
for (const label of q.labels) {
parts.push(`label:${quoteIfNeeded(label)}`)
}
if (q.freeText) {
parts.push(q.freeText)
}
return parts.join(' ')
}
export type TaskQueryFilterKey =
| 'author'
| 'assignee'
| 'reviewRequested'
| 'reviewedBy'
| 'labels'
| 'state'
| 'draft'
/**
* Apply a filter change to a raw query string and return the updated raw string.
* For single-value keys, pass `null` to clear. For `labels`, pass the full next
* array (caller manages add/remove).
*/
export function withQualifier(
rawQuery: string,
key: TaskQueryFilterKey,
value: string | string[] | null
): string {
const parsed = parseTaskQuery(rawQuery)
switch (key) {
case 'author':
parsed.author = typeof value === 'string' ? value : null
break
case 'assignee':
parsed.assignee = typeof value === 'string' ? value : null
break
case 'reviewRequested':
parsed.reviewRequested = typeof value === 'string' ? value : null
if (parsed.reviewRequested) {
parsed.scope = 'pr'
}
break
case 'reviewedBy':
parsed.reviewedBy = typeof value === 'string' ? value : null
if (parsed.reviewedBy) {
parsed.scope = 'pr'
}
break
case 'labels':
parsed.labels = Array.isArray(value) ? value : []
break
case 'state':
parsed.state =
value === 'open' || value === 'closed' || value === 'merged' || value === 'all'
? value
: null
if (parsed.state === 'merged') {
parsed.scope = 'pr'
}
if (parsed.state !== 'open') {
parsed.draft = false
}
break
case 'draft':
parsed.draft = value === 'true'
if (parsed.draft) {
parsed.scope = 'pr'
parsed.state = 'open'
}
break
}
return serializeTaskQuery(parsed)
}
/**
* Strip any `repo:owner/name` qualifiers from a raw search string.
*
* Why: in cross-repo mode the renderer fans the search out to each selected
* repo via IPC. A stray `repo:` qualifier would pin every fan-out call to one
* repo and silently zero out the others, so it must be removed before dispatch.
* Tokens containing whitespace are re-quoted so quoted-label values like
* `label:"needs review"` round-trip cleanly.
*/
export function stripRepoQualifiers(rawQuery: string): string {
const kept: string[] = []
for (const token of tokenizeSearchQuery(rawQuery.trim())) {
if (/^repo:[^\s]+$/i.test(token)) {
continue
}
if (/\s/.test(token)) {
const [rawKey, ...rest] = token.split(':')
if (rest.length > 0) {
kept.push(`${rawKey}:"${rest.join(':')}"`)
} else {
kept.push(`"${token}"`)
}
} else {
kept.push(token)
}
}
return kept.join(' ')
}