mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-15 01:07:40 +00:00
feat: add GitHub repository dashboard with Prometheus metrics
ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics. The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary. The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activeFilterCount,
|
||||
applyFilters,
|
||||
emptyFilters,
|
||||
facetCounts,
|
||||
filtersFromSearch,
|
||||
filtersToSearch,
|
||||
kindGroup,
|
||||
matchesQuery,
|
||||
repoMatches,
|
||||
type Filters,
|
||||
} from './filters'
|
||||
import { buildBlocks } from '@/stores/tracker'
|
||||
import { alert, repo, state } from '@/test/fixtures'
|
||||
|
||||
const fleet = () =>
|
||||
buildBlocks(
|
||||
state({
|
||||
repos: [
|
||||
repo({ fullName: 'a/one', health: 'crit' }),
|
||||
repo({ fullName: 'b/two', health: 'warn', release: null }),
|
||||
],
|
||||
alerts: [
|
||||
alert({
|
||||
repo: 'a/one',
|
||||
kind: 'workflow_failed',
|
||||
severity: 'crit',
|
||||
subject: 'CI',
|
||||
url: '1',
|
||||
}),
|
||||
alert({
|
||||
repo: 'a/one',
|
||||
kind: 'pr_waiting',
|
||||
severity: 'warn',
|
||||
subject: '#3 Bump grpc',
|
||||
url: '2',
|
||||
}),
|
||||
alert({
|
||||
repo: 'b/two',
|
||||
kind: 'issues_stale',
|
||||
severity: 'warn',
|
||||
subject: '4 stale issues',
|
||||
url: '3',
|
||||
}),
|
||||
alert({
|
||||
repo: 'b/two',
|
||||
kind: 'partial_data',
|
||||
severity: 'warn',
|
||||
subject: 'Incomplete data',
|
||||
url: '4',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const withFilters = (over: Partial<Filters>): Filters => ({ ...emptyFilters(), ...over })
|
||||
|
||||
describe('kindGroup', () => {
|
||||
it.each([
|
||||
['workflow_failed', 'workflow'],
|
||||
['workflow_stuck', 'workflow'],
|
||||
['pr_waiting', 'pr'],
|
||||
['pr_checks_failing', 'pr'],
|
||||
['issues_stale', 'issue'],
|
||||
['refresh_failed', 'data'],
|
||||
['data_stale', 'data'],
|
||||
['partial_data', 'data'],
|
||||
['something_new', 'data'],
|
||||
])('%s -> %s', (kind, group) => expect(kindGroup(kind)).toBe(group))
|
||||
})
|
||||
|
||||
describe('matchesQuery', () => {
|
||||
it('requires every term, ignores case and accepts an empty query', () => {
|
||||
expect(matchesQuery('', 'anything')).toBe(true)
|
||||
expect(matchesQuery(' ', 'anything')).toBe(true)
|
||||
expect(matchesQuery('bump GRPC', 'o/r', '#3 Bump google.golang.org/grpc')).toBe(true)
|
||||
expect(matchesQuery('bump nothing', '#3 Bump grpc')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyFilters', () => {
|
||||
it('returns everything for empty filters', () => {
|
||||
expect(applyFilters(fleet(), emptyFilters()).flatMap((b) => b.rows)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('combines severity, kind, owner and search', () => {
|
||||
const rows = (f: Filters) =>
|
||||
applyFilters(fleet(), f).flatMap((b) => b.rows.map((r) => r.subject))
|
||||
expect(rows(withFilters({ severity: 'crit' }))).toEqual(['CI'])
|
||||
expect(rows(withFilters({ kinds: ['pr', 'issue'] }))).toEqual([
|
||||
'#3 Bump grpc',
|
||||
'4 stale issues',
|
||||
])
|
||||
expect(rows(withFilters({ owners: ['b'] }))).toEqual(['4 stale issues', 'Incomplete data'])
|
||||
expect(rows(withFilters({ query: 'grpc' }))).toEqual(['#3 Bump grpc'])
|
||||
expect(rows(withFilters({ query: 'a/one', kinds: ['pr'] }))).toEqual(['#3 Bump grpc'])
|
||||
expect(rows(withFilters({ repo: 'b/two', severity: 'crit' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('drops blocks left without rows', () => {
|
||||
expect(
|
||||
applyFilters(fleet(), withFilters({ kinds: ['workflow'] })).map((b) => b.repo.fullName),
|
||||
).toEqual(['a/one'])
|
||||
})
|
||||
|
||||
it('also searches the release tag', () => {
|
||||
expect(
|
||||
applyFilters(fleet(), withFilters({ query: 'v1.0.0' })).map((b) => b.repo.fullName),
|
||||
).toEqual(['a/one'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('facetCounts', () => {
|
||||
it('counts each facet with the others applied, but not itself', () => {
|
||||
const f = withFilters({ kinds: ['pr'] })
|
||||
const c = facetCounts(fleet(), f, ['a', 'b'])
|
||||
expect(c.kinds).toEqual({ workflow: 1, pr: 1, issue: 1, data: 1 })
|
||||
expect(c.severity).toEqual({ all: 1, crit: 0, warn: 1 })
|
||||
expect(c.owners).toEqual({ a: 1, b: 0 })
|
||||
})
|
||||
|
||||
it('counts everything for empty filters', () => {
|
||||
const c = facetCounts(fleet(), emptyFilters(), ['a', 'b'])
|
||||
expect(c.severity).toEqual({ all: 4, crit: 1, warn: 3 })
|
||||
expect(c.owners).toEqual({ a: 2, b: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('repoMatches', () => {
|
||||
it('uses the query and owner filters only', () => {
|
||||
const r = repo({ fullName: 'a/one', description: 'Kubernetes mirror' })
|
||||
expect(repoMatches(r, withFilters({ query: 'mirror' }))).toBe(true)
|
||||
expect(repoMatches(r, withFilters({ query: 'nope' }))).toBe(false)
|
||||
expect(repoMatches(r, withFilters({ owners: ['b'] }))).toBe(false)
|
||||
expect(repoMatches(r, withFilters({ severity: 'crit', kinds: ['pr'] }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activeFilterCount', () => {
|
||||
it('counts each active filter value', () => {
|
||||
expect(activeFilterCount(emptyFilters())).toBe(0)
|
||||
expect(
|
||||
activeFilterCount(
|
||||
withFilters({
|
||||
query: ' x ',
|
||||
severity: 'warn',
|
||||
kinds: ['pr', 'issue'],
|
||||
owners: ['a'],
|
||||
repo: 'a/b',
|
||||
}),
|
||||
),
|
||||
).toBe(6)
|
||||
expect(activeFilterCount(withFilters({ query: ' ' }))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL round trip', () => {
|
||||
it('omits defaults', () => {
|
||||
expect(filtersToSearch(emptyFilters())).toBe('')
|
||||
})
|
||||
|
||||
it('serialises and restores every filter', () => {
|
||||
const f = withFilters({
|
||||
query: 'bump grpc',
|
||||
severity: 'crit',
|
||||
kinds: ['pr', 'data'],
|
||||
owners: ['a', 'b'],
|
||||
repo: 'a/one',
|
||||
})
|
||||
expect(filtersFromSearch(filtersToSearch(f))).toEqual(f)
|
||||
})
|
||||
|
||||
it('ignores unknown values from a hand-edited URL', () => {
|
||||
expect(filtersFromSearch('?sev=nope&kind=pr,bogus&owner=,a')).toEqual(
|
||||
withFilters({ kinds: ['pr'], owners: ['a'] }),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user