@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
@@ -1,4 +1,4 @@
1
- const kanbn = require("../main");
1
+ const workspace = require("../main");
2
2
  const utility = require("../utility");
3
3
  const parseTask = require("../parse-task");
4
4
  const marked = require("marked");
@@ -6,19 +6,31 @@ const markedTerminalRenderer = require("marked-terminal");
6
6
 
7
7
  /**
8
8
  * Show task information
9
+ * @param {object} kanbn The board-scoped kanbn instance
9
10
  * @param {string} taskId
11
+ * @param {Record<string, string>} boards The boards this task appears on, and the column on each
12
+ * @param {boolean} json
10
13
  */
11
- function showTask(taskId, json = false) {
14
+ function showTask(kanbn, taskId, boards, json = false) {
12
15
  kanbn
13
16
  .getTask(taskId)
14
17
  .then((task) => {
15
18
  if (json) {
16
- console.log(JSON.stringify(task, null, 2));
19
+ console.log(JSON.stringify({ ...task, boards }, null, 2));
17
20
  } else {
18
21
  marked.setOptions({
19
22
  renderer: new markedTerminalRenderer(),
20
23
  });
21
- console.log(marked(parseTask.json2md(task)));
24
+
25
+ // Board membership isn't part of the task file - boards own membership, not tasks - so it's
26
+ // appended here rather than rendered from the task itself. Only worth showing once there is
27
+ // more than one board to be a member of
28
+ const boardSlugs = Object.keys(boards);
29
+ const boardsSection =
30
+ boardSlugs.length > 1
31
+ ? `\n## Boards\n\n${boardSlugs.map((slug) => `- ${slug}: ${boards[slug]}`).join("\n")}\n`
32
+ : "";
33
+ console.log(marked.parse(parseTask.json2md(task) + boardsSection));
22
34
  }
23
35
  })
24
36
  .catch((error) => {
@@ -27,9 +39,10 @@ function showTask(taskId, json = false) {
27
39
  }
28
40
 
29
41
  module.exports = async (args) => {
30
- // Make sure kanbn has been initialised
31
- if (!(await kanbn.initialised())) {
32
- utility.error("Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}");
42
+
43
+ // Work out which board this command targets
44
+ const kanbn = await utility.resolveBoard(workspace, args);
45
+ if (kanbn === null) {
33
46
  return;
34
47
  }
35
48
 
@@ -40,14 +53,25 @@ module.exports = async (args) => {
40
53
  return;
41
54
  }
42
55
 
43
- // 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
44
58
  try {
45
59
  await kanbn.taskExists(taskId);
46
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
+ }
47
71
  utility.error(error);
48
72
  return;
49
73
  }
50
74
 
51
- // Show the task
52
- showTask(taskId, args.json);
75
+ // Show the task, along with the boards it appears on
76
+ showTask(kanbn, taskId, await workspace.findTaskBoards(taskId), args.json);
53
77
  };
@@ -1,7 +1,10 @@
1
- const kanbn = require('../main');
1
+ const workspace = require('../main');
2
2
  const utility = require('../utility');
3
3
  const yaml = require('yamljs');
4
4
 
5
+ // The board this command targets - set from --board before anything else runs
6
+ let kanbn = workspace;
7
+
5
8
  /**
6
9
  * Show a list of tasks whose dates disagree with the column they're in
7
10
  * @param {object[]} drift
@@ -27,18 +30,127 @@ function showDrift(drift, json) {
27
30
  }
28
31
  }
29
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
+
124
+ /**
125
+ * Show warnings about a multi-board workspace
126
+ * @param {object[]} warnings
127
+ */
128
+ function showBoardWarnings(warnings) {
129
+ if (!warnings.length) {
130
+ return;
131
+ }
132
+ console.log(
133
+ `${warnings.length} ${warnings.length === 1 ? 'warning' : 'warnings'}:`
134
+ );
135
+ for (const warning of warnings) {
136
+ console.log(` ${warning.board === null ? 'workspace' : warning.board}: ${warning.message}`);
137
+ }
138
+ }
139
+
30
140
  module.exports = async args => {
31
141
 
32
- // Make sure kanbn has been initialised
33
- if (!await kanbn.initialised()) {
34
- utility.error('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
142
+ // Work out which board this command targets
143
+ const scoped = await utility.resolveBoard(workspace, args);
144
+ if (scoped === null) {
35
145
  return;
36
146
  }
147
+ kanbn = scoped;
37
148
 
38
- // Validate kanbn files
149
+ // Validate kanbn files, on this board or on every board in the workspace
150
+ const allBoards = !!args['all-boards'];
39
151
  let result;
40
152
  try {
41
- result = await kanbn.validate(args.save);
153
+ result = allBoards ? await kanbn.validateAllBoards(args.save) : await kanbn.validate(args.save);
42
154
  } catch (error) {
43
155
  utility.error(error);
44
156
  return;
@@ -54,6 +166,49 @@ module.exports = async args => {
54
166
  return;
55
167
  }
56
168
 
169
+ // Report multi-board problems. These are warnings rather than errors: each one describes a
170
+ // workspace that still works, just not the way its author probably meant it to. A workspace with
171
+ // one board has none of these problems to have, and its output is exactly what it always was
172
+ const multiBoard = (await kanbn.listBoards()).length > 1;
173
+ let boardWarnings = [];
174
+ if (multiBoard) {
175
+ try {
176
+ boardWarnings = await kanbn.findBoardWarnings();
177
+ } catch (error) {
178
+ utility.error(error);
179
+ return;
180
+ }
181
+ }
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
+
57
212
  // Backfill missing started and completed dates
58
213
  if (args.fix) {
59
214
  let fixed;
@@ -64,6 +219,12 @@ module.exports = async args => {
64
219
  return;
65
220
  }
66
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
+ }
67
228
  console.log('Everything OK, nothing to fix');
68
229
  return;
69
230
  }
@@ -86,8 +247,37 @@ module.exports = async args => {
86
247
  utility.error(error);
87
248
  return;
88
249
  }
250
+ if (args.json) {
251
+
252
+ // A multi-board workspace has warnings to report as well as drift, so JSON output carries both.
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));
258
+ return;
259
+ }
260
+ if (drift.length) {
261
+ showDrift(drift, true);
262
+ return;
263
+ }
264
+ console.log('Everything OK');
265
+ return;
266
+ }
89
267
  if (drift.length) {
90
- showDrift(drift, args.json);
268
+ showDrift(drift, false);
269
+ }
270
+ showColumnContentWarnings(columnContentWarnings);
271
+ showContributorWarnings(contributorWarnings);
272
+ showActionWarnings(actionWarnings);
273
+ showBoardWarnings(boardWarnings);
274
+ if (
275
+ drift.length ||
276
+ boardWarnings.length ||
277
+ columnContentWarnings.length ||
278
+ contributorWarnings.length ||
279
+ actionWarnings.length
280
+ ) {
91
281
  return;
92
282
  }
93
283
  console.log('Everything OK');
@@ -0,0 +1,9 @@
1
+ const gitUser = require('./git-user');
2
+
3
+ /**
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
+ * @return {string|null} The git username, or null if git isn't available or no username is set
8
+ */
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
+ };