@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/parse-index.js
CHANGED
|
@@ -27,6 +27,20 @@ function validateOptions(options) {
|
|
|
27
27
|
'startedField': { type: 'string' },
|
|
28
28
|
'completedField': { type: 'string' },
|
|
29
29
|
'verbose': { type: 'boolean' },
|
|
30
|
+
'defaultBoard': { type: 'string' },
|
|
31
|
+
'boards': {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
'exclude': {
|
|
35
|
+
type: 'array',
|
|
36
|
+
items: { type: 'string' }
|
|
37
|
+
},
|
|
38
|
+
'order': {
|
|
39
|
+
type: 'array',
|
|
40
|
+
items: { type: 'string' }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
30
44
|
'sprints': {
|
|
31
45
|
type: 'array',
|
|
32
46
|
items: {
|
|
@@ -98,6 +112,66 @@ function validateOptions(options) {
|
|
|
98
112
|
required: ['name', 'type']
|
|
99
113
|
}
|
|
100
114
|
},
|
|
115
|
+
'contributors': {
|
|
116
|
+
type: 'array',
|
|
117
|
+
items: {
|
|
118
|
+
oneOf: [
|
|
119
|
+
|
|
120
|
+
// A bare name, which is all most workspaces ever need
|
|
121
|
+
{ type: 'string' },
|
|
122
|
+
{
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
'name': { type: 'string' },
|
|
126
|
+
'displayName': { type: 'string' },
|
|
127
|
+
'email': { type: 'string' },
|
|
128
|
+
'aliases': {
|
|
129
|
+
type: 'array',
|
|
130
|
+
items: { type: 'string' }
|
|
131
|
+
},
|
|
132
|
+
'colour': { type: 'string' }
|
|
133
|
+
},
|
|
134
|
+
required: ['name']
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
'actionsFile': { type: 'string' },
|
|
140
|
+
|
|
141
|
+
// The rules themselves are only shape-checked here: what they mean is checked by
|
|
142
|
+
// actions.findRuleErrors(), which knows the event names, the verbs and this board's columns and
|
|
143
|
+
// can say what is wrong rather than which sub-schema failed
|
|
144
|
+
'actions': {
|
|
145
|
+
type: 'array',
|
|
146
|
+
items: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
'name': { type: 'string' },
|
|
150
|
+
'on': { type: 'string' },
|
|
151
|
+
'when': { type: 'object' },
|
|
152
|
+
'for': {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
'related': { type: 'string' },
|
|
156
|
+
'direction': {
|
|
157
|
+
type: 'string',
|
|
158
|
+
enum: [
|
|
159
|
+
'incoming',
|
|
160
|
+
'outgoing'
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
'where': { type: 'object' }
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
'anyBoard': { type: 'boolean' },
|
|
167
|
+
'then': {
|
|
168
|
+
type: 'array',
|
|
169
|
+
items: { type: 'object' }
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
required: ['on', 'then']
|
|
173
|
+
}
|
|
174
|
+
},
|
|
101
175
|
'views': {
|
|
102
176
|
type: 'array',
|
|
103
177
|
items: {
|
|
@@ -157,6 +231,126 @@ function validateOptions(options) {
|
|
|
157
231
|
}
|
|
158
232
|
}
|
|
159
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Get the task id from a column list item, or null if the item isn't a task link
|
|
236
|
+
*
|
|
237
|
+
* A task link is a list item that begins with a markdown link, e.g. `- [task-id](tasks/task-id.md)`.
|
|
238
|
+
* Anything else - a bare title, a checkbox item, a note - is content that this file preserves but
|
|
239
|
+
* doesn't interpret
|
|
240
|
+
* @param {object} item A list item token
|
|
241
|
+
* @return {?string} The task id, or null if this item isn't a task link
|
|
242
|
+
*/
|
|
243
|
+
function taskIdFromListItem(item) {
|
|
244
|
+
const block = item.tokens && item.tokens[0];
|
|
245
|
+
const inline = block && block.tokens && block.tokens[0];
|
|
246
|
+
return inline && inline.type === 'link' ? inline.text : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Remove trailing whitespace from a markdown token's raw source, keeping any internal line breaks so
|
|
251
|
+
* that multi-line items survive a read/write cycle intact
|
|
252
|
+
* @param {string} raw
|
|
253
|
+
* @return {string}
|
|
254
|
+
*/
|
|
255
|
+
function trimRaw(raw) {
|
|
256
|
+
return String(raw).replace(/\s+$/, '');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Split a column's markdown into the task ids it references and everything else it contains
|
|
261
|
+
*
|
|
262
|
+
* Every top-level token is considered, not just the first list: a single line using a different
|
|
263
|
+
* bullet character splits one list into several, and reading only the first one silently dropped
|
|
264
|
+
* every task after it. Content that isn't a task link is recorded with the position it occupied so
|
|
265
|
+
* that it can be written back where the user put it
|
|
266
|
+
* @param {string} markdown The column's markdown content
|
|
267
|
+
* @return {{taskIds: string[], content: object[]}}
|
|
268
|
+
*/
|
|
269
|
+
function parseColumnContent(markdown) {
|
|
270
|
+
const taskIds = [], content = [];
|
|
271
|
+
if (!markdown) {
|
|
272
|
+
return { taskIds, content };
|
|
273
|
+
}
|
|
274
|
+
let position = 0;
|
|
275
|
+
for (const token of marked.lexer(markdown)) {
|
|
276
|
+
if (token.type === 'space') {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (token.type === 'list') {
|
|
280
|
+
for (const item of token.items) {
|
|
281
|
+
const taskId = taskIdFromListItem(item);
|
|
282
|
+
if (taskId === null) {
|
|
283
|
+
content.push({ position, text: item.text.trim(), raw: trimRaw(item.raw) });
|
|
284
|
+
} else {
|
|
285
|
+
taskIds.push(taskId);
|
|
286
|
+
}
|
|
287
|
+
position++;
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A paragraph, quote, code block or anything else that isn't a list is content as well. It's
|
|
293
|
+
// written back surrounded by blank lines so that it doesn't merge into a neighbouring list
|
|
294
|
+
content.push({
|
|
295
|
+
position,
|
|
296
|
+
text: ('text' in token ? String(token.text) : trimRaw(token.raw)).trim(),
|
|
297
|
+
raw: trimRaw(token.raw),
|
|
298
|
+
block: true
|
|
299
|
+
});
|
|
300
|
+
position++;
|
|
301
|
+
}
|
|
302
|
+
return { taskIds, content };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Rebuild a column's markdown from its task ids and preserved content
|
|
307
|
+
* @param {string[]} taskIds
|
|
308
|
+
* @param {object[]} content
|
|
309
|
+
* @return {string} The column's markdown content
|
|
310
|
+
*/
|
|
311
|
+
function buildColumnContent(taskIds, content) {
|
|
312
|
+
const lines = taskIds.map(taskId => `- [${taskId}](tasks/${taskId}.md)`);
|
|
313
|
+
|
|
314
|
+
// Positions were recorded against the column as it was read. Tasks move, so a position can end up
|
|
315
|
+
// past the end of the column - clamping keeps the line in the column it was written in
|
|
316
|
+
for (const entry of [...content].sort((a, b) => a.position - b.position)) {
|
|
317
|
+
lines.splice(
|
|
318
|
+
Math.min(entry.position, lines.length),
|
|
319
|
+
0,
|
|
320
|
+
entry.block ? `\n${entry.raw}\n` : entry.raw
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Validate the preserved column content object
|
|
328
|
+
* @param {object} columnContent
|
|
329
|
+
*/
|
|
330
|
+
function validateColumnContent(columnContent) {
|
|
331
|
+
const result = validate(columnContent, {
|
|
332
|
+
type: 'object',
|
|
333
|
+
patternProperties: {
|
|
334
|
+
'^[\w ]+$': {
|
|
335
|
+
type: 'array',
|
|
336
|
+
items: {
|
|
337
|
+
type: 'object',
|
|
338
|
+
properties: {
|
|
339
|
+
'position': { type: 'number' },
|
|
340
|
+
'text': { type: 'string' },
|
|
341
|
+
'raw': { type: 'string' },
|
|
342
|
+
'block': { type: 'boolean' }
|
|
343
|
+
},
|
|
344
|
+
required: ['position', 'raw']
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
if (result.errors.length) {
|
|
350
|
+
throw new Error(result.errors.map(error => `${error.property} ${error.message}`).join('\n'));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
160
354
|
/**
|
|
161
355
|
* Validate the columns object
|
|
162
356
|
* @param {object} columns
|
|
@@ -184,7 +378,7 @@ module.exports = {
|
|
|
184
378
|
* @return {object}
|
|
185
379
|
*/
|
|
186
380
|
md2json(data) {
|
|
187
|
-
let name = '', description = '', options = {}, columns = {};
|
|
381
|
+
let name = '', description = '', options = {}, columns = {}, columnContent = {};
|
|
188
382
|
try {
|
|
189
383
|
|
|
190
384
|
// Check data type
|
|
@@ -242,26 +436,32 @@ module.exports = {
|
|
|
242
436
|
|
|
243
437
|
// Parse columns
|
|
244
438
|
const columnNames = Object.keys(index).filter(column => ['raw', 'Options', name].indexOf(column) === -1);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
439
|
+
for (const columnName of columnNames) {
|
|
440
|
+
let parsed = null;
|
|
441
|
+
try {
|
|
442
|
+
parsed = parseColumnContent(index[columnName].content);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
throw new Error(`unable to parse column "${columnName}" (${error.message})`);
|
|
445
|
+
}
|
|
446
|
+
columns[columnName] = parsed.taskIds;
|
|
447
|
+
|
|
448
|
+
// Anything in the column that isn't a task link is kept verbatim rather than rejected, so
|
|
449
|
+
// that a hand-written note or a mistyped link is preserved instead of being thrown away the
|
|
450
|
+
// next time the board is saved. Nothing else in kanbn reads it
|
|
451
|
+
if (parsed.content.length) {
|
|
452
|
+
columnContent[columnName] = parsed.content;
|
|
453
|
+
}
|
|
258
454
|
}
|
|
259
455
|
} catch (error) {
|
|
260
456
|
throw new Error(`Unable to parse index: ${error.message}`);
|
|
261
457
|
}
|
|
262
458
|
|
|
263
459
|
// Assemble index object
|
|
264
|
-
|
|
460
|
+
const result = { name, description, options, columns };
|
|
461
|
+
if (Object.keys(columnContent).length) {
|
|
462
|
+
result.columnContent = columnContent;
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
265
465
|
},
|
|
266
466
|
|
|
267
467
|
/**
|
|
@@ -309,11 +509,13 @@ module.exports = {
|
|
|
309
509
|
}
|
|
310
510
|
validateColumns(data.columns);
|
|
311
511
|
|
|
312
|
-
// Add columns
|
|
512
|
+
// Add columns, putting any preserved non-task content back where it was found
|
|
513
|
+
const columnContent = 'columnContent' in data && data.columnContent !== null ? data.columnContent : {};
|
|
514
|
+
validateColumnContent(columnContent);
|
|
313
515
|
for (let column in data.columns) {
|
|
314
516
|
result.push(
|
|
315
517
|
`## ${column}`,
|
|
316
|
-
data.columns[column]
|
|
518
|
+
buildColumnContent(data.columns[column], column in columnContent ? columnContent[column] : [])
|
|
317
519
|
);
|
|
318
520
|
}
|
|
319
521
|
} catch (error) {
|
package/src/parse-task.js
CHANGED
|
@@ -200,6 +200,7 @@ function validateHistoryFromMarkdown(history) {
|
|
|
200
200
|
'column': { type: 'string' },
|
|
201
201
|
'fromColumn': { type: 'string' },
|
|
202
202
|
'toColumn': { type: 'string' },
|
|
203
|
+
'board': { type: 'string' },
|
|
203
204
|
'author': { type: 'string' },
|
|
204
205
|
'fromProgress': { oneOf: [{ type: 'number' }, { type: 'string' }] },
|
|
205
206
|
'toProgress': { oneOf: [{ type: 'number' }, { type: 'string' }] }
|
|
@@ -226,6 +227,7 @@ function validateHistoryFromJSON(history) {
|
|
|
226
227
|
'column': { type: 'string' },
|
|
227
228
|
'fromColumn': { type: 'string' },
|
|
228
229
|
'toColumn': { type: 'string' },
|
|
230
|
+
'board': { type: 'string' },
|
|
229
231
|
'author': { type: 'string' },
|
|
230
232
|
'fromProgress': { type: 'number' },
|
|
231
233
|
'toProgress': { type: 'number' }
|
|
@@ -259,6 +261,13 @@ function validateHistoryEvent(historyEvent) {
|
|
|
259
261
|
throw new Error('created history event is missing column');
|
|
260
262
|
}
|
|
261
263
|
break;
|
|
264
|
+
case 'added':
|
|
265
|
+
if (!historyEvent.column) {
|
|
266
|
+
throw new Error('added history event is missing column');
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
case 'removed':
|
|
270
|
+
break;
|
|
262
271
|
case 'moved':
|
|
263
272
|
if (!historyEvent.fromColumn || !historyEvent.toColumn) {
|
|
264
273
|
throw new Error('moved history event is missing fromColumn or toColumn');
|
|
@@ -293,6 +302,7 @@ const KNOWN_HISTORY_EVENT_KEYS = [
|
|
|
293
302
|
'column',
|
|
294
303
|
'fromColumn',
|
|
295
304
|
'toColumn',
|
|
305
|
+
'board',
|
|
296
306
|
'fromProgress',
|
|
297
307
|
'toProgress',
|
|
298
308
|
'author'
|
|
@@ -670,6 +680,12 @@ module.exports = {
|
|
|
670
680
|
if ('toColumn' in historyEvent) {
|
|
671
681
|
historyEventOutput.push(`toColumn: ${historyEvent.toColumn}`);
|
|
672
682
|
}
|
|
683
|
+
|
|
684
|
+
// Events on the main board carry no board key, so existing task files - and files
|
|
685
|
+
// written by single-board workspaces - are byte-identical to what they always were
|
|
686
|
+
if ('board' in historyEvent && historyEvent.board) {
|
|
687
|
+
historyEventOutput.push(`board: ${historyEvent.board}`);
|
|
688
|
+
}
|
|
673
689
|
if ('fromProgress' in historyEvent) {
|
|
674
690
|
historyEventOutput.push(`fromProgress: ${historyEvent.fromProgress}`);
|
|
675
691
|
}
|
package/src/utility.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const fuzzy = require('fuzzy');
|
|
2
|
+
|
|
1
3
|
module.exports = (() => {
|
|
2
4
|
const tags = {
|
|
3
5
|
b: 'bold',
|
|
@@ -19,6 +21,211 @@ module.exports = (() => {
|
|
|
19
21
|
!dontExit && process.env.KANBN_ENV !== 'test' && process.exit(1);
|
|
20
22
|
},
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the board a command targets and return a Kanbn instance scoped to it, printing an
|
|
26
|
+
* error and returning null if the workspace or the board doesn't exist
|
|
27
|
+
* @param {object} workspace The workspace-scoped Kanbn instance
|
|
28
|
+
* @param {object} args The parsed command arguments
|
|
29
|
+
* @return {Promise<object|null>} A board-scoped Kanbn instance, or null if it can't be resolved
|
|
30
|
+
*/
|
|
31
|
+
async resolveBoard(workspace, args) {
|
|
32
|
+
|
|
33
|
+
// Sprint numbers and names are relative to one board's list, so an aggregate across boards
|
|
34
|
+
// can't resolve them. Refuse rather than guess
|
|
35
|
+
if (args['all-boards'] && args.sprint) {
|
|
36
|
+
this.error(
|
|
37
|
+
'{b}--sprint{b} and {b}--all-boards{b} cannot be used together, because sprint numbers ' +
|
|
38
|
+
'and names are relative to a single board'
|
|
39
|
+
);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const kanbn = await workspace.boardFromArgs(args);
|
|
43
|
+
|
|
44
|
+
// Actions are declarative rules with no code execution, so this is a way around a misbehaving
|
|
45
|
+
// rule rather than a safety control
|
|
46
|
+
if (args.actions === false || args['no-actions'] === true) {
|
|
47
|
+
kanbn.actionsEnabled = false;
|
|
48
|
+
}
|
|
49
|
+
if (!await kanbn.workspaceInitialised()) {
|
|
50
|
+
this.error('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (!await kanbn.initialised()) {
|
|
54
|
+
this.error(
|
|
55
|
+
`Board "${await kanbn.resolveBoardSlug()}" doesn't exist\n` +
|
|
56
|
+
'Try running: {b}kanbn boards{b}'
|
|
57
|
+
);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return kanbn;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the boards a command targets, along with the column to use on each. `-b` is repeatable
|
|
65
|
+
* and pairs positionally with `-c`: board i uses the i-th `-c` if there is one, otherwise the
|
|
66
|
+
* first `-c`, otherwise that board's first column (Q5). Prints an error and returns null if any
|
|
67
|
+
* board can't be resolved
|
|
68
|
+
* @param {object} workspace The workspace-scoped Kanbn instance
|
|
69
|
+
* @param {object} args The parsed command arguments
|
|
70
|
+
* @return {Promise<object[]|null>} One entry per target board, or null on error
|
|
71
|
+
*/
|
|
72
|
+
async resolveBoardTargets(workspace, args) {
|
|
73
|
+
const boardArgs = args.board === undefined
|
|
74
|
+
? []
|
|
75
|
+
: this.arrayArg(args.board).filter(slug => slug !== undefined && slug !== '');
|
|
76
|
+
const columnArgs = args.column === undefined ? [] : this.arrayArg(args.column);
|
|
77
|
+
const slugs = boardArgs.length ? boardArgs : [await workspace.resolveTargetBoard(null)];
|
|
78
|
+
|
|
79
|
+
// With a single target board an unknown column is an error, exactly as it has always been.
|
|
80
|
+
// Falling back only makes sense when several boards are targeted at once, where a column can
|
|
81
|
+
// legitimately exist on one and not another
|
|
82
|
+
const allowFallback = slugs.length > 1;
|
|
83
|
+
|
|
84
|
+
const targets = [];
|
|
85
|
+
for (let i = 0; i < slugs.length; i++) {
|
|
86
|
+
const kanbn = await this.resolveBoard(workspace, { board: slugs[i], actions: args.actions });
|
|
87
|
+
if (kanbn === null) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
let index;
|
|
91
|
+
try {
|
|
92
|
+
index = await kanbn.getIndex();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
this.error(error);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const columnNames = Object.keys(index.columns);
|
|
98
|
+
if (!columnNames.length) {
|
|
99
|
+
this.error('No columns defined in the index\nTry running {b}kanbn init -c "column name"{b}');
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const requestedColumn = columnArgs.length
|
|
104
|
+
? (columnArgs[i] !== undefined ? columnArgs[i] : columnArgs[0])
|
|
105
|
+
: null;
|
|
106
|
+
let columnName = requestedColumn === null ? columnNames[0] : requestedColumn;
|
|
107
|
+
let fallback = false;
|
|
108
|
+
if (!(columnName in index.columns)) {
|
|
109
|
+
if (!allowFallback) {
|
|
110
|
+
this.error(`Column "${columnName}" doesn't exist`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
columnName = columnNames[0];
|
|
114
|
+
fallback = true;
|
|
115
|
+
}
|
|
116
|
+
targets.push({
|
|
117
|
+
kanbn,
|
|
118
|
+
index,
|
|
119
|
+
slug: await kanbn.resolveBoardSlug(),
|
|
120
|
+
columnName,
|
|
121
|
+
requestedColumn,
|
|
122
|
+
fallback
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return targets;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Tell the user when a target board didn't have the column that was asked for, so that a task
|
|
130
|
+
* quietly landing somewhere else is never a surprise
|
|
131
|
+
* @param {object[]} targets Targets from resolveBoardTargets()
|
|
132
|
+
* @param {boolean} show False to suppress the notice
|
|
133
|
+
*/
|
|
134
|
+
showColumnFallbackNotices(targets, show = true) {
|
|
135
|
+
if (!show) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const target of targets.filter(t => t.fallback)) {
|
|
139
|
+
console.log(this.replaceTags(
|
|
140
|
+
`{d}Board "${target.slug}" has no column "${target.requestedColumn}" - using "${target.columnName}"{d}`
|
|
141
|
+
));
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Show a hint about how many boards this workspace has, so that a command showing one board
|
|
147
|
+
* doesn't look like it's showing everything. Suppressed for machine-readable output and when the
|
|
148
|
+
* board sets verbose to false
|
|
149
|
+
* @param {object[]} boards The list of boards
|
|
150
|
+
* @param {boolean} show False to suppress the hint
|
|
151
|
+
*/
|
|
152
|
+
showBoardCountHint(boards, show = true) {
|
|
153
|
+
if (!show || boards.length < 2) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
console.log(this.replaceTags(`\n{d}${boards.length} boards available -{d} {b}kanbn boards{b}`));
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Resolve a string to exactly one simple task on a board, for the commands that accept one.
|
|
161
|
+
* Real tasks always win, so this should only be called once no task has matched
|
|
162
|
+
* @param {object} kanbn The board-scoped Kanbn instance
|
|
163
|
+
* @param {string} input The title to match
|
|
164
|
+
* @param {Error|string} taskError The error to report if nothing matches, i.e. the error the
|
|
165
|
+
* command would have printed before simple tasks existed
|
|
166
|
+
* @param {?object[]} [matches=null] Matches already found, to save looking them up again
|
|
167
|
+
* @return {Promise<?object>} The matching simple task, or null if the error has been reported
|
|
168
|
+
*/
|
|
169
|
+
async resolveSimpleTask(kanbn, input, taskError, matches = null) {
|
|
170
|
+
if (matches === null) {
|
|
171
|
+
try {
|
|
172
|
+
matches = await kanbn.findSimpleTasks(input);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.error(error);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!matches.length) {
|
|
179
|
+
this.error(taskError);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Two lines with the same title are two different lines. Picking one of them silently would
|
|
184
|
+
// eventually move or delete the wrong one
|
|
185
|
+
if (matches.length > 1) {
|
|
186
|
+
this.error(
|
|
187
|
+
`"${input}" matches ${matches.length} simple tasks:\n` +
|
|
188
|
+
matches.map(match => ` "${match.text}" in column "${match.column}"`).join('\n') +
|
|
189
|
+
'\nEdit the board file directly, or rename one of them'
|
|
190
|
+
);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
return matches[0];
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Report rules that were skipped during an operation
|
|
198
|
+
*
|
|
199
|
+
* A rule that quietly stops working is worse than no rule, so these go to stderr and aren't
|
|
200
|
+
* suppressed by verbose - the operation itself succeeded, which is why they aren't errors
|
|
201
|
+
* @param {object} kanbn The board-scoped Kanbn instance the operation ran on
|
|
202
|
+
*/
|
|
203
|
+
showActionWarnings(kanbn) {
|
|
204
|
+
const warnings = (kanbn && kanbn.lastActionWarnings) || [];
|
|
205
|
+
if (!warnings.length) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for (const warning of warnings) {
|
|
209
|
+
console.error(this.replaceTags(`{d}action:{d} ${warning}`));
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Show a hint about tasks that a board references but which have no task file. These are skipped
|
|
215
|
+
* rather than fatal, so the hint is the only sign that the board is showing less than it lists
|
|
216
|
+
* @param {object[]} missing Missing task entries from kanbn.findMissingTaskFiles()
|
|
217
|
+
* @param {boolean} show False to suppress the hint
|
|
218
|
+
*/
|
|
219
|
+
showMissingTaskFilesHint(missing, show = true) {
|
|
220
|
+
if (!show || !missing.length) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const summary =
|
|
224
|
+
`${missing.length} ${missing.length === 1 ? 'task has' : 'tasks have'} no task file and ` +
|
|
225
|
+
`${missing.length === 1 ? 'was' : 'were'} skipped`;
|
|
226
|
+
console.log(this.replaceTags(`\n{d}${summary} - run{d} {b}kanbn validate{b} {d}for details{d}`));
|
|
227
|
+
},
|
|
228
|
+
|
|
22
229
|
/**
|
|
23
230
|
* Show a hint about tasks whose dates don't match the column they're in. Hints are suppressed
|
|
24
231
|
* for machine-readable output and when the board sets verbose to false
|
|
@@ -39,6 +246,62 @@ module.exports = (() => {
|
|
|
39
246
|
));
|
|
40
247
|
},
|
|
41
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Build the prompt definition for a task's assigned user
|
|
251
|
+
*
|
|
252
|
+
* With contributors configured this is an autocomplete over the list, which is where the typo
|
|
253
|
+
* mitigation comes from; without them it's the free-text input it has always been. Free text is
|
|
254
|
+
* accepted either way - contributors are advisory, never an enum, so a name that isn't in the
|
|
255
|
+
* list is still a perfectly good answer
|
|
256
|
+
* @param {object} options The prompt properties to merge in, i.e. name, message, default, when
|
|
257
|
+
* @param {object[]} contributors The workspace's normalised contributors
|
|
258
|
+
* @return {object} A prompt definition
|
|
259
|
+
*/
|
|
260
|
+
assignedPrompt(options, contributors) {
|
|
261
|
+
if (!contributors.length) {
|
|
262
|
+
return { type: 'input', ...options };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Suggestions are the canonical names, because that's the value written into the task file
|
|
266
|
+
const names = contributors.map(contributor => contributor.name);
|
|
267
|
+
return {
|
|
268
|
+
type: 'autocomplete',
|
|
269
|
+
suggestOnly: true,
|
|
270
|
+
source: (answers, input) => Promise.resolve(
|
|
271
|
+
fuzzy.filter(input || '', names).map(result => result.string)
|
|
272
|
+
),
|
|
273
|
+
...options
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Expand "@me" in a filter value to the current user
|
|
279
|
+
*
|
|
280
|
+
* The substitution is kept here, at the argument-parsing boundary: "@me" means something when
|
|
281
|
+
* filtering and nothing at all when setting a value, and a literal "@me" in a task file is
|
|
282
|
+
* nonsense nobody needs supported
|
|
283
|
+
* @param {object} kanbn The board-scoped Kanbn instance
|
|
284
|
+
* @param {string|string[]} value The filter value from the arguments
|
|
285
|
+
* @return {Promise<string|string[]|null>} The expanded value, or null if "@me" can't be resolved
|
|
286
|
+
*/
|
|
287
|
+
async expandCurrentUser(kanbn, value) {
|
|
288
|
+
const isMe = v => typeof v === 'string' && v.trim() === '@me';
|
|
289
|
+
const values = this.arrayArg(value);
|
|
290
|
+
if (!values.some(isMe)) {
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
const currentUser = await kanbn.currentUser();
|
|
294
|
+
if (!currentUser) {
|
|
295
|
+
this.error(
|
|
296
|
+
"Unable to work out who you are, so {b}@me{b} can't be resolved\n" +
|
|
297
|
+
'Set {b}KANBN_USER{b}, or set a git username with {b}git config user.name "your name"{b}'
|
|
298
|
+
);
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const expanded = values.map(v => (isMe(v) ? currentUser : v));
|
|
302
|
+
return Array.isArray(value) ? expanded : expanded[0];
|
|
303
|
+
},
|
|
304
|
+
|
|
42
305
|
/**
|
|
43
306
|
* Convert a string to simplified paramcase, e.g:
|
|
44
307
|
* PascalCase -> pascalcase
|
|
@@ -166,6 +429,17 @@ module.exports = (() => {
|
|
|
166
429
|
return s;
|
|
167
430
|
},
|
|
168
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Coerce a terminal width into a usable number. The terminal width is unavailable when stdout
|
|
434
|
+
* isn't a TTY (i.e. when the output is piped or redirected), so fall back to a default
|
|
435
|
+
* @param {any} width The reported terminal width
|
|
436
|
+
* @param {number} [fallback=80] The width to use when the reported width is unusable
|
|
437
|
+
* @return {number} A usable terminal width
|
|
438
|
+
*/
|
|
439
|
+
terminalWidth(width, fallback = 80) {
|
|
440
|
+
return Number.isInteger(width) && width > 0 ? width : fallback;
|
|
441
|
+
},
|
|
442
|
+
|
|
169
443
|
/**
|
|
170
444
|
* Zip 2 arrays together, i.e. ([1, 2, 3], [a, b, c]) => [[1, a], [2, b], [3, c]]
|
|
171
445
|
* @param {any[]} a
|