@kernhq/module-tracker 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -5
- package/src/client/format.ts +1 -1
- package/src/client/group.ts +1 -1
- package/src/client/kql.ts +13 -5
- package/src/client/types.ts +1 -1
- package/src/contract/events.ts +94 -0
- package/src/contract/index.ts +5 -0
- package/src/contract/models.ts +1422 -0
- package/src/contract/notifications.ts +47 -0
- package/src/contract/permissions.ts +197 -0
- package/src/contract/router.ts +857 -0
- package/src/kql/ast.ts +136 -0
- package/src/kql/dates.ts +62 -0
- package/src/kql/fields.ts +150 -0
- package/src/kql/index.ts +15 -0
- package/src/kql/kql.test.ts +371 -0
- package/src/kql/lexer.ts +172 -0
- package/src/kql/parser.ts +300 -0
- package/src/kql/suggest.ts +102 -0
- package/src/kql/validate.ts +89 -0
package/src/kql/ast.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KQL — Kern Query Language (JQL-like) abstract syntax tree.
|
|
3
|
+
* Produced by `parse()`, consumed by the SQL compiler and the visual filter builder.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type KqlOp =
|
|
7
|
+
| '='
|
|
8
|
+
| '!='
|
|
9
|
+
| '<'
|
|
10
|
+
| '<='
|
|
11
|
+
| '>'
|
|
12
|
+
| '>='
|
|
13
|
+
| '~'
|
|
14
|
+
| '!~'
|
|
15
|
+
| 'in'
|
|
16
|
+
| 'not-in'
|
|
17
|
+
| 'is-empty'
|
|
18
|
+
| 'is-not-empty'
|
|
19
|
+
|
|
20
|
+
export interface Span {
|
|
21
|
+
start: number
|
|
22
|
+
end: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type KqlValue =
|
|
26
|
+
| { kind: 'string'; value: string; span: Span }
|
|
27
|
+
| { kind: 'number'; value: number; span: Span }
|
|
28
|
+
| { kind: 'date'; value: string; span: Span }
|
|
29
|
+
/** relative date `-7d` / `+2w`; units d(ay) w(eek) m(onth) y(ear), h(our) */
|
|
30
|
+
| { kind: 'reldate'; amount: number; unit: 'h' | 'd' | 'w' | 'm' | 'y'; span: Span }
|
|
31
|
+
| { kind: 'bool'; value: boolean; span: Span }
|
|
32
|
+
| { kind: 'null'; span: Span }
|
|
33
|
+
/** bareword like `done` or `high` */
|
|
34
|
+
| { kind: 'ident'; value: string; span: Span }
|
|
35
|
+
/** function call: currentUser(), membersOf("qa"), startOfWeek(-1) */
|
|
36
|
+
| { kind: 'func'; name: string; args: KqlValue[]; span: Span }
|
|
37
|
+
|
|
38
|
+
export type KqlExpr =
|
|
39
|
+
| { kind: 'and'; children: KqlExpr[]; span: Span }
|
|
40
|
+
| { kind: 'or'; children: KqlExpr[]; span: Span }
|
|
41
|
+
| { kind: 'not'; child: KqlExpr; span: Span }
|
|
42
|
+
| KqlComparison
|
|
43
|
+
|
|
44
|
+
export interface KqlComparison {
|
|
45
|
+
kind: 'cmp'
|
|
46
|
+
field: string
|
|
47
|
+
op: KqlOp
|
|
48
|
+
/** absent for is-empty / is-not-empty */
|
|
49
|
+
value: KqlValue | null
|
|
50
|
+
/** list for in / not-in */
|
|
51
|
+
values: KqlValue[] | null
|
|
52
|
+
span: Span
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface KqlOrder {
|
|
56
|
+
field: string
|
|
57
|
+
dir: 'asc' | 'desc'
|
|
58
|
+
span: Span
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface KqlQuery {
|
|
62
|
+
where: KqlExpr | null
|
|
63
|
+
orderBy: KqlOrder[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Walk every comparison in an expression. */
|
|
67
|
+
export function walkComparisons(expr: KqlExpr | null, fn: (cmp: KqlComparison) => void): void {
|
|
68
|
+
if (!expr) return
|
|
69
|
+
if (expr.kind === 'cmp') fn(expr)
|
|
70
|
+
else if (expr.kind === 'not') walkComparisons(expr.child, fn)
|
|
71
|
+
else for (const c of expr.children) walkComparisons(c, fn)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Collect field names used in where + order by. */
|
|
75
|
+
export function fieldsUsed(query: KqlQuery): string[] {
|
|
76
|
+
const out = new Set<string>()
|
|
77
|
+
walkComparisons(query.where, (c) => out.add(c.field))
|
|
78
|
+
for (const o of query.orderBy) out.add(o.field)
|
|
79
|
+
return [...out]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pretty-print a value back to KQL text. */
|
|
83
|
+
export function printValue(v: KqlValue): string {
|
|
84
|
+
switch (v.kind) {
|
|
85
|
+
case 'string':
|
|
86
|
+
return JSON.stringify(v.value)
|
|
87
|
+
case 'number':
|
|
88
|
+
return String(v.value)
|
|
89
|
+
case 'date':
|
|
90
|
+
return v.value
|
|
91
|
+
case 'reldate':
|
|
92
|
+
return `${v.amount >= 0 ? '+' : ''}${v.amount}${v.unit}`
|
|
93
|
+
case 'bool':
|
|
94
|
+
return v.value ? 'true' : 'false'
|
|
95
|
+
case 'null':
|
|
96
|
+
return 'null'
|
|
97
|
+
case 'ident':
|
|
98
|
+
return /^[A-Za-z0-9_.-]+$/.test(v.value) ? v.value : JSON.stringify(v.value)
|
|
99
|
+
case 'func':
|
|
100
|
+
return `${v.name}(${v.args.map(printValue).join(', ')})`
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Pretty-print a whole query (normalised form). */
|
|
105
|
+
export function printQuery(query: KqlQuery): string {
|
|
106
|
+
const expr = (e: KqlExpr, parent?: 'and' | 'or'): string => {
|
|
107
|
+
switch (e.kind) {
|
|
108
|
+
case 'cmp': {
|
|
109
|
+
if (e.op === 'is-empty') return `${e.field} is empty`
|
|
110
|
+
if (e.op === 'is-not-empty') return `${e.field} is not empty`
|
|
111
|
+
if (e.op === 'in' || e.op === 'not-in')
|
|
112
|
+
return `${e.field} ${e.op === 'in' ? 'in' : 'not in'} (${(e.values ?? []).map(printValue).join(', ')})`
|
|
113
|
+
return `${e.field} ${e.op} ${e.value ? printValue(e.value) : ''}`.trim()
|
|
114
|
+
}
|
|
115
|
+
case 'not': {
|
|
116
|
+
const inner = expr(e.child)
|
|
117
|
+
return e.child.kind === 'cmp' ? `not ${inner}` : `not (${inner})`
|
|
118
|
+
}
|
|
119
|
+
case 'and': {
|
|
120
|
+
const s = e.children.map((c) => expr(c, 'and')).join(' and ')
|
|
121
|
+
return parent === 'or' ? s : s
|
|
122
|
+
}
|
|
123
|
+
case 'or': {
|
|
124
|
+
const s = e.children.map((c) => expr(c, 'or')).join(' or ')
|
|
125
|
+
return parent === 'and' ? `(${s})` : s
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const parts: string[] = []
|
|
130
|
+
if (query.where) parts.push(expr(query.where))
|
|
131
|
+
if (query.orderBy.length)
|
|
132
|
+
parts.push(
|
|
133
|
+
`order by ${query.orderBy.map((o) => `${o.field}${o.dir === 'desc' ? ' desc' : ''}`).join(', ')}`,
|
|
134
|
+
)
|
|
135
|
+
return parts.join(' ')
|
|
136
|
+
}
|
package/src/kql/dates.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date maths for KQL literals and functions. Everything is computed in UTC: the tracker stores
|
|
3
|
+
* timestamps with time zone, and a query must mean the same thing wherever it runs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type RelUnit = 'h' | 'd' | 'w' | 'm' | 'y'
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 86_400_000
|
|
9
|
+
|
|
10
|
+
/** Apply a relative offset (`-7d`, `+2w`) to a base instant. */
|
|
11
|
+
export function shift(base: Date, amount: number, unit: RelUnit): Date {
|
|
12
|
+
const d = new Date(base.getTime())
|
|
13
|
+
switch (unit) {
|
|
14
|
+
case 'h':
|
|
15
|
+
return new Date(d.getTime() + amount * 3_600_000)
|
|
16
|
+
case 'd':
|
|
17
|
+
return new Date(d.getTime() + amount * DAY_MS)
|
|
18
|
+
case 'w':
|
|
19
|
+
return new Date(d.getTime() + amount * 7 * DAY_MS)
|
|
20
|
+
case 'm': {
|
|
21
|
+
const target = new Date(d.getTime())
|
|
22
|
+
const day = target.getUTCDate()
|
|
23
|
+
target.setUTCDate(1)
|
|
24
|
+
target.setUTCMonth(target.getUTCMonth() + Math.trunc(amount))
|
|
25
|
+
// clamp (31 Jan + 1m → 28/29 Feb)
|
|
26
|
+
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate()
|
|
27
|
+
target.setUTCDate(Math.min(day, lastDay))
|
|
28
|
+
return target
|
|
29
|
+
}
|
|
30
|
+
case 'y': {
|
|
31
|
+
const target = new Date(d.getTime())
|
|
32
|
+
target.setUTCFullYear(target.getUTCFullYear() + Math.trunc(amount))
|
|
33
|
+
return target
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function startOfDay(base: Date, offsetDays = 0): Date {
|
|
39
|
+
const d = new Date(base.getTime() + offsetDays * DAY_MS)
|
|
40
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Start of the ISO week (Monday) containing `base`, shifted by `offsetWeeks`. */
|
|
44
|
+
export function startOfWeek(base: Date, offsetWeeks = 0, weekStartsOn = 1): Date {
|
|
45
|
+
const day = startOfDay(base)
|
|
46
|
+
const diff = (day.getUTCDay() - weekStartsOn + 7) % 7
|
|
47
|
+
return new Date(day.getTime() - diff * DAY_MS + offsetWeeks * 7 * DAY_MS)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function startOfMonth(base: Date, offsetMonths = 0): Date {
|
|
51
|
+
return new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + offsetMonths, 1))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `YYYY-MM-DD` in UTC. */
|
|
55
|
+
export const dateOnly = (d: Date): string => d.toISOString().slice(0, 10)
|
|
56
|
+
|
|
57
|
+
/** Parse a KQL date literal (`2026-08-22` or a full ISO timestamp) into an instant. */
|
|
58
|
+
export function parseDateLiteral(value: string): Date | null {
|
|
59
|
+
const iso = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : value.replace(' ', 'T')
|
|
60
|
+
const d = new Date(iso)
|
|
61
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
62
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { FieldType } from '../contract/models.js'
|
|
2
|
+
import type { KqlOp } from './ast.js'
|
|
3
|
+
|
|
4
|
+
export type KqlFieldKind =
|
|
5
|
+
| 'text'
|
|
6
|
+
| 'enum'
|
|
7
|
+
| 'user'
|
|
8
|
+
| 'number'
|
|
9
|
+
| 'date'
|
|
10
|
+
| 'datetime'
|
|
11
|
+
| 'boolean'
|
|
12
|
+
| 'ref'
|
|
13
|
+
| 'id'
|
|
14
|
+
| 'key'
|
|
15
|
+
|
|
16
|
+
export interface KqlField {
|
|
17
|
+
name: string
|
|
18
|
+
kind: KqlFieldKind
|
|
19
|
+
label: string
|
|
20
|
+
/** value is a uuid[] column (membership semantics for = / in) */
|
|
21
|
+
array?: boolean
|
|
22
|
+
sortable?: boolean
|
|
23
|
+
/** enum values for autocomplete/validation */
|
|
24
|
+
enumValues?: readonly string[]
|
|
25
|
+
/** ref fields resolve barewords/strings by name via the resolver (labels, cycles…) */
|
|
26
|
+
refType?:
|
|
27
|
+
| 'project'
|
|
28
|
+
| 'type'
|
|
29
|
+
| 'label'
|
|
30
|
+
| 'component'
|
|
31
|
+
| 'version'
|
|
32
|
+
| 'cycle'
|
|
33
|
+
| 'milestone'
|
|
34
|
+
| 'status'
|
|
35
|
+
| 'issue'
|
|
36
|
+
custom?: { key: string; fieldType: FieldType }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const EQ: KqlOp[] = ['=', '!=', 'in', 'not-in']
|
|
40
|
+
const EMPTY: KqlOp[] = ['is-empty', 'is-not-empty']
|
|
41
|
+
const CMP: KqlOp[] = ['<', '<=', '>', '>=']
|
|
42
|
+
const LIKE: KqlOp[] = ['~', '!~']
|
|
43
|
+
|
|
44
|
+
export function operatorsFor(f: KqlField): KqlOp[] {
|
|
45
|
+
switch (f.kind) {
|
|
46
|
+
case 'text':
|
|
47
|
+
return [...LIKE, '=', '!=', ...EMPTY]
|
|
48
|
+
case 'enum':
|
|
49
|
+
return [...EQ, ...CMP, ...EMPTY]
|
|
50
|
+
case 'user':
|
|
51
|
+
return [...EQ, ...EMPTY]
|
|
52
|
+
case 'number':
|
|
53
|
+
return ['=', '!=', ...CMP, 'in', 'not-in', ...EMPTY]
|
|
54
|
+
case 'date':
|
|
55
|
+
case 'datetime':
|
|
56
|
+
return ['=', '!=', ...CMP, ...EMPTY]
|
|
57
|
+
case 'boolean':
|
|
58
|
+
return ['=', '!=']
|
|
59
|
+
case 'ref':
|
|
60
|
+
case 'id':
|
|
61
|
+
return [...EQ, ...EMPTY]
|
|
62
|
+
case 'key':
|
|
63
|
+
return [...EQ, ...LIKE]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent'] as const
|
|
68
|
+
export const STATUS_CATEGORIES = ['backlog', 'todo', 'in_progress', 'done', 'cancelled', 'triage'] as const
|
|
69
|
+
|
|
70
|
+
/** System fields available in every KQL query. Custom fields are added as `cf.<key>`. */
|
|
71
|
+
export const SYSTEM_FIELDS: readonly KqlField[] = [
|
|
72
|
+
{ name: 'key', kind: 'key', label: 'Issue key', sortable: true },
|
|
73
|
+
{ name: 'project', kind: 'ref', refType: 'project', label: 'Project', sortable: true },
|
|
74
|
+
{ name: 'type', kind: 'ref', refType: 'type', label: 'Type', sortable: true },
|
|
75
|
+
{ name: 'title', kind: 'text', label: 'Title', sortable: true },
|
|
76
|
+
{ name: 'status', kind: 'ref', refType: 'status', label: 'Status', sortable: true },
|
|
77
|
+
{
|
|
78
|
+
name: 'statusCategory',
|
|
79
|
+
kind: 'enum',
|
|
80
|
+
label: 'Status category',
|
|
81
|
+
enumValues: STATUS_CATEGORIES,
|
|
82
|
+
sortable: true,
|
|
83
|
+
},
|
|
84
|
+
{ name: 'priority', kind: 'enum', label: 'Priority', enumValues: PRIORITIES, sortable: true },
|
|
85
|
+
{ name: 'assignee', kind: 'user', array: true, label: 'Assignee' },
|
|
86
|
+
{ name: 'reporter', kind: 'user', label: 'Reporter' },
|
|
87
|
+
{ name: 'label', kind: 'ref', refType: 'label', array: true, label: 'Label' },
|
|
88
|
+
{ name: 'component', kind: 'ref', refType: 'component', array: true, label: 'Component' },
|
|
89
|
+
{ name: 'version', kind: 'ref', refType: 'version', array: true, label: 'Fix version' },
|
|
90
|
+
{ name: 'affectsVersion', kind: 'ref', refType: 'version', array: true, label: 'Affects version' },
|
|
91
|
+
{ name: 'cycle', kind: 'ref', refType: 'cycle', label: 'Cycle', sortable: true },
|
|
92
|
+
{ name: 'milestone', kind: 'ref', refType: 'milestone', label: 'Milestone', sortable: true },
|
|
93
|
+
{ name: 'parent', kind: 'ref', refType: 'issue', label: 'Parent', sortable: true },
|
|
94
|
+
{ name: 'created', kind: 'datetime', label: 'Created', sortable: true },
|
|
95
|
+
{ name: 'updated', kind: 'datetime', label: 'Updated', sortable: true },
|
|
96
|
+
{ name: 'completed', kind: 'datetime', label: 'Completed', sortable: true },
|
|
97
|
+
{ name: 'due', kind: 'date', label: 'Due date', sortable: true },
|
|
98
|
+
{ name: 'start', kind: 'date', label: 'Start date', sortable: true },
|
|
99
|
+
{ name: 'estimate', kind: 'number', label: 'Estimate', sortable: true },
|
|
100
|
+
{ name: 'timeSpent', kind: 'number', label: 'Time spent (s)', sortable: true },
|
|
101
|
+
{ name: 'watcher', kind: 'user', array: true, label: 'Watcher' },
|
|
102
|
+
{ name: 'resolution', kind: 'text', label: 'Resolution', sortable: true },
|
|
103
|
+
{ name: 'text', kind: 'text', label: 'Full text' },
|
|
104
|
+
{ name: 'triage', kind: 'boolean', label: 'In triage', sortable: true },
|
|
105
|
+
{ name: 'archived', kind: 'boolean', label: 'Archived' },
|
|
106
|
+
/** sort-only pseudo field (manual order) */
|
|
107
|
+
{ name: 'rank', kind: 'text', label: 'Manual rank', sortable: true },
|
|
108
|
+
] as const
|
|
109
|
+
|
|
110
|
+
export const KQL_FUNCTIONS = [
|
|
111
|
+
{ name: 'currentUser', args: 0, detail: 'The authenticated user' },
|
|
112
|
+
{ name: 'now', args: 0, detail: 'Current timestamp' },
|
|
113
|
+
{ name: 'startOfDay', args: '0-1', detail: 'Midnight today (optional day offset)' },
|
|
114
|
+
{ name: 'startOfWeek', args: '0-1', detail: 'Start of this week (optional week offset)' },
|
|
115
|
+
{ name: 'startOfMonth', args: '0-1', detail: 'Start of this month (optional month offset)' },
|
|
116
|
+
{ name: 'membersOf', args: 1, detail: 'Members of a workspace group' },
|
|
117
|
+
{ name: 'activeCycle', args: 0, detail: 'The active cycle(s) of the queried projects' },
|
|
118
|
+
{ name: 'openCycles', args: 0, detail: 'Active + upcoming cycles' },
|
|
119
|
+
] as const
|
|
120
|
+
|
|
121
|
+
export function findField(fields: readonly KqlField[], name: string): KqlField | undefined {
|
|
122
|
+
const lower = name.toLowerCase()
|
|
123
|
+
return fields.find((f) => f.name.toLowerCase() === lower)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Map a custom field def to a KQL field (`cf.<key>`). */
|
|
127
|
+
export function customKqlField(key: string, fieldType: FieldType, label: string): KqlField {
|
|
128
|
+
const kind: KqlFieldKind =
|
|
129
|
+
fieldType === 'number' || fieldType === 'formula'
|
|
130
|
+
? 'number'
|
|
131
|
+
: fieldType === 'date'
|
|
132
|
+
? 'date'
|
|
133
|
+
: fieldType === 'datetime'
|
|
134
|
+
? 'datetime'
|
|
135
|
+
: fieldType === 'checkbox'
|
|
136
|
+
? 'boolean'
|
|
137
|
+
: fieldType === 'user' || fieldType === 'multiuser'
|
|
138
|
+
? 'user'
|
|
139
|
+
: fieldType === 'select' || fieldType === 'multiselect' || fieldType === 'label'
|
|
140
|
+
? 'enum'
|
|
141
|
+
: 'text'
|
|
142
|
+
return {
|
|
143
|
+
name: `cf.${key}`,
|
|
144
|
+
kind,
|
|
145
|
+
label,
|
|
146
|
+
array: fieldType === 'multiselect' || fieldType === 'multiuser' || fieldType === 'label',
|
|
147
|
+
sortable: kind === 'number' || kind === 'date' || kind === 'datetime' || kind === 'text',
|
|
148
|
+
custom: { key, fieldType },
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/kql/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KQL — Kern Query Language.
|
|
3
|
+
*
|
|
4
|
+
* A JQL-like query language over tracker issues: `assignee = currentUser() and status != done
|
|
5
|
+
* order by priority desc`. This entry point is isomorphic (no database, no Node built-ins) so the
|
|
6
|
+
* app can parse, validate and autocomplete a query while the user types; the SQL compiler that turns
|
|
7
|
+
* a parsed query into a statement lives server-side in `@kernhq/module-tracker/server`.
|
|
8
|
+
*/
|
|
9
|
+
export * from './ast.js'
|
|
10
|
+
export * from './dates.js'
|
|
11
|
+
export * from './fields.js'
|
|
12
|
+
export { type LexError, type LexResult, type Token, type TokenKind, tokenize } from './lexer.js'
|
|
13
|
+
export { type ParseError, type ParseResult, parseKql } from './parser.js'
|
|
14
|
+
export { suggest } from './suggest.js'
|
|
15
|
+
export { type KqlIssue, validateQuery } from './validate.js'
|