@basementuniverse/kanbn 2.0.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.
- package/README.md +3 -1
- package/coverage/tmp/coverage-916017-1788028598821-0.json +1 -0
- package/coverage/tmp/{coverage-214293-1787777184569-0.json → coverage-916018-1788028597179-0.json} +1 -1
- package/coverage/tmp/coverage-916036-1788028598796-0.json +1 -0
- package/docs/actions.md +337 -0
- package/docs/advanced-configuration.md +55 -0
- package/docs/commands/add.txt +17 -1
- package/docs/commands/archive.txt +11 -0
- package/docs/commands/board.txt +9 -0
- package/docs/commands/boards.txt +34 -0
- package/docs/commands/burndown.txt +7 -0
- package/docs/commands/comment.txt +13 -1
- package/docs/commands/contributors.txt +58 -0
- package/docs/commands/edit.txt +18 -1
- package/docs/commands/find.txt +39 -1
- package/docs/commands/gantt.txt +6 -0
- package/docs/commands/help.txt +2 -0
- package/docs/commands/history.txt +6 -0
- package/docs/commands/init.txt +13 -0
- package/docs/commands/move.txt +26 -0
- package/docs/commands/remove.txt +22 -1
- package/docs/commands/rename.txt +5 -0
- package/docs/commands/restore.txt +12 -0
- package/docs/commands/sort.txt +23 -0
- package/docs/commands/sprint.txt +9 -0
- package/docs/commands/status.txt +10 -1
- package/docs/commands/task.txt +9 -0
- package/docs/commands/validate.txt +29 -1
- package/docs/contributors.md +145 -0
- package/docs/filtering-and-sorting.md +60 -3
- package/docs/index-structure.md +145 -11
- package/docs/index.md +6 -2
- package/docs/multiple-boards.md +259 -0
- package/docs/quick-start.md +21 -1
- package/docs/task-structure.md +31 -3
- package/example/README.md +23 -0
- package/example/advanced/kanbn.yml +65 -0
- package/example/boards/.kanbn/design.md +31 -0
- package/example/boards/.kanbn/index.md +44 -0
- package/example/boards/.kanbn/tasks/add-usage-alert-emails.md +19 -0
- package/example/boards/.kanbn/tasks/build-tenant-settings-page.md +43 -0
- package/example/boards/.kanbn/tasks/create-organization-switcher.md +44 -0
- package/example/boards/.kanbn/tasks/design-onboarding-checklist.md +22 -0
- package/example/boards/.kanbn/tasks/refresh-marketing-site.md +28 -0
- package/example/boards/.kanbn/tasks/ship-billing-portal.md +29 -0
- package/package.json +9 -7
- package/routes/add.json +40 -12
- package/routes/archive.json +14 -2
- package/routes/board.json +11 -3
- package/routes/boards.json +30 -0
- package/routes/burndown.json +23 -7
- package/routes/comment.json +19 -5
- package/routes/contributors.json +18 -0
- package/routes/edit.json +37 -11
- package/routes/find.json +37 -12
- package/routes/gantt.json +20 -6
- package/routes/history.json +39 -25
- package/routes/init.json +3 -1
- package/routes/move.json +24 -6
- package/routes/remove.json +18 -3
- package/routes/rename.json +11 -3
- package/routes/restore.json +14 -2
- package/routes/sort.json +40 -11
- package/routes/sprint.json +14 -4
- package/routes/status.json +23 -7
- package/routes/task.json +10 -2
- package/routes/validate.json +18 -5
- package/skills/kanbn-plan/SKILL.md +10 -1
- package/skills/kanbn-replan/SKILL.md +6 -1
- package/src/actions.js +904 -0
- package/src/board.js +25 -2
- package/src/controller/add.js +72 -55
- package/src/controller/archive.js +9 -4
- package/src/controller/board.js +21 -13
- package/src/controller/boards.js +140 -0
- package/src/controller/burndown.js +14 -7
- package/src/controller/comment.js +13 -6
- package/src/controller/contributors.js +166 -0
- package/src/controller/edit.js +48 -18
- package/src/controller/find.js +87 -13
- package/src/controller/gantt.js +13 -6
- package/src/controller/history.js +13 -6
- package/src/controller/init.js +39 -4
- package/src/controller/move.js +153 -25
- package/src/controller/remove.js +73 -13
- package/src/controller/rename.js +8 -4
- package/src/controller/restore.js +24 -8
- package/src/controller/sort.js +59 -3
- package/src/controller/sprint.js +31 -7
- package/src/controller/status.js +8 -4
- package/src/controller/task.js +34 -10
- package/src/controller/validate.js +197 -7
- package/src/git-user-name.js +9 -0
- package/src/git-user.js +55 -0
- package/src/main.d.ts +387 -9
- package/src/main.js +2757 -113
- package/src/parse-index.js +219 -17
- package/src/parse-task.js +16 -0
- package/src/utility.js +274 -0
- 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
|
+
};
|