@basementuniverse/kanbn 2.1.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/coverage/tmp/coverage-916017-1788028598821-0.json +1 -0
  3. package/coverage/tmp/{coverage-214293-1787777184569-0.json → coverage-916018-1788028597179-0.json} +1 -1
  4. package/coverage/tmp/coverage-916036-1788028598796-0.json +1 -0
  5. package/docs/actions.md +337 -0
  6. package/docs/advanced-configuration.md +32 -0
  7. package/docs/commands/add.txt +8 -1
  8. package/docs/commands/archive.txt +6 -0
  9. package/docs/commands/board.txt +4 -0
  10. package/docs/commands/burndown.txt +1 -0
  11. package/docs/commands/comment.txt +8 -1
  12. package/docs/commands/contributors.txt +58 -0
  13. package/docs/commands/edit.txt +13 -1
  14. package/docs/commands/find.txt +28 -1
  15. package/docs/commands/gantt.txt +1 -0
  16. package/docs/commands/help.txt +1 -0
  17. package/docs/commands/history.txt +1 -0
  18. package/docs/commands/move.txt +17 -0
  19. package/docs/commands/remove.txt +10 -0
  20. package/docs/commands/restore.txt +6 -0
  21. package/docs/commands/sort.txt +18 -0
  22. package/docs/commands/task.txt +4 -0
  23. package/docs/commands/validate.txt +14 -1
  24. package/docs/contributors.md +145 -0
  25. package/docs/filtering-and-sorting.md +60 -3
  26. package/docs/index-structure.md +119 -11
  27. package/docs/index.md +3 -1
  28. package/docs/multiple-boards.md +1 -0
  29. package/docs/task-structure.md +9 -2
  30. package/example/advanced/kanbn.yml +65 -0
  31. package/package.json +1 -1
  32. package/routes/add.json +5 -1
  33. package/routes/archive.json +6 -2
  34. package/routes/comment.json +5 -1
  35. package/routes/contributors.json +18 -0
  36. package/routes/edit.json +5 -1
  37. package/routes/move.json +4 -2
  38. package/routes/remove.json +6 -2
  39. package/routes/restore.json +6 -0
  40. package/routes/sort.json +5 -0
  41. package/src/actions.js +904 -0
  42. package/src/board.js +24 -1
  43. package/src/controller/add.js +21 -10
  44. package/src/controller/archive.js +1 -0
  45. package/src/controller/board.js +13 -4
  46. package/src/controller/burndown.js +5 -2
  47. package/src/controller/comment.js +5 -2
  48. package/src/controller/contributors.js +166 -0
  49. package/src/controller/edit.js +40 -14
  50. package/src/controller/find.js +75 -4
  51. package/src/controller/gantt.js +5 -2
  52. package/src/controller/history.js +5 -2
  53. package/src/controller/move.js +84 -10
  54. package/src/controller/remove.js +39 -2
  55. package/src/controller/restore.js +1 -0
  56. package/src/controller/sort.js +40 -0
  57. package/src/controller/task.js +12 -1
  58. package/src/controller/validate.js +141 -4
  59. package/src/git-user-name.js +5 -15
  60. package/src/git-user.js +55 -0
  61. package/src/main.d.ts +204 -4
  62. package/src/main.js +1412 -38
  63. package/src/parse-index.js +205 -17
  64. package/src/utility.js +135 -1
  65. package/coverage/tmp/coverage-214292-1787777191526-0.json +0 -1
@@ -50,6 +50,7 @@ function moveTask(taskId, columnName, position = null, relative = false, add = f
50
50
  .moveTask(taskId, columnName, position, relative, add)
51
51
  .then(taskId => {
52
52
  console.log(`Moved task "${taskId}" to column "${columnName}"`);
53
+ utility.showActionWarnings(kanbn);
53
54
  })
54
55
  .catch(error => {
55
56
  utility.error(error);
@@ -72,6 +73,42 @@ async function addTaskToBoard(taskId, columnName, boardSlug, verbose) {
72
73
  ));
73
74
  }
74
75
  console.log(`Moved task "${taskId}" to column "${columnName}"`);
76
+ utility.showActionWarnings(kanbn);
77
+ } catch (error) {
78
+ utility.error(error);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Move a simple task to another column on the board it's on
84
+ * @param {object} boardKanbn The board-scoped Kanbn instance
85
+ * @param {string} input
86
+ * @param {?string} columnName The column to move to, or null to leave it where it is
87
+ * @param {?number} position
88
+ */
89
+ async function moveSimpleTask(boardKanbn, input, columnName, position) {
90
+ try {
91
+ const moved = await boardKanbn.moveSimpleTask(input, columnName, position);
92
+ console.log(`Moved simple task "${moved.text}" to column "${moved.toColumn}"`);
93
+ } catch (error) {
94
+ utility.error(error);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Move a simple task onto another board
100
+ * @param {object} sourceKanbn The board the simple task is on
101
+ * @param {string} input
102
+ * @param {string} targetSlug
103
+ * @param {?string} columnName
104
+ * @param {?number} position
105
+ */
106
+ async function moveSimpleTaskToBoard(sourceKanbn, input, targetSlug, columnName, position) {
107
+ try {
108
+ const moved = await sourceKanbn.moveSimpleTaskToBoard(input, targetSlug, columnName, position);
109
+ console.log(
110
+ `Moved simple task "${moved.text}" to column "${moved.toColumn}" on board "${moved.toBoard}"`
111
+ );
75
112
  } catch (error) {
76
113
  utility.error(error);
77
114
  }
@@ -107,6 +144,53 @@ module.exports = async args => {
107
144
  return;
108
145
  }
109
146
 
147
+ // Re-use sprint option for position
148
+ let newPosition = args.position || args.p;
149
+ if (newPosition) {
150
+ newPosition = parseInt(utility.trimLeftEscapeCharacters(newPosition));
151
+ if (isNaN(newPosition)) {
152
+ utility.error('Position value must be numeric');
153
+ return;
154
+ }
155
+ } else {
156
+ newPosition = null;
157
+ }
158
+
159
+ // A task file always wins, so a simple task - a line in a column that isn't a task link - is only
160
+ // looked for when nothing has this task id. Falling through leaves every existing error unchanged
161
+ if (!(await kanbn.taskFileExists(taskId))) {
162
+ const targetColumn = args.column ? utility.strArg(args.column) : null;
163
+ const matches = await kanbn.findSimpleTasks(taskId);
164
+ if (matches.length) {
165
+ const simpleTask = await utility.resolveSimpleTask(kanbn, taskId, null, matches);
166
+ if (simpleTask !== null) {
167
+ await moveSimpleTask(
168
+ kanbn,
169
+ taskId,
170
+ targetColumn === null ? simpleTask.column : targetColumn,
171
+ newPosition
172
+ );
173
+ }
174
+ return;
175
+ }
176
+
177
+ // A simple task lives in one board file rather than in a shared task file, so `-b` names the
178
+ // board it is moving to rather than the board it is on
179
+ if (args.board) {
180
+ const sourceKanbn = await workspace.boardFromArgs({});
181
+ if ((await sourceKanbn.findSimpleTasks(taskId)).length) {
182
+ await moveSimpleTaskToBoard(
183
+ sourceKanbn,
184
+ taskId,
185
+ await kanbn.resolveBoardSlug(),
186
+ targetColumn,
187
+ newPosition
188
+ );
189
+ return;
190
+ }
191
+ }
192
+ }
193
+
110
194
  // Moving a task on a board it isn't on yet adds it there, so that pulling work onto a board is one
111
195
  // command rather than two. --no-add turns that back into an error. The main board is unaffected:
112
196
  // moving an untracked task there has always been an error and still is
@@ -157,16 +241,6 @@ module.exports = async args => {
157
241
 
158
242
  // Re-use sprint option for position
159
243
  const currentPosition = index.columns[currentColumnName].indexOf(taskId);
160
- let newPosition = args.position || args.p;
161
- if (newPosition) {
162
- newPosition = parseInt(utility.trimLeftEscapeCharacters(newPosition));
163
- if (isNaN(newPosition)) {
164
- utility.error('Position value must be numeric');
165
- return;
166
- }
167
- } else {
168
- newPosition = null;
169
- }
170
244
 
171
245
  // Get a list of sorted columns
172
246
  const sortedColumnNames = 'columnSorting' in index.options ? Object.keys(index.options.columnSorting) : [];
@@ -5,6 +5,34 @@ const inquirer = require('inquirer');
5
5
  // The board this command targets - set from --board before anything else runs
6
6
  let kanbn = workspace;
7
7
 
8
+ /**
9
+ * Remove a simple task - a line in a column that isn't a task link. There is no file to delete and
10
+ * nothing to archive: the line is the whole of it
11
+ * @param {string} input
12
+ * @param {boolean} force True to skip the confirmation prompt
13
+ */
14
+ async function removeSimpleTask(input, force) {
15
+ if (!force) {
16
+ const answers = await inquirer.prompt([
17
+ {
18
+ type: 'confirm',
19
+ message: 'Are you sure you want to remove this simple task?',
20
+ name: 'sure',
21
+ default: false
22
+ }
23
+ ]);
24
+ if (!answers.sure) {
25
+ return;
26
+ }
27
+ }
28
+ try {
29
+ const removed = await kanbn.deleteSimpleTask(input);
30
+ console.log(`Removed simple task "${removed.text}" from column "${removed.column}"`);
31
+ } catch (error) {
32
+ utility.error(error);
33
+ }
34
+ }
35
+
8
36
  /**
9
37
  * Remove a task
10
38
  * @param {string} taskId
@@ -21,6 +49,7 @@ function removeTask(taskId, removeFile, allBoards, boardSlug, secondaryBoard) {
21
49
  `Removed task "${taskId}"${removeFile ? ' file and index entry' : ' from the index'}` +
22
50
  (secondaryBoard && !allBoards ? ` on board "${boardSlug}"` : '')
23
51
  );
52
+ utility.showActionWarnings(kanbn);
24
53
  })
25
54
  .catch(error => {
26
55
 
@@ -50,11 +79,19 @@ module.exports = async args => {
50
79
  return;
51
80
  }
52
81
 
53
- // Make sure the task exists
82
+ // Make sure the task exists. A task file always wins, so a simple task is only looked for when
83
+ // nothing has this task id
54
84
  try {
55
85
  await kanbn.taskExists(taskId);
56
86
  } catch (error) {
57
- utility.error(error);
87
+ if (await kanbn.taskFileExists(taskId)) {
88
+ utility.error(error);
89
+ return;
90
+ }
91
+ const simpleTask = await utility.resolveSimpleTask(kanbn, taskId, error);
92
+ if (simpleTask !== null) {
93
+ await removeSimpleTask(taskId, !!args.force);
94
+ }
58
95
  return;
59
96
  }
60
97
 
@@ -14,6 +14,7 @@ function restoreTask(taskId, columnName, singleBoard) {
14
14
  .restoreTask(taskId, columnName, singleBoard)
15
15
  .then(taskId => {
16
16
  console.log(`Restored task "${taskId}" from the archive`);
17
+ utility.showActionWarnings(kanbn);
17
18
 
18
19
  // A task is restored to every board it was on when it was archived. A board that has since been
19
20
  // deleted is skipped rather than failing the restore, but the user should hear about it
@@ -181,6 +181,46 @@ const sorterFields = [
181
181
  '--assigned'
182
182
  ],
183
183
  filterable: true
184
+ },
185
+ {
186
+ name: 'Overdue',
187
+ field: 'overdue',
188
+ options: [
189
+ '--overdue'
190
+ ],
191
+ filterable: false
192
+ },
193
+ {
194
+ name: 'Is started',
195
+ field: 'isStarted',
196
+ options: [
197
+ '--is-started'
198
+ ],
199
+ filterable: false
200
+ },
201
+ {
202
+ name: 'Is completed',
203
+ field: 'isCompleted',
204
+ options: [
205
+ '--is-completed'
206
+ ],
207
+ filterable: false
208
+ },
209
+ {
210
+ name: 'In started column',
211
+ field: 'inStartedColumn',
212
+ options: [
213
+ '--in-started-column'
214
+ ],
215
+ filterable: false
216
+ },
217
+ {
218
+ name: 'In completed column',
219
+ field: 'inCompletedColumn',
220
+ options: [
221
+ '--in-completed-column'
222
+ ],
223
+ filterable: false
184
224
  }
185
225
  ];
186
226
 
@@ -53,10 +53,21 @@ module.exports = async (args) => {
53
53
  return;
54
54
  }
55
55
 
56
- // Make sure the task exists
56
+ // Make sure the task exists. A line in a column that isn't a task link has no task file to show,
57
+ // so say what it is and how to turn it into one rather than reporting a missing file
57
58
  try {
58
59
  await kanbn.taskExists(taskId);
59
60
  } catch (error) {
61
+ if (!(await kanbn.taskFileExists(taskId))) {
62
+ const matches = await kanbn.findSimpleTasks(taskId);
63
+ if (matches.length === 1) {
64
+ utility.error(
65
+ `"${matches[0].text}" is a simple task in column "${matches[0].column}" - it has no task file\n` +
66
+ `Run {b}kanbn edit "${matches[0].text}"{b} to promote it to a task`
67
+ );
68
+ return;
69
+ }
70
+ }
60
71
  utility.error(error);
61
72
  return;
62
73
  }
@@ -30,6 +30,97 @@ function showDrift(drift, json) {
30
30
  }
31
31
  }
32
32
 
33
+ /**
34
+ * Show lines in columns that aren't task links. These are preserved and ignored, so this is
35
+ * information rather than a problem - except for a line that looks like a task link with a typo in
36
+ * it, which is the one way a task can silently stop being tracked
37
+ * @param {object[]} warnings
38
+ */
39
+ function showColumnContentWarnings(warnings) {
40
+ if (!warnings.length) {
41
+ return;
42
+ }
43
+ const malformed = warnings.filter(w => w.type === 'malformed-task-link');
44
+ const untracked = warnings.filter(w => w.type === 'untracked-task-line');
45
+ const other = warnings.filter(w => w.type === 'non-task-line');
46
+ if (malformed.length) {
47
+ console.log(utility.replaceTags(
48
+ `${malformed.length} ${malformed.length === 1 ? 'line looks' : 'lines look'} like a ` +
49
+ `{b}malformed task link{b} and ${malformed.length === 1 ? 'is' : 'are'} not being tracked:`
50
+ ));
51
+ for (const warning of malformed) {
52
+ console.log(` ${warning.column}: ${warning.text}`);
53
+ }
54
+ }
55
+ if (untracked.length) {
56
+ console.log(utility.replaceTags(
57
+ `${untracked.length} ${untracked.length === 1 ? 'line names' : 'lines name'} a task file that ` +
58
+ `exists but ${untracked.length === 1 ? 'is' : 'are'} {b}not written as a link{b}, so ` +
59
+ `${untracked.length === 1 ? 'it isn\'t' : 'they aren\'t'} tracked:`
60
+ ));
61
+ for (const warning of untracked) {
62
+ console.log(` ${warning.column}: ${warning.text}`);
63
+ }
64
+ }
65
+ if (other.length) {
66
+ console.log(
67
+ other.length === 1
68
+ ? "1 line in a column isn't a task link (preserved, but ignored):"
69
+ : `${other.length} lines in columns aren't task links (preserved, but ignored):`
70
+ );
71
+ for (const warning of other) {
72
+ console.log(` ${warning.column}: ${warning.text}`);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Show tasks whose assigned user or comment author isn't a known contributor. These are advisory:
79
+ * contributors are a convenience list, not an enum, and every one of these tasks works exactly as it
80
+ * always has
81
+ * @param {object[]} warnings
82
+ */
83
+ function showContributorWarnings(warnings) {
84
+ if (!warnings.length) {
85
+ return;
86
+ }
87
+
88
+ // One line per name rather than per task, because "Gordon appears in 12 tasks" is the useful shape
89
+ const byValue = new Map();
90
+ for (const warning of warnings) {
91
+ if (!byValue.has(warning.value)) {
92
+ byValue.set(warning.value, []);
93
+ }
94
+ byValue.get(warning.value).push(warning.task);
95
+ }
96
+ console.log(utility.replaceTags(
97
+ `${byValue.size} ${byValue.size === 1 ? 'name is' : 'names are'} used in tasks but ` +
98
+ `${byValue.size === 1 ? "isn't" : "aren't"} a {b}known contributor{b}:`
99
+ ));
100
+ for (const [value, tasks] of byValue) {
101
+ console.log(` ${value}: ${tasks.join(', ')}`);
102
+ }
103
+ console.log(utility.replaceTags('Run {b}kanbn contributors --usage{b} for more detail.'));
104
+ }
105
+
106
+ /**
107
+ * Show rules that are legal but probably not what their author meant. A rule that is wrong in the
108
+ * file is an error rather than a warning, and is reported with the rest of them
109
+ * @param {object[]} warnings
110
+ */
111
+ function showActionWarnings(warnings) {
112
+ if (!warnings.length) {
113
+ return;
114
+ }
115
+ console.log(utility.replaceTags(
116
+ `${warnings.length} ${warnings.length === 1 ? 'action rule needs' : 'action rules need'} ` +
117
+ 'a {b}second look{b}:'
118
+ ));
119
+ for (const warning of warnings) {
120
+ console.log(` ${warning.message}`);
121
+ }
122
+ }
123
+
33
124
  /**
34
125
  * Show warnings about a multi-board workspace
35
126
  * @param {object[]} warnings
@@ -89,6 +180,35 @@ module.exports = async args => {
89
180
  }
90
181
  }
91
182
 
183
+ // Report lines in columns that aren't task links. Unlike the warnings above these apply to any
184
+ // workspace, so they're gathered whether or not it has more than one board
185
+ let columnContentWarnings = [];
186
+ try {
187
+ columnContentWarnings = await kanbn.findColumnContentWarnings();
188
+ } catch (error) {
189
+ utility.error(error);
190
+ return;
191
+ }
192
+
193
+ // Report action rules that work but probably don't do what their author meant
194
+ let actionWarnings = [];
195
+ try {
196
+ actionWarnings = await kanbn.findActionWarnings();
197
+ } catch (error) {
198
+ utility.error(error);
199
+ return;
200
+ }
201
+
202
+ // Report names used in tasks that the contributors list doesn't know about. A workspace with no
203
+ // contributors declared has none of these, so its output is exactly what it always was
204
+ let contributorWarnings = [];
205
+ try {
206
+ contributorWarnings = await kanbn.findContributorWarnings();
207
+ } catch (error) {
208
+ utility.error(error);
209
+ return;
210
+ }
211
+
92
212
  // Backfill missing started and completed dates
93
213
  if (args.fix) {
94
214
  let fixed;
@@ -99,6 +219,12 @@ module.exports = async args => {
99
219
  return;
100
220
  }
101
221
  if (!fixed.length) {
222
+ if (!args.json && (columnContentWarnings.length || contributorWarnings.length || actionWarnings.length)) {
223
+ showColumnContentWarnings(columnContentWarnings);
224
+ showContributorWarnings(contributorWarnings);
225
+ showActionWarnings(actionWarnings);
226
+ return;
227
+ }
102
228
  console.log('Everything OK, nothing to fix');
103
229
  return;
104
230
  }
@@ -124,9 +250,11 @@ module.exports = async args => {
124
250
  if (args.json) {
125
251
 
126
252
  // A multi-board workspace has warnings to report as well as drift, so JSON output carries both.
127
- // With a single board it stays the bare drift list it has always been
128
- if (multiBoard) {
129
- console.log(JSON.stringify({ drift, warnings: boardWarnings }, null, 2));
253
+ // With a single board it stays the bare drift list it has always been - unless there are
254
+ // warnings, which have nowhere to go in a bare drift list and are worth more than the shape
255
+ const warnings = [...boardWarnings, ...columnContentWarnings, ...contributorWarnings, ...actionWarnings];
256
+ if (multiBoard || warnings.length) {
257
+ console.log(JSON.stringify({ drift, warnings }, null, 2));
130
258
  return;
131
259
  }
132
260
  if (drift.length) {
@@ -139,8 +267,17 @@ module.exports = async args => {
139
267
  if (drift.length) {
140
268
  showDrift(drift, false);
141
269
  }
270
+ showColumnContentWarnings(columnContentWarnings);
271
+ showContributorWarnings(contributorWarnings);
272
+ showActionWarnings(actionWarnings);
142
273
  showBoardWarnings(boardWarnings);
143
- if (drift.length || boardWarnings.length) {
274
+ if (
275
+ drift.length ||
276
+ boardWarnings.length ||
277
+ columnContentWarnings.length ||
278
+ contributorWarnings.length ||
279
+ actionWarnings.length
280
+ ) {
144
281
  return;
145
282
  }
146
283
  console.log('Everything OK');
@@ -1,19 +1,9 @@
1
- const { execFileSync } = require('child_process');
1
+ const gitUser = require('./git-user');
2
2
 
3
3
  /**
4
- * Get the git username of the current user, or null if it can't be found. This replaces the
5
- * git-user-name package, which depends on a version of parse-git-config with an unpatched
6
- * prototype pollution vulnerability
4
+ * Get the git username of the current user, or null if it can't be found
5
+ *
6
+ * Kept as a named module because it was the public entry point before git-user.js existed
7
7
  * @return {string|null} The git username, or null if git isn't available or no username is set
8
8
  */
9
- module.exports = () => {
10
- try {
11
- const username = execFileSync('git', ['config', 'user.name'], {
12
- encoding: 'utf8',
13
- stdio: ['ignore', 'pipe', 'ignore']
14
- }).trim();
15
- return username || null;
16
- } catch (error) {
17
- return null;
18
- }
19
- };
9
+ module.exports = () => gitUser.name();
@@ -0,0 +1,55 @@
1
+ const { execFileSync } = require('child_process');
2
+
3
+ // Reading a git config value means spawning a process, and the current user is resolved from prompt
4
+ // defaults that run in loops. The values can't change while a command is running, so they're read at
5
+ // most once each per process
6
+ const memo = {};
7
+
8
+ /**
9
+ * Read a value from git config, or null if it can't be read. This replaces the git-user-name
10
+ * package, which depends on a version of parse-git-config with an unpatched prototype pollution
11
+ * vulnerability
12
+ * @param {string} key The git config key
13
+ * @return {?string} The value, or null if git isn't available or the value isn't set
14
+ */
15
+ function gitConfig(key) {
16
+ if (!(key in memo)) {
17
+ try {
18
+ memo[key] = execFileSync('git', ['config', key], {
19
+ encoding: 'utf8',
20
+ stdio: ['ignore', 'pipe', 'ignore']
21
+ }).trim() || null;
22
+ } catch (error) {
23
+ memo[key] = null;
24
+ }
25
+ }
26
+ return memo[key];
27
+ }
28
+
29
+ module.exports = {
30
+
31
+ /**
32
+ * Get the git username of the current user, or null if it can't be found
33
+ * @return {?string} The git username
34
+ */
35
+ name() {
36
+ return gitConfig('user.name');
37
+ },
38
+
39
+ /**
40
+ * Get the git email address of the current user, or null if it can't be found
41
+ * @return {?string} The git email address
42
+ */
43
+ email() {
44
+ return gitConfig('user.email');
45
+ },
46
+
47
+ /**
48
+ * Forget the memoized values. Only useful in tests, where git config is faked per case
49
+ */
50
+ clearCache() {
51
+ for (const key of Object.keys(memo)) {
52
+ delete memo[key];
53
+ }
54
+ }
55
+ };