306 lines
7.6 KiB
TypeScript
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(' ')
|
|
}
|