@basementuniverse/kanbn 2.1.0 → 2.5.0

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.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/coverage/tmp/coverage-916017-1788028598821-0.json +1 -0
  3. package/coverage/tmp/{coverage-214293-1787777184569-0.json → coverage-916018-1788028597179-0.json} +1 -1
  4. package/coverage/tmp/coverage-916036-1788028598796-0.json +1 -0
  5. package/docs/actions.md +337 -0
  6. package/docs/advanced-configuration.md +32 -0
  7. package/docs/commands/add.txt +8 -1
  8. package/docs/commands/archive.txt +6 -0
  9. package/docs/commands/board.txt +4 -0
  10. package/docs/commands/burndown.txt +1 -0
  11. package/docs/commands/comment.txt +8 -1
  12. package/docs/commands/contributors.txt +58 -0
  13. package/docs/commands/edit.txt +13 -1
  14. package/docs/commands/find.txt +28 -1
  15. package/docs/commands/gantt.txt +1 -0
  16. package/docs/commands/help.txt +1 -0
  17. package/docs/commands/history.txt +1 -0
  18. package/docs/commands/move.txt +17 -0
  19. package/docs/commands/remove.txt +10 -0
  20. package/docs/commands/restore.txt +6 -0
  21. package/docs/commands/sort.txt +18 -0
  22. package/docs/commands/task.txt +4 -0
  23. package/docs/commands/validate.txt +14 -1
  24. package/docs/contributors.md +145 -0
  25. package/docs/filtering-and-sorting.md +60 -3
  26. package/docs/index-structure.md +119 -11
  27. package/docs/index.md +3 -1
  28. package/docs/multiple-boards.md +1 -0
  29. package/docs/task-structure.md +9 -2
  30. package/example/advanced/kanbn.yml +65 -0
  31. package/package.json +1 -1
  32. package/routes/add.json +5 -1
  33. package/routes/archive.json +6 -2
  34. package/routes/comment.json +5 -1
  35. package/routes/contributors.json +18 -0
  36. package/routes/edit.json +5 -1
  37. package/routes/move.json +4 -2
  38. package/routes/remove.json +6 -2
  39. package/routes/restore.json +6 -0
  40. package/routes/sort.json +5 -0
  41. package/src/actions.js +904 -0
  42. package/src/board.js +24 -1
  43. package/src/controller/add.js +21 -10
  44. package/src/controller/archive.js +1 -0
  45. package/src/controller/board.js +13 -4
  46. package/src/controller/burndown.js +5 -2
  47. package/src/controller/comment.js +5 -2
  48. package/src/controller/contributors.js +166 -0
  49. package/src/controller/edit.js +40 -14
  50. package/src/controller/find.js +75 -4
  51. package/src/controller/gantt.js +5 -2
  52. package/src/controller/history.js +5 -2
  53. package/src/controller/move.js +84 -10
  54. package/src/controller/remove.js +39 -2
  55. package/src/controller/restore.js +1 -0
  56. package/src/controller/sort.js +40 -0
  57. package/src/controller/task.js +12 -1
  58. package/src/controller/validate.js +141 -4
  59. package/src/git-user-name.js +5 -15
  60. package/src/git-user.js +55 -0
  61. package/src/main.d.ts +204 -4
  62. package/src/main.js +1412 -38
  63. package/src/parse-index.js +205 -17
  64. package/src/utility.js +135 -1
  65. package/coverage/tmp/coverage-214292-1787777191526-0.json +0 -1
package/src/actions.js ADDED
@@ -0,0 +1,904 @@
1
+ const yaml = require('yamljs');
2
+
3
+ /**
4
+ * The events a rule can listen for
5
+ *
6
+ * Mutation-only, by design: nothing here fires during a read, so `board`, `status`, `find` and
7
+ * `burndown` stay a pure function of the files on disk
8
+ */
9
+ const EVENT_TYPES = [
10
+ 'task.created',
11
+ 'task.moved',
12
+ 'task.updated',
13
+ 'task.commented',
14
+ 'task.archived',
15
+ 'task.restored',
16
+ 'task.deleted',
17
+ 'task.addedToBoard',
18
+ 'task.started',
19
+ 'task.completed'
20
+ ];
21
+
22
+ // Events that aren't operations in their own right. They're computed as transitions - the predicate
23
+ // was false before the operation and true after it - so a rule meaning "when this is done" doesn't
24
+ // have to duplicate the column list that config already holds
25
+ const DERIVED_EVENT_TYPES = ['task.started', 'task.completed'];
26
+
27
+ // Operations that affect every board rather than the one the command was run on. Only rules on these
28
+ // events may set `anyBoard`, because only these events have a board other than the acting one that
29
+ // might want to hear about them
30
+ const WORKSPACE_WIDE_EVENTS = ['task.archived', 'task.restored', 'task.deleted'];
31
+
32
+ // Events where the task is leaving the board, so moving it makes no sense
33
+ const LEAVING_EVENTS = ['task.archived', 'task.deleted'];
34
+
35
+ // Events that have to act on other tasks, because acting on the task itself would be a write to a
36
+ // file that's about to be removed
37
+ const CROSS_TASK_ONLY_EVENTS = ['task.deleted'];
38
+
39
+ // The payload keys each event carries, beyond the board keys every event has. Used to check
40
+ // `@event.<key>` substitutions and `when` clauses that name an event key
41
+ const EVENT_PAYLOAD_KEYS = {
42
+ 'task.created': ['column'],
43
+ 'task.moved': ['fromColumn', 'toColumn'],
44
+ 'task.updated': ['changedFields', 'unsetFields'],
45
+ 'task.commented': ['comment'],
46
+ 'task.archived': ['fromColumn'],
47
+ 'task.restored': ['toColumn'],
48
+ 'task.deleted': ['fromColumn', 'removeFile', 'allBoards'],
49
+ 'task.addedToBoard': ['column', 'board']
50
+ };
51
+
52
+ // Board keys every event carries
53
+ const EVENT_BOARD_KEYS = ['board', 'boardSlug', 'isMainBoard'];
54
+
55
+ // The verbs a rule can use. Closed by design: the moment this needs a condition, a comparison or a
56
+ // variable it didn't come with, the answer is a script the user writes outside Kanbn
57
+ const VERBS = [
58
+ 'set',
59
+ 'unset',
60
+ 'assign',
61
+ 'addTag',
62
+ 'removeTag',
63
+ 'setProgress',
64
+ 'comment',
65
+ 'move',
66
+ 'addToBoard'
67
+ ];
68
+
69
+ // Metadata fields Kanbn manages, which a rule may not write
70
+ const RESERVED_FIELDS = ['created', 'updated'];
71
+
72
+ // The author recorded against history events and comments a rule writes, so that an action-driven
73
+ // change is distinguishable from a human one
74
+ const ACTION_AUTHOR_PREFIX = '@kanbn';
75
+
76
+ /**
77
+ * Normalise a relation type for comparison. Relation types are free text in a task file, so
78
+ * "Child Of", "child-of" and "child of" all mean the same thing
79
+ * @param {any} relationType
80
+ * @return {string} The normalised relation type
81
+ */
82
+ function normaliseRelationType(relationType) {
83
+ return String(relationType || '')
84
+ .trim()
85
+ .toLowerCase()
86
+ .replace(/[\s_]+/g, '-');
87
+ }
88
+
89
+ /**
90
+ * Coerce a value into an array, treating null and undefined as empty
91
+ * @param {any} value
92
+ * @return {any[]} The value as an array
93
+ */
94
+ function toArray(value) {
95
+ if (value === null || value === undefined) {
96
+ return [];
97
+ }
98
+ return Array.isArray(value) ? value : [value];
99
+ }
100
+
101
+ /**
102
+ * Get the author to record against anything a rule writes
103
+ * @param {object} rule The rule
104
+ * @return {string} The author
105
+ */
106
+ function ruleAuthor(rule) {
107
+ return rule.name ? `${ACTION_AUTHOR_PREFIX}/${rule.name}` : ACTION_AUTHOR_PREFIX;
108
+ }
109
+
110
+ /**
111
+ * Describe a rule for an error or warning message
112
+ * @param {object} rule The rule
113
+ * @param {number} i The rule's position in the list
114
+ * @return {string} A label for the rule
115
+ */
116
+ function ruleLabel(rule, i) {
117
+ return rule && rule.name ? `rule "${rule.name}"` : `rule ${i + 1}`;
118
+ }
119
+
120
+ /**
121
+ * Normalise the `actions` option into a list of rules
122
+ *
123
+ * Only the shape is normalised here - whether the contents make sense is findRuleErrors()' job, so
124
+ * that a malformed rule produces a readable error instead of a type error somewhere downstream
125
+ * @param {any} actions The raw actions option
126
+ * @return {object[]} The rules
127
+ */
128
+ function normaliseRules(actions) {
129
+ return toArray(actions).map(
130
+ (rule) => (rule !== null && typeof rule === 'object' && !Array.isArray(rule) ? rule : { invalid: rule })
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Parse the contents of an actionsFile
136
+ * @param {string} contents The file contents
137
+ * @param {string} fileName The file name, for error messages
138
+ * @return {object[]} The rules
139
+ */
140
+ function parseActionsFile(contents, fileName) {
141
+ let parsed = null;
142
+ try {
143
+ parsed = contents.trim() ? yaml.parse(contents) : [];
144
+ } catch (error) {
145
+ throw new Error(`Unable to parse actionsFile "${fileName}": ${error.message}`);
146
+ }
147
+
148
+ // A file holding nothing but a list is the shape this option is for. An object with an `actions`
149
+ // key is accepted too, because it's what someone who has just moved the block out of front matter
150
+ // will write first
151
+ if (parsed !== null && !Array.isArray(parsed) && typeof parsed === 'object' && 'actions' in parsed) {
152
+ parsed = parsed.actions;
153
+ }
154
+ if (parsed === null || parsed === undefined) {
155
+ return [];
156
+ }
157
+ if (!Array.isArray(parsed)) {
158
+ throw new Error(`actionsFile "${fileName}" must contain a list of rules`);
159
+ }
160
+ return normaliseRules(parsed);
161
+ }
162
+
163
+ /**
164
+ * Find the substitution tokens used in a value, however deeply nested
165
+ * @param {any} value The value to search
166
+ * @param {string[]} [found=[]] Tokens found so far
167
+ * @return {string[]} The tokens found, including the leading @
168
+ */
169
+ function findSubstitutions(value, found = []) {
170
+ if (typeof value === 'string') {
171
+ const matches = value.match(/@[a-zA-Z][a-zA-Z0-9_.]*/g);
172
+ if (matches !== null) {
173
+ found.push(...matches);
174
+ }
175
+ return found;
176
+ }
177
+ if (Array.isArray(value)) {
178
+ value.forEach((item) => findSubstitutions(item, found));
179
+ return found;
180
+ }
181
+ if (value !== null && typeof value === 'object') {
182
+ Object.values(value).forEach((item) => findSubstitutions(item, found));
183
+ }
184
+ return found;
185
+ }
186
+
187
+ /**
188
+ * Check a list of rules and return everything wrong with them
189
+ *
190
+ * These are configuration errors rather than runtime failures: they're wrong in the file, not wrong
191
+ * at the moment they run, so validate reports them and an operation that meets one fails before
192
+ * writing anything
193
+ * @param {object[]} rules The rules to check
194
+ * @param {object} [context={}] `columns` on this board and `boards` in the workspace, when known
195
+ * @return {string[]} A list of error messages
196
+ */
197
+ function findRuleErrors(rules, context = {}) {
198
+ const errors = [];
199
+ const columns = context.columns || null;
200
+ const boards = context.boards || null;
201
+
202
+ rules.forEach((rule, i) => {
203
+ const label = ruleLabel(rule, i);
204
+ if ('invalid' in rule) {
205
+ errors.push(`${label} is not an object`);
206
+ return;
207
+ }
208
+ if ('name' in rule && typeof rule.name !== 'string') {
209
+ errors.push(`${label} has a non-string name`);
210
+ }
211
+
212
+ // Event
213
+ if (!('on' in rule)) {
214
+ errors.push(`${label} has no "on" event`);
215
+ } else if (EVENT_TYPES.indexOf(rule.on) === -1) {
216
+ errors.push(`${label} listens for unknown event "${rule.on}"`);
217
+ }
218
+ const eventType = EVENT_TYPES.indexOf(rule.on) === -1 ? null : rule.on;
219
+
220
+ // Rules that execute code were declined, so `run` is worth naming explicitly rather than
221
+ // failing as an unknown key - it's the thing someone who read an old draft will try
222
+ if ('run' in rule) {
223
+ errors.push(`${label} uses "run", which Kanbn doesn't support - actions are declarative only`);
224
+ }
225
+
226
+ // Conditions
227
+ const whenIsObject = rule.when === undefined ||
228
+ (rule.when !== null && typeof rule.when === 'object' && !Array.isArray(rule.when));
229
+ if (!whenIsObject) {
230
+ errors.push(`${label} has a "when" that isn't an object`);
231
+ }
232
+ if (eventType !== null && whenIsObject && rule.when !== undefined) {
233
+ const payloadKeys = EVENT_PAYLOAD_KEYS[eventType] || null;
234
+ for (const key of Object.keys(rule.when)) {
235
+ if (EVENT_BOARD_KEYS.indexOf(key) !== -1) {
236
+ continue;
237
+ }
238
+
239
+ // A `when` key that names an event payload key belonging to a different event is almost
240
+ // always a rule written against the wrong event, and would otherwise never match
241
+ const belongsElsewhere = Object.entries(EVENT_PAYLOAD_KEYS).some(
242
+ ([type, keys]) => type !== eventType && keys.indexOf(key) !== -1
243
+ );
244
+ if (payloadKeys !== null && payloadKeys.indexOf(key) === -1 && belongsElsewhere) {
245
+ errors.push(`${label} filters on "${key}", which ${eventType} doesn't carry`);
246
+ }
247
+ }
248
+ }
249
+
250
+ // Targets
251
+ if ('for' in rule) {
252
+ const target = rule.for;
253
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) {
254
+ errors.push(`${label} has a "for" that isn't an object`);
255
+ } else {
256
+ if ('related' in target && typeof target.related !== 'string') {
257
+ errors.push(`${label} has a "for.related" that isn't a relation type`);
258
+ }
259
+ if ('direction' in target && ['incoming', 'outgoing'].indexOf(target.direction) === -1) {
260
+ errors.push(`${label} has an unknown "for.direction" - use "incoming" or "outgoing"`);
261
+ }
262
+ if (
263
+ 'where' in target &&
264
+ (target.where === null || typeof target.where !== 'object' || Array.isArray(target.where))
265
+ ) {
266
+ errors.push(`${label} has a "for.where" that isn't an object`);
267
+ }
268
+ for (const key of Object.keys(target)) {
269
+ if (['related', 'direction', 'where'].indexOf(key) === -1) {
270
+ errors.push(`${label} has an unknown "for" key "${key}"`);
271
+ }
272
+ }
273
+ }
274
+ } else if (eventType !== null && CROSS_TASK_ONLY_EVENTS.indexOf(eventType) !== -1) {
275
+ errors.push(
276
+ `${label} listens for ${eventType} without a "for" clause - the task is being removed, so a ` +
277
+ 'rule on this event has to act on related tasks'
278
+ );
279
+ }
280
+
281
+ // Listening from other boards
282
+ if ('anyBoard' in rule) {
283
+ if (typeof rule.anyBoard !== 'boolean') {
284
+ errors.push(`${label} has a non-boolean "anyBoard"`);
285
+ } else if (eventType !== null && WORKSPACE_WIDE_EVENTS.indexOf(eventType) === -1) {
286
+ errors.push(
287
+ `${label} sets "anyBoard" on ${eventType}, which is scoped to one board - only ` +
288
+ `${WORKSPACE_WIDE_EVENTS.join(', ')} affect every board`
289
+ );
290
+ }
291
+ }
292
+
293
+ // Unknown keys, so that a typo in a rule doesn't silently do nothing
294
+ for (const key of Object.keys(rule)) {
295
+ if (['name', 'on', 'when', 'for', 'then', 'anyBoard', 'run'].indexOf(key) === -1) {
296
+ errors.push(`${label} has an unknown key "${key}"`);
297
+ }
298
+ }
299
+
300
+ // Verbs
301
+ if (!('then' in rule)) {
302
+ errors.push(`${label} has no "then" verbs`);
303
+ return;
304
+ }
305
+ if (!Array.isArray(rule.then)) {
306
+ errors.push(`${label} has a "then" that isn't a list`);
307
+ return;
308
+ }
309
+ rule.then.forEach((verb, j) => {
310
+ if (verb === null || typeof verb !== 'object' || Array.isArray(verb)) {
311
+ errors.push(`${label} verb ${j + 1} is not an object`);
312
+ return;
313
+ }
314
+ const names = Object.keys(verb);
315
+ if (names.length !== 1) {
316
+ errors.push(
317
+ `${label} verb ${j + 1} has ${names.length} keys - each verb is a single key, so write them ` +
318
+ 'as separate list items'
319
+ );
320
+ return;
321
+ }
322
+ const [name] = names;
323
+ const argument = verb[name];
324
+ if (VERBS.indexOf(name) === -1) {
325
+ errors.push(`${label} uses unknown verb "${name}"`);
326
+ return;
327
+ }
328
+ switch (name) {
329
+ case 'set':
330
+ if (argument === null || typeof argument !== 'object' || Array.isArray(argument)) {
331
+ errors.push(`${label} "set" needs an object of fields and values`);
332
+ break;
333
+ }
334
+ for (const field of Object.keys(argument)) {
335
+ if (RESERVED_FIELDS.indexOf(field) !== -1) {
336
+ errors.push(`${label} sets "${field}", which Kanbn manages`);
337
+ }
338
+ }
339
+ break;
340
+ case 'unset':
341
+ for (const field of toArray(argument)) {
342
+ if (typeof field !== 'string') {
343
+ errors.push(`${label} "unset" needs a field name or a list of them`);
344
+ } else if (RESERVED_FIELDS.indexOf(field) !== -1) {
345
+ errors.push(`${label} unsets "${field}", which Kanbn manages`);
346
+ }
347
+ }
348
+ break;
349
+ case 'assign':
350
+ if (typeof argument !== 'string') {
351
+ errors.push(`${label} "assign" needs a name`);
352
+ }
353
+ break;
354
+ case 'addTag':
355
+ case 'removeTag':
356
+ if (!toArray(argument).length || toArray(argument).some((tag) => typeof tag !== 'string')) {
357
+ errors.push(`${label} "${name}" needs a tag or a list of tags`);
358
+ }
359
+ break;
360
+ case 'setProgress':
361
+ if (typeof argument !== 'number' || argument < 0 || argument > 1) {
362
+ errors.push(`${label} "setProgress" needs a number between 0 and 1`);
363
+ }
364
+ break;
365
+ case 'comment':
366
+ if (typeof argument !== 'string' || !argument.trim()) {
367
+ errors.push(`${label} "comment" needs some text`);
368
+ }
369
+ break;
370
+ case 'move': {
371
+ const column = argument !== null && typeof argument === 'object' ? argument.column : argument;
372
+ if (typeof column !== 'string' || !column) {
373
+ errors.push(`${label} "move" needs a column`);
374
+ break;
375
+ }
376
+ // A column that comes from a substitution isn't knowable until the rule fires, so it is
377
+ // left to the runtime, which skips it with a warning rather than failing
378
+ if (columns !== null && findSubstitutions(column).length === 0 && columns.indexOf(column) === -1) {
379
+ errors.push(`${label} moves to column "${column}", which doesn't exist on this board`);
380
+ }
381
+ if (
382
+ argument !== null &&
383
+ typeof argument === 'object' &&
384
+ 'position' in argument &&
385
+ typeof argument.position !== 'number'
386
+ ) {
387
+ errors.push(`${label} "move" has a non-numeric position`);
388
+ }
389
+ if (eventType !== null && LEAVING_EVENTS.indexOf(eventType) !== -1 && !('for' in rule)) {
390
+ errors.push(`${label} moves a task on ${eventType}, but the task is leaving the board`);
391
+ }
392
+ if (rule.anyBoard === true) {
393
+ errors.push(
394
+ `${label} sets "anyBoard" and moves a task - a rule listening from another board names ` +
395
+ "columns that belong to its own board, so it can't move anything"
396
+ );
397
+ }
398
+ break;
399
+ }
400
+ case 'addToBoard': {
401
+ if (argument === null || typeof argument !== 'object' || Array.isArray(argument)) {
402
+ errors.push(`${label} "addToBoard" needs a board and a column`);
403
+ break;
404
+ }
405
+ if (typeof argument.board !== 'string' || !argument.board) {
406
+ errors.push(`${label} "addToBoard" needs a board`);
407
+ } else if (
408
+ boards !== null &&
409
+ findSubstitutions(argument.board).length === 0 &&
410
+ boards.indexOf(argument.board) === -1
411
+ ) {
412
+ errors.push(`${label} adds to board "${argument.board}", which doesn't exist`);
413
+ }
414
+ if (typeof argument.column !== 'string' || !argument.column) {
415
+ errors.push(`${label} "addToBoard" needs a column`);
416
+ }
417
+ break;
418
+ }
419
+ }
420
+ });
421
+
422
+ // Substitutions
423
+ if (eventType !== null && Array.isArray(rule.then)) {
424
+ const payloadKeys = [...(EVENT_PAYLOAD_KEYS[eventType] || []), ...EVENT_BOARD_KEYS];
425
+ const known = ['@me', '@now', '@task.id', '@task.name', '@board', '@event.task.id', '@event.task.name'];
426
+ for (const token of new Set(findSubstitutions(rule.then))) {
427
+ if (known.indexOf(token) !== -1) {
428
+ continue;
429
+ }
430
+ if (token.startsWith('@event.')) {
431
+ const key = token.slice('@event.'.length);
432
+
433
+ // A derived event carries whatever the operation that caused it carried, so its payload
434
+ // isn't knowable from the rule alone - those are left to the runtime, which skips them
435
+ // with a warning rather than failing
436
+ if (DERIVED_EVENT_TYPES.indexOf(eventType) === -1 && payloadKeys.indexOf(key) === -1) {
437
+ errors.push(`${label} uses "${token}", which ${eventType} doesn't carry`);
438
+ }
439
+ continue;
440
+ }
441
+ errors.push(`${label} uses unknown substitution "${token}"`);
442
+ }
443
+ }
444
+ });
445
+ return errors;
446
+ }
447
+
448
+ /**
449
+ * Find things about a rule set that are legal but probably not what the author meant
450
+ * @param {object[]} rules The rules to check
451
+ * @param {object} [context={}] `hasUser` is false when no current user can be resolved
452
+ * @return {object[]} A list of {type, message} warnings
453
+ */
454
+ function findRuleWarnings(rules, context = {}) {
455
+ const warnings = [];
456
+
457
+ // Two rules on the same event writing the same field means last-wins, silently
458
+ const writers = new Map();
459
+ rules.forEach((rule, i) => {
460
+ if (rule === null || typeof rule !== 'object' || !Array.isArray(rule.then)) {
461
+ return;
462
+ }
463
+ const fields = new Set();
464
+ for (const verb of rule.then) {
465
+ if (verb === null || typeof verb !== 'object') {
466
+ continue;
467
+ }
468
+ if ('set' in verb && verb.set !== null && typeof verb.set === 'object') {
469
+ Object.keys(verb.set).forEach((field) => fields.add(field));
470
+ }
471
+ if ('assign' in verb) {
472
+ fields.add('assigned');
473
+ }
474
+ if ('setProgress' in verb) {
475
+ fields.add('progress');
476
+ }
477
+ if ('move' in verb) {
478
+ fields.add('(column)');
479
+ }
480
+ }
481
+ for (const field of fields) {
482
+ const key = `${rule.on}${field}`;
483
+ if (!writers.has(key)) {
484
+ writers.set(key, []);
485
+ }
486
+ writers.get(key).push(ruleLabel(rule, i));
487
+ }
488
+ });
489
+ for (const [key, labels] of writers) {
490
+ if (labels.length < 2) {
491
+ continue;
492
+ }
493
+ const [eventType, field] = key.split('');
494
+ warnings.push({
495
+ type: 'conflicting-actions',
496
+ message:
497
+ `${labels.join(' and ')} both write ${field === '(column)' ? "the task's column" : `"${field}"`} on ` +
498
+ `${eventType} - the last one wins`
499
+ });
500
+ }
501
+
502
+ // A rule using @me on a machine with no resolvable user skips that verb every time it fires
503
+ if (context.hasUser === false) {
504
+ rules.forEach((rule, i) => {
505
+ if (findSubstitutions(rule.then).indexOf('@me') !== -1) {
506
+ warnings.push({
507
+ type: 'unresolvable-user',
508
+ message:
509
+ `${ruleLabel(rule, i)} uses "@me", but no user can be resolved here - set KANBN_USER or a git ` +
510
+ 'user, or those verbs will be skipped'
511
+ });
512
+ }
513
+ });
514
+ }
515
+ return warnings;
516
+ }
517
+
518
+ /**
519
+ * Build the substitution table for a rule firing on a task
520
+ * @param {object} params The task being written, the task the event fired for, and the event context
521
+ * @return {object} A map of token to value
522
+ */
523
+ function buildSubstitutions({ task, eventTask, payload, user, date, boardSlug }) {
524
+ const substitutions = {
525
+ '@now': date,
526
+ '@task.id': task.id,
527
+ '@task.name': task.name,
528
+ '@event.task.id': eventTask.id,
529
+ '@event.task.name': eventTask.name,
530
+ '@board': boardSlug
531
+ };
532
+
533
+ // A machine with no resolvable user has no @me, and a verb that needs one is skipped rather than
534
+ // writing an empty value
535
+ if (user) {
536
+ substitutions['@me'] = user;
537
+ }
538
+ for (const [key, value] of Object.entries(payload)) {
539
+ if (value === null || value === undefined || typeof value === 'object') {
540
+ continue;
541
+ }
542
+ substitutions[`@event.${key}`] = value;
543
+ }
544
+ return substitutions;
545
+ }
546
+
547
+ /**
548
+ * Replace substitution tokens in a value
549
+ *
550
+ * A value that is nothing but a token keeps the token's type, so `'@now'` writes a date rather than
551
+ * a string. A token used inside a longer string is interpolated
552
+ * @param {any} value The value to substitute into
553
+ * @param {object} substitutions A map of token to value
554
+ * @param {string[]} unresolved Tokens that couldn't be resolved are pushed here
555
+ * @return {any} The substituted value
556
+ */
557
+ function substitute(value, substitutions, unresolved) {
558
+ if (Array.isArray(value)) {
559
+ return value.map((item) => substitute(item, substitutions, unresolved));
560
+ }
561
+ if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
562
+ return Object.fromEntries(
563
+ Object.entries(value).map(([key, item]) => [key, substitute(item, substitutions, unresolved)])
564
+ );
565
+ }
566
+ if (typeof value !== 'string') {
567
+ return value;
568
+ }
569
+ const tokens = findSubstitutions(value);
570
+ if (!tokens.length) {
571
+ return value;
572
+ }
573
+
574
+ // Longest first, so that @event.task.id isn't consumed by a shorter token that prefixes it
575
+ const ordered = [...new Set(tokens)].sort((a, b) => b.length - a.length);
576
+ if (ordered.length === 1 && value.trim() === ordered[0]) {
577
+ const token = ordered[0];
578
+ if (!(token in substitutions)) {
579
+ unresolved.push(token);
580
+ return undefined;
581
+ }
582
+ return substitutions[token];
583
+ }
584
+ let result = value;
585
+ for (const token of ordered) {
586
+ if (!(token in substitutions)) {
587
+ unresolved.push(token);
588
+ continue;
589
+ }
590
+ const replacement = substitutions[token];
591
+ result = result.split(token).join(replacement instanceof Date ? replacement.toISOString() : String(replacement));
592
+ }
593
+ return result;
594
+ }
595
+
596
+ /**
597
+ * Check a rule's `when` clause against the event payload
598
+ *
599
+ * Event keys are matched exactly rather than as substrings, because a column called "Done" should
600
+ * not match a rule written for "Done Later". A list matches any of its values
601
+ * @param {object} when The event keys from the rule's when clause
602
+ * @param {object} payload The event payload
603
+ * @return {boolean} False if an event key in the filter doesn't match
604
+ */
605
+ function matchEventKeys(when, payload) {
606
+ for (const [key, expected] of Object.entries(when)) {
607
+ if (!(key in payload)) {
608
+ continue;
609
+ }
610
+ const actual = payload[key];
611
+ const options = toArray(expected);
612
+ if (Array.isArray(actual)) {
613
+ if (!options.some((option) => actual.indexOf(option) !== -1)) {
614
+ return false;
615
+ }
616
+ continue;
617
+ }
618
+ if (!options.some((option) => option === actual)) {
619
+ return false;
620
+ }
621
+ }
622
+ return true;
623
+ }
624
+
625
+ /**
626
+ * Split a `when` clause into the keys that belong to the event and the keys that belong to the task
627
+ * @param {object} when The rule's when clause
628
+ * @param {object} payload The event payload
629
+ * @return {object} {eventFilters, taskFilters}
630
+ */
631
+ function splitFilters(when, payload) {
632
+ const eventFilters = {};
633
+ const taskFilters = {};
634
+ for (const [key, value] of Object.entries(when || {})) {
635
+ if (key in payload || EVENT_BOARD_KEYS.indexOf(key) !== -1) {
636
+ eventFilters[key] = value;
637
+ } else {
638
+ taskFilters[key] = value;
639
+ }
640
+ }
641
+ return { eventFilters, taskFilters };
642
+ }
643
+
644
+ /**
645
+ * Apply one verb to one task
646
+ * @param {object} params The verb, the task it applies to, and the context it resolves against
647
+ */
648
+ function applyVerb({ name, argument, task, patch, rule, index, helpers, substitutions, date, warnings, label }) {
649
+ const unresolved = [];
650
+ const value = substitute(argument, substitutions, unresolved);
651
+ if (unresolved.length) {
652
+ warnings.push(`${label} skipped "${name}": ${[...new Set(unresolved)].join(', ')} couldn't be resolved`);
653
+ return;
654
+ }
655
+ const author = ruleAuthor(rule);
656
+ switch (name) {
657
+ case 'set':
658
+ for (const [field, fieldValue] of Object.entries(value)) {
659
+ helpers.setTaskMetadata(task, field, fieldValue);
660
+ }
661
+ break;
662
+ case 'unset':
663
+ for (const field of toArray(value)) {
664
+ helpers.setTaskMetadata(task, field, undefined);
665
+ }
666
+ break;
667
+ case 'assign':
668
+ helpers.setTaskMetadata(task, 'assigned', value);
669
+ break;
670
+ case 'addTag': {
671
+ const tags = [...(helpers.getTaskMetadata(task, 'tags') || [])];
672
+ for (const tag of toArray(value)) {
673
+ if (tags.indexOf(tag) === -1) {
674
+ tags.push(tag);
675
+ }
676
+ }
677
+ helpers.setTaskMetadata(task, 'tags', tags);
678
+ break;
679
+ }
680
+ case 'removeTag': {
681
+ const remove = toArray(value);
682
+ const tags = (helpers.getTaskMetadata(task, 'tags') || []).filter((tag) => remove.indexOf(tag) === -1);
683
+ helpers.setTaskMetadata(task, 'tags', tags);
684
+ break;
685
+ }
686
+ case 'setProgress': {
687
+ const from = helpers.getTaskMetadata(task, 'progress') || 0;
688
+ if (from === value) {
689
+ break;
690
+ }
691
+ helpers.setTaskMetadata(task, 'progress', value);
692
+ helpers.appendTaskHistory(task, { date, type: 'progress', fromProgress: from, toProgress: value }, null, author);
693
+ break;
694
+ }
695
+ case 'comment':
696
+ // A task on its way to being created may not have been through the parser yet, so its
697
+ // optional lists aren't guaranteed to exist
698
+ if (!Array.isArray(task.comments)) {
699
+ task.comments = [];
700
+ }
701
+ task.comments.push({ text: value, author, date });
702
+ break;
703
+ case 'move': {
704
+ const column = value !== null && typeof value === 'object' ? value.column : value;
705
+ if (!(column in index.columns)) {
706
+ warnings.push(`${label} skipped "move": column "${column}" doesn't exist on this board`);
707
+ break;
708
+ }
709
+ patch.moveTo = {
710
+ column,
711
+ position: value !== null && typeof value === 'object' && 'position' in value ? value.position : null,
712
+ author
713
+ };
714
+ break;
715
+ }
716
+ case 'addToBoard':
717
+ patch.boardAdds.push({ board: value.board, column: value.column, author });
718
+ break;
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Select the tasks a rule acts on
724
+ * @param {object} params The rule, the task the event fired for, and the readers to find others with
725
+ * @return {Promise<object[]>} The tasks to apply the rule's verbs to
726
+ */
727
+ async function selectTargets({ rule, task, index, helpers, loadTask, loadAllTasks, warnings, label }) {
728
+ const target = rule.for;
729
+ const wanted = 'related' in target ? normaliseRelationType(target.related) : null;
730
+ const direction = target.direction || 'outgoing';
731
+ let candidates = [];
732
+
733
+ if (direction === 'outgoing') {
734
+ // The relations are in the task file that's already loaded, so this costs nothing beyond reading
735
+ // the tasks it names
736
+ const seen = new Set();
737
+ for (const relation of task.relations || []) {
738
+ if (relation === null || !relation.task) {
739
+ continue;
740
+ }
741
+ if (wanted !== null && normaliseRelationType(relation.type) !== wanted) {
742
+ continue;
743
+ }
744
+ if (seen.has(relation.task)) {
745
+ continue;
746
+ }
747
+ seen.add(relation.task);
748
+ const related = await loadTask(relation.task);
749
+ if (related === null) {
750
+ warnings.push(`${label} skipped related task "${relation.task}": no task file`);
751
+ continue;
752
+ }
753
+ candidates.push(related);
754
+ }
755
+ } else {
756
+ // Kanbn doesn't maintain inverse relations, so finding the tasks that point at this one means
757
+ // reading them all - the same cost as kanbn find, and only paid by a workspace that has such a
758
+ // rule
759
+ const all = await loadAllTasks();
760
+ candidates = all.filter((candidate) =>
761
+ (candidate.relations || []).some(
762
+ (relation) =>
763
+ relation !== null &&
764
+ relation.task === task.id &&
765
+ (wanted === null || normaliseRelationType(relation.type) === wanted)
766
+ )
767
+ );
768
+ }
769
+
770
+ // The task the event fired for is never its own target, however the relations are drawn
771
+ candidates = candidates.filter((candidate) => candidate.id !== task.id);
772
+ if ('where' in target && Object.keys(target.where || {}).length) {
773
+ candidates = helpers.filterTasks(index, candidates, target.where);
774
+ }
775
+ return candidates;
776
+ }
777
+
778
+ /**
779
+ * Run the rules that match an event
780
+ *
781
+ * Everything this returns is a patch: nothing is written here, and nothing it produces fires another
782
+ * event. That's what makes "actions never fire actions" structural rather than a limit with a dial
783
+ * @param {object} params The rules, the event, and the state the operation is about to write
784
+ * @return {Promise<object>} {patch, targets, warnings}
785
+ */
786
+ async function run({ rules, eventTypes, payload, index, task, date, user, boardSlug, helpers, loadTask, loadAllTasks }) {
787
+ const warnings = [];
788
+ const patch = { moveTo: null, boardAdds: [] };
789
+ const targets = new Map();
790
+
791
+ // A target loaded once here is the object every rule mutates, so two rules acting on the same
792
+ // target compose the way two verbs in one rule do
793
+ const loaded = new Map();
794
+ const targetPatch = (targetTask) => {
795
+ if (!targets.has(targetTask.id)) {
796
+ targets.set(targetTask.id, { task: targetTask, moveTo: null, boardAdds: [] });
797
+ }
798
+ return targets.get(targetTask.id);
799
+ };
800
+ const loadTargetOnce = async (taskId) => {
801
+ if (!loaded.has(taskId)) {
802
+ loaded.set(taskId, await loadTask(taskId));
803
+ }
804
+ return loaded.get(taskId);
805
+ };
806
+ let allTasks = null;
807
+ const loadAllTargetsOnce = async () => {
808
+ if (allTasks === null) {
809
+ allTasks = await loadAllTasks();
810
+ for (const candidate of allTasks) {
811
+ if (!loaded.has(candidate.id)) {
812
+ loaded.set(candidate.id, candidate);
813
+ }
814
+ }
815
+ }
816
+
817
+ // Anything already loaded by an outgoing selector is reused, so one operation never holds two
818
+ // copies of the same task
819
+ return allTasks.map((candidate) => loaded.get(candidate.id) || candidate);
820
+ };
821
+
822
+ for (const eventType of eventTypes) {
823
+ for (const rule of rules) {
824
+ if (rule.on !== eventType) {
825
+ continue;
826
+ }
827
+ const label = ruleLabel(rule, rules.indexOf(rule));
828
+ const { eventFilters, taskFilters } = splitFilters(rule.when || {}, payload);
829
+
830
+ // The event has to match before the task does, because an event key that doesn't match makes
831
+ // the task irrelevant
832
+ if (!matchEventKeys(eventFilters, payload)) {
833
+ continue;
834
+ }
835
+ if (Object.keys(taskFilters).length && !helpers.filterTasks(index, [task], taskFilters).length) {
836
+ continue;
837
+ }
838
+
839
+ const acting =
840
+ 'for' in rule
841
+ ? await selectTargets({
842
+ rule,
843
+ task,
844
+ index,
845
+ helpers,
846
+ loadTask: loadTargetOnce,
847
+ loadAllTasks: loadAllTargetsOnce,
848
+ warnings,
849
+ label
850
+ })
851
+ : [task];
852
+ for (const actingTask of acting) {
853
+ const isEventTask = actingTask.id === task.id;
854
+ const substitutions = buildSubstitutions({
855
+ task: actingTask,
856
+ eventTask: task,
857
+ payload,
858
+ user,
859
+ date,
860
+ boardSlug
861
+ });
862
+ const actingPatch = isEventTask ? patch : targetPatch(actingTask);
863
+ for (const verb of rule.then) {
864
+ const [name] = Object.keys(verb);
865
+ applyVerb({
866
+ name,
867
+ argument: verb[name],
868
+ task: actingTask,
869
+ patch: actingPatch,
870
+ rule,
871
+ index,
872
+ helpers,
873
+ substitutions,
874
+ date,
875
+ warnings,
876
+ label
877
+ });
878
+ }
879
+ }
880
+ }
881
+ }
882
+ return { patch, targets: [...targets.values()], warnings };
883
+ }
884
+
885
+ module.exports = {
886
+ EVENT_TYPES,
887
+ DERIVED_EVENT_TYPES,
888
+ WORKSPACE_WIDE_EVENTS,
889
+ LEAVING_EVENTS,
890
+ CROSS_TASK_ONLY_EVENTS,
891
+ EVENT_PAYLOAD_KEYS,
892
+ EVENT_BOARD_KEYS,
893
+ VERBS,
894
+ RESERVED_FIELDS,
895
+ ACTION_AUTHOR_PREFIX,
896
+ normaliseRules,
897
+ parseActionsFile,
898
+ findRuleErrors,
899
+ findRuleWarnings,
900
+ findSubstitutions,
901
+ normaliseRelationType,
902
+ substitute,
903
+ run
904
+ };