@kernhq/module-tracker 0.7.3 → 0.8.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-tracker",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
4
  "description": "Kern tracker module: projects, work item types, custom fields, workflows, issues, KQL, cycles, views, reports, intake, time tracking (server + client skeleton)",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -57,4 +57,5 @@ export {
57
57
  rankForIndex,
58
58
  rankSequence,
59
59
  } from './rank.js'
60
+ export { describeApprovers, describeRule, type RuleDescription } from './rules.js'
60
61
  export type * from './types.js'
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { describeApprovers, describeRule } from './rules.js'
3
+
4
+ const say = (type: string, config?: unknown) => describeRule({ type, config }).text
5
+
6
+ describe('describeRule', () => {
7
+ it('says who may do it', () => {
8
+ expect(say('user.isAssignee')).toBe('Only the assignee')
9
+ expect(say('user.hasPermission', { permission: 'tracker.issue.transition' })).toBe(
10
+ 'Only somebody with “tracker.issue.transition”',
11
+ )
12
+ })
13
+
14
+ it('says what has to be true first', () => {
15
+ expect(say('subtasks.allDone')).toBe('Only when every sub-issue is done')
16
+ expect(say('field.equals', { field: 'priority', value: 'urgent' })).toBe('Only when priority is “urgent”')
17
+ })
18
+
19
+ it('says what has to be filled in', () => {
20
+ expect(say('comment.required')).toBe('A comment is required')
21
+ expect(say('field.required', { field: 'resolution' })).toBe('resolution must be filled in')
22
+ })
23
+
24
+ it('says what happens afterwards, in the terms the config uses', () => {
25
+ expect(say('assign.to', { to: 'currentUser' })).toBe('Assigns it to whoever moved it')
26
+ expect(say('assign.to', { to: 'unassigned' })).toBe('Assigns it to nobody')
27
+ expect(say('resolution.set', { value: 'done' })).toBe('Sets the resolution to “done”')
28
+ expect(say('resolution.set', { value: null })).toBe('Clears the resolution')
29
+ expect(say('notify', { subjects: [{ kind: 'assignee' }, { kind: 'reporter' }] })).toBe(
30
+ 'Notifies the assignee, the reporter',
31
+ )
32
+ })
33
+
34
+ it('copes with a rule that arrives without its configuration', () => {
35
+ // A definition written by hand, or one from an older server.
36
+ expect(say('field.required')).toBe('A field must be filled in')
37
+ expect(say('webhook')).toBe('Calls a webhook')
38
+ })
39
+
40
+ it('names a rule it does not know rather than guessing', () => {
41
+ // A rule from a newer server, or one an extension added.
42
+ const described = describeRule({ type: 'acme.custom_check' })
43
+ expect(described.text).toBe('acme.custom_check')
44
+ expect(described.unknown).toBe(true)
45
+ })
46
+ })
47
+
48
+ describe('describeApprovers', () => {
49
+ it('says how many and from whom', () => {
50
+ expect(describeApprovers([{ kind: 'role', id: 'admin' }], 1)).toBe(
51
+ 'Approval from anyone with the admin role',
52
+ )
53
+ expect(describeApprovers([{ kind: 'projectLead' }], 2)).toBe('2 approvals from the project lead')
54
+ })
55
+
56
+ it('says nobody when the list is empty, which is a workflow that can never proceed', () => {
57
+ expect(describeApprovers([], 1)).toBe('Approval from nobody')
58
+ })
59
+ })
@@ -0,0 +1,125 @@
1
+ import type { RuleRef } from '@kernhq/workflow'
2
+
3
+ /**
4
+ * A workflow rule, said the way an administrator thinks about it.
5
+ *
6
+ * A transition's conditions, validators and post-functions are stored as `{type, config}`, which is
7
+ * the right thing to store and the wrong thing to show: an editor that renders JSON asks somebody
8
+ * to read a data structure to answer "who is allowed to close this".
9
+ *
10
+ * This lives in the module that owns the rules, so a rule and the sentence describing it cannot
11
+ * drift into different repositories. Every rule type the registry defines has a case; an unknown
12
+ * one — a rule from a newer server, or one an extension added — says its type rather than
13
+ * pretending to know it.
14
+ */
15
+
16
+ export interface RuleDescription {
17
+ /** the sentence to show */
18
+ text: string
19
+ /** true when nothing here understood the rule, so an interface can mark it as such */
20
+ unknown: boolean
21
+ }
22
+
23
+ type Config = Record<string, unknown>
24
+
25
+ const str = (config: Config, key: string, fallback = '') => {
26
+ const value = config[key]
27
+ return typeof value === 'string' && value ? value : fallback
28
+ }
29
+
30
+ /** `{kind, id}` subjects, as a list somebody can read. */
31
+ function describeSubjects(value: unknown): string {
32
+ if (!Array.isArray(value) || !value.length) return 'nobody'
33
+ return value
34
+ .map((entry) => {
35
+ const subject = (entry ?? {}) as { kind?: string; id?: string }
36
+ switch (subject.kind) {
37
+ case 'assignee':
38
+ return 'the assignee'
39
+ case 'reporter':
40
+ return 'the reporter'
41
+ case 'projectLead':
42
+ return 'the project lead'
43
+ case 'group':
44
+ return 'a group'
45
+ case 'role':
46
+ return subject.id ? `anyone with the ${subject.id} role` : 'a role'
47
+ case 'user':
48
+ return 'a named person'
49
+ case 'field':
50
+ return subject.id ? `whoever is in ${subject.id}` : 'a field'
51
+ default:
52
+ return subject.kind ?? 'somebody'
53
+ }
54
+ })
55
+ .join(', ')
56
+ }
57
+
58
+ export function describeRule(rule: RuleRef): RuleDescription {
59
+ const config = (rule.config ?? {}) as Config
60
+ const known = (text: string): RuleDescription => ({ text, unknown: false })
61
+
62
+ switch (rule.type) {
63
+ // conditions — who may do it, and when
64
+ case 'user.hasPermission':
65
+ return known(`Only somebody with “${str(config, 'permission', 'a permission')}”`)
66
+ case 'user.isAssignee':
67
+ return known('Only the assignee')
68
+ case 'user.isReporter':
69
+ return known('Only the reporter')
70
+ case 'user.inGroup':
71
+ return known('Only members of a particular group')
72
+ case 'field.equals':
73
+ return known(`Only when ${str(config, 'field', 'a field')} is “${String(config.value ?? '')}”`)
74
+ case 'field.notEmpty':
75
+ return known(`Only when ${str(config, 'field', 'a field')} has a value`)
76
+ case 'subtasks.allDone':
77
+ return known('Only when every sub-issue is done')
78
+
79
+ // validators — what has to be filled in first
80
+ case 'field.required':
81
+ return known(`${str(config, 'field', 'A field')} must be filled in`)
82
+ case 'comment.required':
83
+ return known('A comment is required')
84
+ case 'estimate.required':
85
+ return known('An estimate is required')
86
+
87
+ // post-functions — what happens afterwards
88
+ case 'field.set':
89
+ return known(`Sets ${str(config, 'field', 'a field')} to “${String(config.value ?? '')}”`)
90
+ case 'assign.to': {
91
+ const to = str(config, 'to')
92
+ const who =
93
+ to === 'currentUser'
94
+ ? 'whoever moved it'
95
+ : to === 'reporter'
96
+ ? 'the reporter'
97
+ : to === 'unassigned'
98
+ ? 'nobody'
99
+ : 'a named person'
100
+ return known(`Assigns it to ${who}`)
101
+ }
102
+ case 'resolution.set': {
103
+ const value = config.value
104
+ return known(value === null ? 'Clears the resolution' : `Sets the resolution to “${String(value)}”`)
105
+ }
106
+ case 'notify':
107
+ return known(`Notifies ${describeSubjects(config.subjects)}`)
108
+ case 'webhook':
109
+ return known(`Calls ${str(config, 'url', 'a webhook')}`)
110
+ case 'subitem.create':
111
+ return known(`Creates a sub-issue called “${str(config, 'title', 'something')}”`)
112
+ case 'run.automation':
113
+ return known('Runs an automation')
114
+
115
+ default:
116
+ // Naming the type is more use than a guess: somebody can search for it.
117
+ return { text: rule.type, unknown: true }
118
+ }
119
+ }
120
+
121
+ /** Who may approve, for a transition that needs sign-off. */
122
+ export function describeApprovers(subjects: unknown, minApprovals: number): string {
123
+ const who = describeSubjects(subjects)
124
+ return minApprovals > 1 ? `${minApprovals} approvals from ${who}` : `Approval from ${who}`
125
+ }
@@ -32,6 +32,7 @@ export type {
32
32
  IssueQueryInput,
33
33
  IssueQueryResult,
34
34
  IssueSummary,
35
+ IssueTemplate,
35
36
  KqlFieldInfo,
36
37
  KqlParseResult,
37
38
  KqlSuggestion,
@@ -47,6 +48,8 @@ export type {
47
48
  ProjectTemplateBody,
48
49
  ProjectTemplateId,
49
50
  ReactionSummary,
51
+ RecurrenceRule,
52
+ RecurringIssue,
50
53
  RelationSummary,
51
54
  RelationType,
52
55
  RelationView,