@basementuniverse/kanbn 2.0.0 → 2.1.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 (87) hide show
  1. package/README.md +2 -1
  2. package/docs/advanced-configuration.md +23 -0
  3. package/docs/commands/add.txt +9 -0
  4. package/docs/commands/archive.txt +5 -0
  5. package/docs/commands/board.txt +5 -0
  6. package/docs/commands/boards.txt +34 -0
  7. package/docs/commands/burndown.txt +6 -0
  8. package/docs/commands/comment.txt +5 -0
  9. package/docs/commands/edit.txt +5 -0
  10. package/docs/commands/find.txt +11 -0
  11. package/docs/commands/gantt.txt +5 -0
  12. package/docs/commands/help.txt +1 -0
  13. package/docs/commands/history.txt +5 -0
  14. package/docs/commands/init.txt +13 -0
  15. package/docs/commands/move.txt +9 -0
  16. package/docs/commands/remove.txt +12 -1
  17. package/docs/commands/rename.txt +5 -0
  18. package/docs/commands/restore.txt +6 -0
  19. package/docs/commands/sort.txt +5 -0
  20. package/docs/commands/sprint.txt +9 -0
  21. package/docs/commands/status.txt +10 -1
  22. package/docs/commands/task.txt +5 -0
  23. package/docs/commands/validate.txt +15 -0
  24. package/docs/index-structure.md +26 -0
  25. package/docs/index.md +3 -1
  26. package/docs/multiple-boards.md +258 -0
  27. package/docs/quick-start.md +21 -1
  28. package/docs/task-structure.md +22 -1
  29. package/example/README.md +23 -0
  30. package/example/boards/.kanbn/design.md +31 -0
  31. package/example/boards/.kanbn/index.md +44 -0
  32. package/example/boards/.kanbn/tasks/add-usage-alert-emails.md +19 -0
  33. package/example/boards/.kanbn/tasks/build-tenant-settings-page.md +43 -0
  34. package/example/boards/.kanbn/tasks/create-organization-switcher.md +44 -0
  35. package/example/boards/.kanbn/tasks/design-onboarding-checklist.md +22 -0
  36. package/example/boards/.kanbn/tasks/refresh-marketing-site.md +28 -0
  37. package/example/boards/.kanbn/tasks/ship-billing-portal.md +29 -0
  38. package/package.json +9 -7
  39. package/routes/add.json +35 -11
  40. package/routes/archive.json +10 -2
  41. package/routes/board.json +11 -3
  42. package/routes/boards.json +30 -0
  43. package/routes/burndown.json +23 -7
  44. package/routes/comment.json +14 -4
  45. package/routes/edit.json +32 -10
  46. package/routes/find.json +37 -12
  47. package/routes/gantt.json +20 -6
  48. package/routes/history.json +39 -25
  49. package/routes/init.json +3 -1
  50. package/routes/move.json +22 -6
  51. package/routes/remove.json +15 -4
  52. package/routes/rename.json +11 -3
  53. package/routes/restore.json +8 -2
  54. package/routes/sort.json +35 -11
  55. package/routes/sprint.json +14 -4
  56. package/routes/status.json +23 -7
  57. package/routes/task.json +10 -2
  58. package/routes/validate.json +18 -5
  59. package/skills/kanbn-plan/SKILL.md +10 -1
  60. package/skills/kanbn-replan/SKILL.md +6 -1
  61. package/src/board.js +1 -1
  62. package/src/controller/add.js +52 -46
  63. package/src/controller/archive.js +8 -4
  64. package/src/controller/board.js +8 -9
  65. package/src/controller/boards.js +140 -0
  66. package/src/controller/burndown.js +9 -5
  67. package/src/controller/comment.js +9 -5
  68. package/src/controller/edit.js +9 -5
  69. package/src/controller/find.js +12 -9
  70. package/src/controller/gantt.js +8 -4
  71. package/src/controller/history.js +8 -4
  72. package/src/controller/init.js +39 -4
  73. package/src/controller/move.js +69 -15
  74. package/src/controller/remove.js +34 -11
  75. package/src/controller/rename.js +8 -4
  76. package/src/controller/restore.js +23 -8
  77. package/src/controller/sort.js +19 -3
  78. package/src/controller/sprint.js +31 -7
  79. package/src/controller/status.js +8 -4
  80. package/src/controller/task.js +22 -9
  81. package/src/controller/validate.js +60 -7
  82. package/src/git-user-name.js +19 -0
  83. package/src/main.d.ts +184 -6
  84. package/src/main.js +1333 -63
  85. package/src/parse-index.js +14 -0
  86. package/src/parse-task.js +16 -0
  87. package/src/utility.js +140 -0
@@ -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: {
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
@@ -19,6 +19,135 @@ module.exports = (() => {
19
19
  !dontExit && process.env.KANBN_ENV !== 'test' && process.exit(1);
20
20
  },
21
21
 
22
+ /**
23
+ * Resolve the board a command targets and return a Kanbn instance scoped to it, printing an
24
+ * error and returning null if the workspace or the board doesn't exist
25
+ * @param {object} workspace The workspace-scoped Kanbn instance
26
+ * @param {object} args The parsed command arguments
27
+ * @return {Promise<object|null>} A board-scoped Kanbn instance, or null if it can't be resolved
28
+ */
29
+ async resolveBoard(workspace, args) {
30
+
31
+ // Sprint numbers and names are relative to one board's list, so an aggregate across boards
32
+ // can't resolve them. Refuse rather than guess
33
+ if (args['all-boards'] && args.sprint) {
34
+ this.error(
35
+ '{b}--sprint{b} and {b}--all-boards{b} cannot be used together, because sprint numbers ' +
36
+ 'and names are relative to a single board'
37
+ );
38
+ return null;
39
+ }
40
+ const kanbn = await workspace.boardFromArgs(args);
41
+ if (!await kanbn.workspaceInitialised()) {
42
+ this.error('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
43
+ return null;
44
+ }
45
+ if (!await kanbn.initialised()) {
46
+ this.error(
47
+ `Board "${await kanbn.resolveBoardSlug()}" doesn't exist\n` +
48
+ 'Try running: {b}kanbn boards{b}'
49
+ );
50
+ return null;
51
+ }
52
+ return kanbn;
53
+ },
54
+
55
+ /**
56
+ * Resolve the boards a command targets, along with the column to use on each. `-b` is repeatable
57
+ * and pairs positionally with `-c`: board i uses the i-th `-c` if there is one, otherwise the
58
+ * first `-c`, otherwise that board's first column (Q5). Prints an error and returns null if any
59
+ * board can't be resolved
60
+ * @param {object} workspace The workspace-scoped Kanbn instance
61
+ * @param {object} args The parsed command arguments
62
+ * @return {Promise<object[]|null>} One entry per target board, or null on error
63
+ */
64
+ async resolveBoardTargets(workspace, args) {
65
+ const boardArgs = args.board === undefined
66
+ ? []
67
+ : this.arrayArg(args.board).filter(slug => slug !== undefined && slug !== '');
68
+ const columnArgs = args.column === undefined ? [] : this.arrayArg(args.column);
69
+ const slugs = boardArgs.length ? boardArgs : [await workspace.resolveTargetBoard(null)];
70
+
71
+ // With a single target board an unknown column is an error, exactly as it has always been.
72
+ // Falling back only makes sense when several boards are targeted at once, where a column can
73
+ // legitimately exist on one and not another
74
+ const allowFallback = slugs.length > 1;
75
+
76
+ const targets = [];
77
+ for (let i = 0; i < slugs.length; i++) {
78
+ const kanbn = await this.resolveBoard(workspace, { board: slugs[i] });
79
+ if (kanbn === null) {
80
+ return null;
81
+ }
82
+ let index;
83
+ try {
84
+ index = await kanbn.getIndex();
85
+ } catch (error) {
86
+ this.error(error);
87
+ return null;
88
+ }
89
+ const columnNames = Object.keys(index.columns);
90
+ if (!columnNames.length) {
91
+ this.error('No columns defined in the index\nTry running {b}kanbn init -c "column name"{b}');
92
+ return null;
93
+ }
94
+
95
+ const requestedColumn = columnArgs.length
96
+ ? (columnArgs[i] !== undefined ? columnArgs[i] : columnArgs[0])
97
+ : null;
98
+ let columnName = requestedColumn === null ? columnNames[0] : requestedColumn;
99
+ let fallback = false;
100
+ if (!(columnName in index.columns)) {
101
+ if (!allowFallback) {
102
+ this.error(`Column "${columnName}" doesn't exist`);
103
+ return null;
104
+ }
105
+ columnName = columnNames[0];
106
+ fallback = true;
107
+ }
108
+ targets.push({
109
+ kanbn,
110
+ index,
111
+ slug: await kanbn.resolveBoardSlug(),
112
+ columnName,
113
+ requestedColumn,
114
+ fallback
115
+ });
116
+ }
117
+ return targets;
118
+ },
119
+
120
+ /**
121
+ * Tell the user when a target board didn't have the column that was asked for, so that a task
122
+ * quietly landing somewhere else is never a surprise
123
+ * @param {object[]} targets Targets from resolveBoardTargets()
124
+ * @param {boolean} show False to suppress the notice
125
+ */
126
+ showColumnFallbackNotices(targets, show = true) {
127
+ if (!show) {
128
+ return;
129
+ }
130
+ for (const target of targets.filter(t => t.fallback)) {
131
+ console.log(this.replaceTags(
132
+ `{d}Board "${target.slug}" has no column "${target.requestedColumn}" - using "${target.columnName}"{d}`
133
+ ));
134
+ }
135
+ },
136
+
137
+ /**
138
+ * Show a hint about how many boards this workspace has, so that a command showing one board
139
+ * doesn't look like it's showing everything. Suppressed for machine-readable output and when the
140
+ * board sets verbose to false
141
+ * @param {object[]} boards The list of boards
142
+ * @param {boolean} show False to suppress the hint
143
+ */
144
+ showBoardCountHint(boards, show = true) {
145
+ if (!show || boards.length < 2) {
146
+ return;
147
+ }
148
+ console.log(this.replaceTags(`\n{d}${boards.length} boards available -{d} {b}kanbn boards{b}`));
149
+ },
150
+
22
151
  /**
23
152
  * Show a hint about tasks whose dates don't match the column they're in. Hints are suppressed
24
153
  * for machine-readable output and when the board sets verbose to false
@@ -166,6 +295,17 @@ module.exports = (() => {
166
295
  return s;
167
296
  },
168
297
 
298
+ /**
299
+ * Coerce a terminal width into a usable number. The terminal width is unavailable when stdout
300
+ * isn't a TTY (i.e. when the output is piped or redirected), so fall back to a default
301
+ * @param {any} width The reported terminal width
302
+ * @param {number} [fallback=80] The width to use when the reported width is unusable
303
+ * @return {number} A usable terminal width
304
+ */
305
+ terminalWidth(width, fallback = 80) {
306
+ return Number.isInteger(width) && width > 0 ? width : fallback;
307
+ },
308
+
169
309
  /**
170
310
  * Zip 2 arrays together, i.e. ([1, 2, 3], [a, b, c]) => [[1, a], [2, b], [3, c]]
171
311
  * @param {any[]} a