mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-13 00:48:42 +00:00
Each open issue now has its own alert. An issue with recent activity is issue_fresh. An issue idle for issue_stale_after or longer is issue_stale. This replaces the single issues_stale line per repo. A non-draft pull request younger than pr_warn_after now raises pr_fresh. Older pull requests keep pr_waiting. All new alerts have warn severity, so a repo with open work is no longer green. The pr_open alert for auto actions is unchanged.
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
import type { Block, Repo, Row } from './types'
|
|
|
|
export type SeverityFilter = 'all' | 'crit' | 'warn'
|
|
export type KindGroup = 'workflow' | 'pr' | 'issue' | 'data'
|
|
|
|
export interface Filters {
|
|
query: string
|
|
severity: SeverityFilter
|
|
kinds: KindGroup[]
|
|
owners: string[]
|
|
repo: string | null
|
|
}
|
|
|
|
export const KIND_GROUPS: KindGroup[] = ['workflow', 'pr', 'issue', 'data']
|
|
|
|
export const emptyFilters = (): Filters => ({
|
|
query: '',
|
|
severity: 'all',
|
|
kinds: [],
|
|
owners: [],
|
|
repo: null,
|
|
})
|
|
|
|
export function kindGroup(kind: string): KindGroup {
|
|
if (kind.startsWith('workflow_')) return 'workflow'
|
|
if (kind.startsWith('pr_')) return 'pr'
|
|
if (kind.startsWith('issue_')) return 'issue'
|
|
return 'data'
|
|
}
|
|
|
|
export const ownerOf = (fullName: string): string => fullName.split('/')[0]
|
|
|
|
/** Every whitespace-separated term must appear somewhere in the haystack. */
|
|
export function matchesQuery(query: string, ...fields: string[]): boolean {
|
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
|
if (terms.length === 0) return true
|
|
const haystack = fields.join(' ').toLowerCase()
|
|
return terms.every((t) => haystack.includes(t))
|
|
}
|
|
|
|
/** Facets a row or block can be counted under; the named one is ignored while counting it. */
|
|
export type Facet = 'severity' | 'kinds' | 'owners'
|
|
|
|
function rowMatches(repo: Repo, row: Row, f: Filters, skip?: Facet): boolean {
|
|
if (f.repo && repo.fullName !== f.repo) return false
|
|
if (skip !== 'severity' && f.severity !== 'all' && row.severity !== f.severity) return false
|
|
if (skip !== 'kinds' && f.kinds.length > 0 && !f.kinds.includes(kindGroup(row.kind))) return false
|
|
if (skip !== 'owners' && f.owners.length > 0 && !f.owners.includes(ownerOf(repo.fullName)))
|
|
return false
|
|
return matchesQuery(f.query, repo.fullName, repo.release?.tag ?? '', row.subject, row.detail)
|
|
}
|
|
|
|
export function applyFilters(blocks: Block[], f: Filters, skip?: Facet): Block[] {
|
|
const out: Block[] = []
|
|
for (const b of blocks) {
|
|
const rows = b.rows.filter((r) => rowMatches(b.repo, r, f, skip))
|
|
if (rows.length > 0) out.push({ repo: b.repo, rows })
|
|
}
|
|
return out
|
|
}
|
|
|
|
export interface Facets {
|
|
severity: Record<SeverityFilter, number>
|
|
kinds: Record<KindGroup, number>
|
|
owners: Record<string, number>
|
|
}
|
|
|
|
/** Counts rows per facet value, each with every other filter applied (faceted search). */
|
|
export function facetCounts(blocks: Block[], f: Filters, owners: string[]): Facets {
|
|
const rows = (skip: Facet) =>
|
|
applyFilters(blocks, f, skip).flatMap((b) => b.rows.map((r) => ({ b, r })))
|
|
const sev = rows('severity')
|
|
const kinds = rows('kinds')
|
|
const own = rows('owners')
|
|
return {
|
|
severity: {
|
|
all: sev.length,
|
|
crit: sev.filter(({ r }) => r.severity === 'crit').length,
|
|
warn: sev.filter(({ r }) => r.severity === 'warn').length,
|
|
},
|
|
kinds: Object.fromEntries(
|
|
KIND_GROUPS.map((g) => [g, kinds.filter(({ r }) => kindGroup(r.kind) === g).length]),
|
|
) as Facets['kinds'],
|
|
owners: Object.fromEntries(
|
|
owners.map((o) => [o, own.filter(({ b }) => ownerOf(b.repo.fullName) === o).length]),
|
|
),
|
|
}
|
|
}
|
|
|
|
/** Repos that pass the query and owner filters; used for the project list and the strip. */
|
|
export function repoMatches(repo: Repo, f: Filters): boolean {
|
|
if (f.owners.length > 0 && !f.owners.includes(ownerOf(repo.fullName))) return false
|
|
return matchesQuery(f.query, repo.fullName, repo.release?.tag ?? '', repo.description)
|
|
}
|
|
|
|
export function activeFilterCount(f: Filters): number {
|
|
return (
|
|
(f.query.trim() ? 1 : 0) +
|
|
(f.severity !== 'all' ? 1 : 0) +
|
|
f.kinds.length +
|
|
f.owners.length +
|
|
(f.repo ? 1 : 0)
|
|
)
|
|
}
|
|
|
|
/** Serialises filters for the URL; defaults are omitted so the plain URL stays clean. */
|
|
export function filtersToSearch(f: Filters): string {
|
|
const p = new URLSearchParams()
|
|
if (f.query.trim()) p.set('q', f.query.trim())
|
|
if (f.severity !== 'all') p.set('sev', f.severity)
|
|
if (f.kinds.length) p.set('kind', f.kinds.join(','))
|
|
if (f.owners.length) p.set('owner', f.owners.join(','))
|
|
if (f.repo) p.set('repo', f.repo)
|
|
const s = p.toString()
|
|
return s ? `?${s}` : ''
|
|
}
|
|
|
|
export function filtersFromSearch(search: string): Filters {
|
|
const p = new URLSearchParams(search)
|
|
const sev = p.get('sev')
|
|
const list = (key: string) => (p.get(key) ?? '').split(',').filter(Boolean)
|
|
return {
|
|
query: p.get('q') ?? '',
|
|
severity: sev === 'crit' || sev === 'warn' ? sev : 'all',
|
|
kinds: list('kind').filter((k): k is KindGroup => (KIND_GROUPS as string[]).includes(k)),
|
|
owners: list('owner'),
|
|
repo: p.get('repo'),
|
|
}
|
|
}
|