@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.
Files changed (100) hide show
  1. package/README.md +3 -1
  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 +55 -0
  7. package/docs/commands/add.txt +17 -1
  8. package/docs/commands/archive.txt +11 -0
  9. package/docs/commands/board.txt +9 -0
  10. package/docs/commands/boards.txt +34 -0
  11. package/docs/commands/burndown.txt +7 -0
  12. package/docs/commands/comment.txt +13 -1
  13. package/docs/commands/contributors.txt +58 -0
  14. package/docs/commands/edit.txt +18 -1
  15. package/docs/commands/find.txt +39 -1
  16. package/docs/commands/gantt.txt +6 -0
  17. package/docs/commands/help.txt +2 -0
  18. package/docs/commands/history.txt +6 -0
  19. package/docs/commands/init.txt +13 -0
  20. package/docs/commands/move.txt +26 -0
  21. package/docs/commands/remove.txt +22 -1
  22. package/docs/commands/rename.txt +5 -0
  23. package/docs/commands/restore.txt +12 -0
  24. package/docs/commands/sort.txt +23 -0
  25. package/docs/commands/sprint.txt +9 -0
  26. package/docs/commands/status.txt +10 -1
  27. package/docs/commands/task.txt +9 -0
  28. package/docs/commands/validate.txt +29 -1
  29. package/docs/contributors.md +145 -0
  30. package/docs/filtering-and-sorting.md +60 -3
  31. package/docs/index-structure.md +145 -11
  32. package/docs/index.md +6 -2
  33. package/docs/multiple-boards.md +259 -0
  34. package/docs/quick-start.md +21 -1
  35. package/docs/task-structure.md +31 -3
  36. package/example/README.md +23 -0
  37. package/example/advanced/kanbn.yml +65 -0
  38. package/example/boards/.kanbn/design.md +31 -0
  39. package/example/boards/.kanbn/index.md +44 -0
  40. package/example/boards/.kanbn/tasks/add-usage-alert-emails.md +19 -0
  41. package/example/boards/.kanbn/tasks/build-tenant-settings-page.md +43 -0
  42. package/example/boards/.kanbn/tasks/create-organization-switcher.md +44 -0
  43. package/example/boards/.kanbn/tasks/design-onboarding-checklist.md +22 -0
  44. package/example/boards/.kanbn/tasks/refresh-marketing-site.md +28 -0
  45. package/example/boards/.kanbn/tasks/ship-billing-portal.md +29 -0
  46. package/package.json +9 -7
  47. package/routes/add.json +40 -12
  48. package/routes/archive.json +14 -2
  49. package/routes/board.json +11 -3
  50. package/routes/boards.json +30 -0
  51. package/routes/burndown.json +23 -7
  52. package/routes/comment.json +19 -5
  53. package/routes/contributors.json +18 -0
  54. package/routes/edit.json +37 -11
  55. package/routes/find.json +37 -12
  56. package/routes/gantt.json +20 -6
  57. package/routes/history.json +39 -25
  58. package/routes/init.json +3 -1
  59. package/routes/move.json +24 -6
  60. package/routes/remove.json +18 -3
  61. package/routes/rename.json +11 -3
  62. package/routes/restore.json +14 -2
  63. package/routes/sort.json +40 -11
  64. package/routes/sprint.json +14 -4
  65. package/routes/status.json +23 -7
  66. package/routes/task.json +10 -2
  67. package/routes/validate.json +18 -5
  68. package/skills/kanbn-plan/SKILL.md +10 -1
  69. package/skills/kanbn-replan/SKILL.md +6 -1
  70. package/src/actions.js +904 -0
  71. package/src/board.js +25 -2
  72. package/src/controller/add.js +72 -55
  73. package/src/controller/archive.js +9 -4
  74. package/src/controller/board.js +21 -13
  75. package/src/controller/boards.js +140 -0
  76. package/src/controller/burndown.js +14 -7
  77. package/src/controller/comment.js +13 -6
  78. package/src/controller/contributors.js +166 -0
  79. package/src/controller/edit.js +48 -18
  80. package/src/controller/find.js +87 -13
  81. package/src/controller/gantt.js +13 -6
  82. package/src/controller/history.js +13 -6
  83. package/src/controller/init.js +39 -4
  84. package/src/controller/move.js +153 -25
  85. package/src/controller/remove.js +73 -13
  86. package/src/controller/rename.js +8 -4
  87. package/src/controller/restore.js +24 -8
  88. package/src/controller/sort.js +59 -3
  89. package/src/controller/sprint.js +31 -7
  90. package/src/controller/status.js +8 -4
  91. package/src/controller/task.js +34 -10
  92. package/src/controller/validate.js +197 -7
  93. package/src/git-user-name.js +9 -0
  94. package/src/git-user.js +55 -0
  95. package/src/main.d.ts +387 -9
  96. package/src/main.js +2757 -113
  97. package/src/parse-index.js +219 -17
  98. package/src/parse-task.js +16 -0
  99. package/src/utility.js +274 -0
  100. package/coverage/tmp/coverage-214292-1787777191526-0.json +0 -1
@@ -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
- if (columnNames.length) {
246
- columns = Object.fromEntries(columnNames.map(columnName => {
247
- try {
248
- return [
249
- columnName,
250
- index[columnName].content
251
- ? marked.lexer(index[columnName].content)[0].items.map(item => item.tokens[0].tokens[0].text)
252
- : []
253
- ];
254
- } catch (error) {
255
- throw new Error(`column "${columnName}" must contain a list`);
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
- return { name, description, options, columns };
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].map(task => `- [${task}](tasks/${task}.md)`).join('\n')
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