@basementuniverse/kanbn 2.5.0 → 2.5.3
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/docs/api.md +227 -0
- package/docs/index-structure.md +2 -2
- package/docs/index.md +2 -0
- package/docs/sprints.md +175 -0
- package/docs/task-structure.md +3 -1
- package/index.js +2 -0
- package/package.json +3 -1
- package/src/main.js +1 -1
- package/src/parse-index.js +50 -5
- package/src/parse-markdown.js +117 -14
- package/src/parse-task.js +42 -14
package/docs/api.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Library API
|
|
2
|
+
|
|
3
|
+
This document is for programs that use `@basementuniverse/kanbn` as a dependency, rather than the `kanbn` CLI. For the file formats that the library
|
|
4
|
+
reads and writes, see the [board](index-structure.md) and [task](task-structure.md) structure docs.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @basementuniverse/kanbn
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Importing
|
|
13
|
+
|
|
14
|
+
The package's default export is the CLI entry point (an async function with no return value), used
|
|
15
|
+
by the `kanbn` executable. Library consumers should use the named `Kanbn` export instead:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const { Kanbn } = require('@basementuniverse/kanbn');
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { Kanbn } from '@basementuniverse/kanbn';
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Every method on `Kanbn` is documented with its parameter and return types in the package's shipped
|
|
26
|
+
type declarations (`src/main.d.ts`, referenced by the `types` field in `package.json`), so
|
|
27
|
+
TypeScript consumers get full IntelliSense without any extra shim.
|
|
28
|
+
|
|
29
|
+
## Creating an instance
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
const kanbn = new Kanbn();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- The constructor takes an optional `root` (the directory to treat as the current working
|
|
36
|
+
directory, defaulting to `process.cwd()`) and an `options` object: `{ board?: string, caches?:
|
|
37
|
+
any, actions?: boolean }`.
|
|
38
|
+
- `options.board` scopes the instance to a secondary board by slug; omit it (or pass `"main"` /
|
|
39
|
+
`"default"`) to target the main board. Use `kanbn.board(slug)` to get a scoped copy of an
|
|
40
|
+
existing instance instead of constructing a new one — this shares config caching.
|
|
41
|
+
- `options.actions` defaults to `true`. Pass `false`, or call `kanbn.withoutActions()`, to get an
|
|
42
|
+
instance that never runs [action rules](actions.md) — useful for tooling that shouldn't trigger
|
|
43
|
+
side effects.
|
|
44
|
+
- A workspace must be initialised before most methods will work. Check with `initialised()` and
|
|
45
|
+
set one up with `initialise()`:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
const kanbn = new Kanbn();
|
|
49
|
+
if (!(await kanbn.initialised())) {
|
|
50
|
+
await kanbn.initialise({ name: 'My project' });
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Methods that operate on a workspace throw an `Error` (with a message such as `"Not initialised in
|
|
55
|
+
this folder"`) rather than returning an error value, so wrap calls in `try`/`catch` or let promise
|
|
56
|
+
rejections propagate.
|
|
57
|
+
|
|
58
|
+
## Method reference
|
|
59
|
+
|
|
60
|
+
Methods are grouped by what they operate on. See `src/main.d.ts` for full parameter and return
|
|
61
|
+
types, including the shapes of `task`, `index`, `board`, `contributor` and other objects used
|
|
62
|
+
below.
|
|
63
|
+
|
|
64
|
+
### Workspace lifecycle
|
|
65
|
+
|
|
66
|
+
| Method | Description |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `initialised()` | Whether the current working directory has been initialised |
|
|
69
|
+
| `workspaceInitialised()` | Whether the workspace has been initialised, regardless of which board this instance is scoped to |
|
|
70
|
+
| `initialise(options?)` | Initialise a kanbn board in the current working directory |
|
|
71
|
+
| `getMainFolder()` | The `.kanbn` folder path for the current working directory |
|
|
72
|
+
| `removeAll()` | Delete the entire `.kanbn` folder |
|
|
73
|
+
|
|
74
|
+
### Configuration
|
|
75
|
+
|
|
76
|
+
| Method | Description |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `configExists()` | Whether a separate config file exists |
|
|
79
|
+
| `getConfig()` | Configuration settings from the config file, or `null` if there isn't one |
|
|
80
|
+
| `saveConfig(config)` | Save configuration data to a separate config file |
|
|
81
|
+
| `clearConfigCache()` | Clear cached config so the next read hits disk again |
|
|
82
|
+
| `getWorkspaceOptions()` | Workspace-scoped options, from the config file or the main board's front matter |
|
|
83
|
+
| `loadWorkspaceOptions()` | Workspace options along with whether they came from a config file |
|
|
84
|
+
| `getFolderName()` / `getIndexFileName()` / `getTaskFolderName()` / `getArchiveFolderName()` | Configured folder and file names |
|
|
85
|
+
|
|
86
|
+
### Boards
|
|
87
|
+
|
|
88
|
+
A workspace can have one main board plus any number of secondary boards sharing the same pool of
|
|
89
|
+
task files — see [Multiple Boards](multiple-boards.md).
|
|
90
|
+
|
|
91
|
+
| Method | Description |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `board(slug?)` | Get a copy of this instance scoped to another board (alias: `withBoard(slug?)`) |
|
|
94
|
+
| `listBoards()` | Find all boards in the workspace |
|
|
95
|
+
| `getBoardsSummary()` | List boards with column/task counts, completion percentage and last modified date |
|
|
96
|
+
| `boardExists(slug)` | Whether a board exists |
|
|
97
|
+
| `createBoard(slug, options?)` | Create a new secondary board |
|
|
98
|
+
| `initialiseBoard(slug, options?)` | Create a secondary board, or update an existing one |
|
|
99
|
+
| `deleteBoard(slug)` | Delete a board file; returns ids of tasks that are no longer on any board |
|
|
100
|
+
| `renameBoard(slug, newSlug, newName?)` | Rename a board |
|
|
101
|
+
| `findOrphanedTasks(slug)` | Tasks that would become untracked if a board were deleted |
|
|
102
|
+
| `getCrossBoardTasks(allTasks?)` | Every task that appears on more than one board, with the column it occupies on each |
|
|
103
|
+
| `findTaskBoards(taskId)` | Every board that references a task, and the column it occupies on each (alias: `getTaskBoardColumns(taskId)`) |
|
|
104
|
+
| `getIndex()` | The index (board) this instance is scoped to, as an object |
|
|
105
|
+
| `getBoardsConfig()` | The boards config from the config file: exclude list and display order |
|
|
106
|
+
|
|
107
|
+
### Tasks
|
|
108
|
+
|
|
109
|
+
| Method | Description |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `getTask(taskId)` | Get a task as an object |
|
|
112
|
+
| `createTask(taskData, columnName)` | Create a task file and add it to the index |
|
|
113
|
+
| `updateTask(taskId, taskData, columnName?)` | Update an existing task, optionally moving it |
|
|
114
|
+
| `renameTask(taskId, newTaskName)` | Rename a task (changes its id and file name) |
|
|
115
|
+
| `moveTask(taskId, columnName, position?, relative?, add?)` | Move a task between columns |
|
|
116
|
+
| `deleteTask(taskId, removeFile?, allBoards?)` | Remove a task from the index and optionally delete its file |
|
|
117
|
+
| `taskExists(taskId)` | Throws unless the task file exists and is indexed |
|
|
118
|
+
| `taskFileExists(taskId)` | Whether a task file exists, regardless of whether any board references it |
|
|
119
|
+
| `findTaskColumn(taskId)` | The column a task is in, or throws if it doesn't exist / isn't indexed |
|
|
120
|
+
| `findTrackedTasks(columnName?)` | Ids of tasks listed in the index, optionally filtered by column |
|
|
121
|
+
| `findUntrackedTasks()` | Ids of markdown files in the tasks folder that aren't listed in the index |
|
|
122
|
+
| `addUntrackedTaskToIndex(taskId, columnName)` | Add an untracked task to a column in the index |
|
|
123
|
+
| `addTaskToBoard(taskId, columnName)` | Add an existing (already-tracked-elsewhere) task to this board |
|
|
124
|
+
| `search(filters?, quiet?)` | Search for indexed tasks matching filters — see [Filtering and Sorting](filtering-and-sorting.md) |
|
|
125
|
+
| `sort(columnName, sorters, save?)` | Sort a column using the shared sorter model |
|
|
126
|
+
| `comment(taskId, text, author?)` | Add a comment to a task |
|
|
127
|
+
|
|
128
|
+
### Simple tasks
|
|
129
|
+
|
|
130
|
+
Simple tasks are plain lines in a column that aren't task links; see [Index Structure](index-structure.md).
|
|
131
|
+
|
|
132
|
+
| Method | Description |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `findSimpleTasks(input?, index?)` | Simple tasks matching a title, or all of them if no title given |
|
|
135
|
+
| `getSimpleTask(input, index?)` | Resolve a title to exactly one simple task, or throw |
|
|
136
|
+
| `moveSimpleTask(input, columnName, position?)` | Move a simple task to another column on this board |
|
|
137
|
+
| `moveSimpleTaskToBoard(input, targetSlug, columnName?, position?)` | Move a simple task onto another board |
|
|
138
|
+
| `deleteSimpleTask(input)` | Remove a simple task |
|
|
139
|
+
| `promoteSimpleTask(input, columnName?)` | Turn a simple task into a real task file |
|
|
140
|
+
|
|
141
|
+
### Archive
|
|
142
|
+
|
|
143
|
+
| Method | Description |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `listArchivedTasks()` | List archived task ids |
|
|
146
|
+
| `archiveTask(taskId)` | Move a task to the archive |
|
|
147
|
+
| `restoreTask(taskId, columnName?, singleBoard?)` | Restore a task from the archive |
|
|
148
|
+
| `loadArchivedTask(taskId)` | Load an archived task file as an object |
|
|
149
|
+
|
|
150
|
+
### Contributors
|
|
151
|
+
|
|
152
|
+
See [Contributors](contributors.md).
|
|
153
|
+
|
|
154
|
+
| Method | Description |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `getContributors()` | The workspace's contributors, normalised to object form |
|
|
157
|
+
| `findContributor(value)` | Find the contributor a name/alias/display-name value refers to |
|
|
158
|
+
| `currentUser()` | Work out who the current user is (`KANBN_USER`, then git email/name, then git username, then `null`) |
|
|
159
|
+
| `getContributorUsage()` | How contributors are used across tasks, and which names in use aren't known contributors |
|
|
160
|
+
| `findContributorWarnings()` | Tasks whose assigned user or comment author isn't a known contributor |
|
|
161
|
+
|
|
162
|
+
### Actions
|
|
163
|
+
|
|
164
|
+
See [Actions](actions.md).
|
|
165
|
+
|
|
166
|
+
| Method | Description |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| `withoutActions()` | Get a copy of this instance that runs no action rules |
|
|
169
|
+
| `actionsAllowed()` | Whether actions should run at all for this instance |
|
|
170
|
+
| `getActionRules(index?)` | The action rules that apply to this board |
|
|
171
|
+
| `findActionWarnings()` | Action rules that are legal but probably not what the author meant |
|
|
172
|
+
| `lastActionWarnings` | Property: rules that were skipped during the last operation |
|
|
173
|
+
|
|
174
|
+
### Sprints, status and charts
|
|
175
|
+
|
|
176
|
+
See [Sprints](sprints.md).
|
|
177
|
+
|
|
178
|
+
| Method | Description |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| `sprint(name, description, start)` | Start a new sprint |
|
|
181
|
+
| `status(quiet?, untracked?, due?, sprint?, dates?)` | Project status information |
|
|
182
|
+
| `burndown(sprints?, dates?, assigned?, columns?, normalise?)` | Burndown chart data |
|
|
183
|
+
|
|
184
|
+
### Validation
|
|
185
|
+
|
|
186
|
+
| Method | Description |
|
|
187
|
+
| --- | --- |
|
|
188
|
+
| `validate(save?)` | Validate the index and task files; `true` on success, otherwise an array of errors |
|
|
189
|
+
| `findMissingTaskFiles(index?)` | Tasks referenced by this board that have no task file |
|
|
190
|
+
| `findColumnContentWarnings(index?)` | Lines in this board's columns that aren't task links |
|
|
191
|
+
| `findWorkspaceUntrackedTasks()` | Tasks that no board references at all |
|
|
192
|
+
| `findTasksOnOtherBoards()` | Tasks that other boards track but this one doesn't |
|
|
193
|
+
|
|
194
|
+
## Error handling
|
|
195
|
+
|
|
196
|
+
Kanbn methods reject/throw plain `Error` objects with human-readable messages (for example `"Task
|
|
197
|
+
already exists"`, `"Column does not exist"`). There are no custom error classes or machine-readable
|
|
198
|
+
error codes to switch on — match on `error.message` if you need to distinguish specific failures.
|
|
199
|
+
|
|
200
|
+
## A worked example
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
const { Kanbn } = require('@basementuniverse/kanbn');
|
|
204
|
+
|
|
205
|
+
async function main() {
|
|
206
|
+
const kanbn = new Kanbn('/path/to/project');
|
|
207
|
+
|
|
208
|
+
if (!(await kanbn.initialised())) {
|
|
209
|
+
throw new Error('Not a kanbn project');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const taskId = await kanbn.createTask(
|
|
213
|
+
{ name: 'Write documentation', description: 'Document the library API' },
|
|
214
|
+
'Todo'
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await kanbn.moveTask(taskId, 'In Progress');
|
|
218
|
+
|
|
219
|
+
const status = await kanbn.status(false, true);
|
|
220
|
+
console.log(status);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
main().catch(err => {
|
|
224
|
+
console.error(err.message);
|
|
225
|
+
process.exitCode = 1;
|
|
226
|
+
});
|
|
227
|
+
```
|
package/docs/index-structure.md
CHANGED
|
@@ -25,7 +25,7 @@ The index file can optionally begin with YAML front-matter containing project op
|
|
|
25
25
|
|
|
26
26
|
There should be a single level-1 heading at the top of the markdown body containing the project name.
|
|
27
27
|
|
|
28
|
-
The project description should appear below the title. The description can be of any length and can contain markdown, however it must not contain any headings.
|
|
28
|
+
The project description should appear below the title. The description can be of any length and can contain markdown, however it must not contain any level-2 headings. Deeper headings (level 3 and below) are fine, as are level-2 headings inside a fenced code block.
|
|
29
29
|
|
|
30
30
|
Below the description there should be one or more level-2 headings. The 'Options' name is reserved for project options.
|
|
31
31
|
|
|
@@ -223,7 +223,7 @@ Workspace-scoped. See [multiple boards](multiple-boards.md).
|
|
|
223
223
|
|
|
224
224
|
### `sprints`
|
|
225
225
|
|
|
226
|
-
A list of sprints. Each sprint will have `start`, `name` and `description` properties.
|
|
226
|
+
A list of sprints. Each sprint will have `start`, `name` and `description` properties. See [sprints](sprints.md).
|
|
227
227
|
|
|
228
228
|
A board can declare its own `sprints` in its front matter, which **replaces** the workspace list for that board entirely. See [multiple boards](multiple-boards.md#per-board-sprints).
|
|
229
229
|
|
package/docs/index.md
CHANGED
|
@@ -7,12 +7,14 @@ To get started quickly, check out the [Quick Start](quick-start.md) guide.
|
|
|
7
7
|
## Contents
|
|
8
8
|
|
|
9
9
|
- [Quick Start](quick-start.md)
|
|
10
|
+
- [Library API](api.md) — using `@basementuniverse/kanbn` as a dependency instead of the CLI
|
|
10
11
|
- [Index Structure](index-structure.md) — the board file and all project options
|
|
11
12
|
- [Task Structure](task-structure.md) — task files, metadata, sub-tasks, relations, comments and history
|
|
12
13
|
- [Multiple Boards](multiple-boards.md) — several boards over one shared pool of tasks
|
|
13
14
|
- [Contributors](contributors.md) — an optional list of who works on a workspace, and who "you" are
|
|
14
15
|
- [Actions](actions.md) — declarative rules that fire when a task is created, moved, updated or finished
|
|
15
16
|
- [Views](views.md) — custom board layouts, columns and lanes
|
|
17
|
+
- [Sprints](sprints.md) — named time windows that `status`, `burndown` and `history` report through
|
|
16
18
|
- [Filtering and Sorting](filtering-and-sorting.md) — the filter and sorter model shared by `find`, `sort` and views
|
|
17
19
|
- [Advanced Configuration](advanced-configuration.md)
|
|
18
20
|
- [Migrating to 2.0.0](migration-2.0.md) — separate config files and custom folder locations
|
package/docs/sprints.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Sprints
|
|
2
|
+
|
|
3
|
+
A sprint in Kanbn is nothing more than **a named point in time**. Each sprint has a start date, and
|
|
4
|
+
runs until the next sprint starts; the last sprint in the list is the current one, and runs up to
|
|
5
|
+
now. Nothing stores an end date or a duration, and there is no way for a sprint to be "closed" -
|
|
6
|
+
starting the next sprint is what ends the previous one, and any end date you see in reporting output
|
|
7
|
+
is derived from the next sprint's start.
|
|
8
|
+
|
|
9
|
+
Tasks are never assigned to a sprint. A sprint is a window that reporting commands measure through,
|
|
10
|
+
so which sprint a task belongs to is worked out from its dates (`created`, `started`, `completed`,
|
|
11
|
+
`due`) rather than being recorded anywhere on the task. This means sprints cost nothing to add
|
|
12
|
+
retrospectively, and moving a boundary re-slices history rather than invalidating it.
|
|
13
|
+
|
|
14
|
+
Sprints live in the [`sprints`](index-structure.md#sprints) index option:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
sprints:
|
|
18
|
+
-
|
|
19
|
+
start: 2026-07-01T09:00:00.000Z
|
|
20
|
+
name: 'Foundation Sprint'
|
|
21
|
+
description: 'Baseline product and infrastructure work.'
|
|
22
|
+
-
|
|
23
|
+
start: 2026-07-08T09:00:00.000Z
|
|
24
|
+
name: 'Workflow Sprint'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`start` and `name` are required, `description` is optional.
|
|
28
|
+
|
|
29
|
+
## Starting a sprint
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
kanbn sprint --name "Workflow Sprint" --description "Core team workflow and onboarding features."
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`kanbn sp` is the short alias. The sprint always starts *now* - there is no option to backdate one
|
|
36
|
+
from the CLI, so a sprint that should have started last Monday is a front matter edit.
|
|
37
|
+
|
|
38
|
+
| Option | Effect |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `--name`, `-n` | Sprint name. If omitted, auto-named `Sprint {n}` from the number of existing sprints |
|
|
41
|
+
| `--description`, `-d` | Optional description, shown in `kanbn status` output |
|
|
42
|
+
| `--interactive`, `-i` | Prompt for name and (optionally) description |
|
|
43
|
+
| `--board`, `-b` | Run from the context of another board (see [below](#sprints-and-multiple-boards)) |
|
|
44
|
+
|
|
45
|
+
There is no command to rename, re-date or delete a sprint. All three are edits to the `sprints`
|
|
46
|
+
list in the board's front matter, which is deliberate: the sprint list is a small hand-maintainable
|
|
47
|
+
piece of history, and `kanbn sprint` only ever appends to it.
|
|
48
|
+
|
|
49
|
+
## Reading a sprint
|
|
50
|
+
|
|
51
|
+
Three commands take a `--sprint N|"name"` option (`-p` in every case). A sprint can be selected by
|
|
52
|
+
1-based number or by exact name; an unknown number or name is an error, not an empty result.
|
|
53
|
+
|
|
54
|
+
### `kanbn status --sprint`
|
|
55
|
+
|
|
56
|
+
Adds a `sprint` section to the status output for the selected sprint, defaulting to the current one:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
kanbn status --sprint 2
|
|
60
|
+
kanbn status --sprint "Workflow Sprint"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
sprint:
|
|
65
|
+
number: 2
|
|
66
|
+
name: 'Workflow Sprint'
|
|
67
|
+
start: 2026-07-08T09:00:00.000Z
|
|
68
|
+
end: 2026-07-15T09:00:00.000Z
|
|
69
|
+
current: 3
|
|
70
|
+
description: 'Core team workflow and onboarding features.'
|
|
71
|
+
durationDelta: 604800000
|
|
72
|
+
durationMessage: '1 week'
|
|
73
|
+
created: # tasks created during the sprint, with their total workload
|
|
74
|
+
started: # tasks started during the sprint
|
|
75
|
+
completed: # tasks completed during the sprint
|
|
76
|
+
due: # tasks due during the sprint
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Each of those four groups lists the matching task ids with their column and workload, plus a summed
|
|
80
|
+
`workload`. Any [custom date fields](index-structure.md#customfields) you have declared get a group
|
|
81
|
+
of their own too.
|
|
82
|
+
|
|
83
|
+
`current` and `end` only appear when you're looking at a sprint other than the current one: `current`
|
|
84
|
+
is the number of the sprint that is running now, and `end` is the moment the sprint you asked about
|
|
85
|
+
stopped, which is the next sprint's `start`. The current sprint has no `end`, because it hasn't
|
|
86
|
+
ended - its `durationDelta` and `durationMessage` are measured up to now, and will be larger every
|
|
87
|
+
time you run the command until the next sprint starts.
|
|
88
|
+
|
|
89
|
+
### `kanbn burndown --sprint`
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
kanbn burndown --sprint "Workflow Sprint"
|
|
93
|
+
kanbn burndown --sprint 1 --sprint 2
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Plots remaining workload across the sprint window. `--sprint` can be repeated to draw a chart per
|
|
97
|
+
sprint. With no `--sprint` or `--date` at all, burndown uses the current sprint - and if no sprints
|
|
98
|
+
are defined, it falls back to all time, from the earliest task date to now.
|
|
99
|
+
|
|
100
|
+
### `kanbn history --sprint`
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
kanbn history --sprint 2
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Filters the event listing down to events that fall inside the sprint window. Repeatable, and
|
|
107
|
+
combinable with `--assigned`, `--task` and `--date`.
|
|
108
|
+
|
|
109
|
+
Gantt charts don't use sprints at all.
|
|
110
|
+
|
|
111
|
+
## Sprints and multiple boards
|
|
112
|
+
|
|
113
|
+
Sprints are workspace-level by default: every board reads the workspace list, so one sprint cadence
|
|
114
|
+
covers the whole workspace and `kanbn sprint -b design` appends to that shared list (and says so in
|
|
115
|
+
verbose mode).
|
|
116
|
+
|
|
117
|
+
A board that declares its own `sprints` in its front matter **replaces** the workspace list for
|
|
118
|
+
itself entirely - it can no longer see workspace sprints, so `kanbn status -b design -p "Foundation
|
|
119
|
+
Sprint"` will fail to find one that only exists at workspace level. Sprints started on such a board
|
|
120
|
+
are auto-named `{Board name} Sprint {n}` so two boards' sprints can't be confused. Forking is never
|
|
121
|
+
implicit; it stays a deliberate front matter edit. See
|
|
122
|
+
[multiple boards](multiple-boards.md#per-board-sprints).
|
|
123
|
+
|
|
124
|
+
`--sprint` can't be combined with `--all-boards`, because sprint numbers and names are relative to
|
|
125
|
+
one board's list.
|
|
126
|
+
|
|
127
|
+
## Keeping the list sane
|
|
128
|
+
|
|
129
|
+
The "current sprint is the last one" and "a sprint runs until the next one starts" rules both assume
|
|
130
|
+
the list is in chronological order. `kanbn validate` emits a `sprints-out-of-order` warning if it
|
|
131
|
+
isn't - worth checking after hand-editing dates.
|
|
132
|
+
|
|
133
|
+
## Example workflow
|
|
134
|
+
|
|
135
|
+
A team working in weekly sprints, reviewing at the end of each one.
|
|
136
|
+
|
|
137
|
+
Start the first sprint on the Monday:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
kanbn sprint -n "Foundation Sprint" -d "Baseline product and infrastructure work."
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Work the week normally - `kanbn add`, `kanbn move`, `kanbn comment`. Nothing needs to reference the
|
|
144
|
+
sprint; the dates Kanbn stamps on tasks as they're created, started and completed are what the
|
|
145
|
+
sprint reporting reads.
|
|
146
|
+
|
|
147
|
+
Check progress mid-week:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
kanbn status --due
|
|
151
|
+
kanbn burndown --normalise auto
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
With no `--sprint`, both default to the current sprint, so this is the "how is this week going"
|
|
155
|
+
view.
|
|
156
|
+
|
|
157
|
+
On the following Monday, review what the week actually contained, then start the next sprint:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
kanbn status --sprint "Foundation Sprint"
|
|
161
|
+
kanbn history --sprint "Foundation Sprint"
|
|
162
|
+
kanbn sprint -n "Workflow Sprint" -d "Core team workflow and onboarding features."
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Starting the second sprint closes the first one at that moment, and the reports above stay valid
|
|
166
|
+
for it forever - `kanbn status -p 1` and `kanbn burndown -p 1` will show the same window next month.
|
|
167
|
+
|
|
168
|
+
Comparing two sprints later on:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
kanbn burndown -p "Foundation Sprint" -p "Workflow Sprint"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The [`example/basic`](../example/basic) workspace ships with three sprints defined, so every command
|
|
175
|
+
on this page can be run there as-is.
|
package/docs/task-structure.md
CHANGED
|
@@ -64,7 +64,9 @@ There should be a single level-1 heading at the top of the markdown body contain
|
|
|
64
64
|
|
|
65
65
|
The task description should appear below the title. The description can be of any length and can contain markdown.
|
|
66
66
|
|
|
67
|
-
The following level-2 headings are reserved for special purposes (`## Metadata`, `## Sub-tasks`, `## Relations`, `## Comments` and `## History`). Any other level-2 heading is treated as part of the description.
|
|
67
|
+
The following level-2 headings are reserved for special purposes (`## Metadata`, `## Sub-tasks`, `## Relations`, `## Comments` and `## History`). Any other level-2 heading is treated as part of the description, as is a heading at any other level - a `### Sub-tasks` heading in a description is description content, not the task's sub-tasks.
|
|
68
|
+
|
|
69
|
+
Headings inside fenced code blocks are ignored, so a shell or Python comment like `# install deps` in a ```` ```bash ```` block stays where you wrote it.
|
|
68
70
|
|
|
69
71
|
## Metadata
|
|
70
72
|
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basementuniverse/kanbn",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.3",
|
|
4
4
|
"description": "A CLI Kanban application",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"types": "src/main.d.ts",
|
|
6
7
|
"scripts": {
|
|
7
8
|
"test": "KANBN_ENV=test qunit ./test/**/*.test.js",
|
|
8
9
|
"coverage": "KANBN_ENV=test c8 --reporter=text --reporter=html --include=src/** --include=index.js qunit ./test/**/*.test.js"
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"dotenv": "^8.2.0",
|
|
39
40
|
"front-matter": "^4.0.2",
|
|
40
41
|
"fuzzy": "^0.1.3",
|
|
42
|
+
"glob": "^7.2.3",
|
|
41
43
|
"glob-promise": "^3.4.0",
|
|
42
44
|
"humanize-duration": "^3.25.0",
|
|
43
45
|
"inquirer": "^7.3.3",
|
package/src/main.js
CHANGED
|
@@ -4426,7 +4426,7 @@ class Kanbn {
|
|
|
4426
4426
|
start: sprints[sprintIndex].start,
|
|
4427
4427
|
};
|
|
4428
4428
|
if (currentSprint - 1 !== sprintIndex) {
|
|
4429
|
-
if (sprintIndex
|
|
4429
|
+
if (sprintIndex !== sprints.length - 1) {
|
|
4430
4430
|
result.sprint.end = sprints[sprintIndex + 1].start;
|
|
4431
4431
|
}
|
|
4432
4432
|
result.sprint.current = currentSprint;
|
package/src/parse-index.js
CHANGED
|
@@ -231,6 +231,46 @@ function validateOptions(options) {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Check whether a parsed section is a level-2 heading
|
|
236
|
+
*
|
|
237
|
+
* The index format reserves level 2 for columns and for the Options section. A deeper heading is
|
|
238
|
+
* part of the project description: it used to be promoted into a column of its own, which put an
|
|
239
|
+
* empty phantom column on the board and rewrote the user's `###` as a `##`
|
|
240
|
+
* @param {object} section A section from parseMarkdown
|
|
241
|
+
* @return {boolean}
|
|
242
|
+
*/
|
|
243
|
+
function isLevel2(section) {
|
|
244
|
+
return /^## /.test(section.heading);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Compile the project name's section and any sub-headings below it into a project description
|
|
249
|
+
*
|
|
250
|
+
* The Options section and the columns have already been taken out of the index by this point, so
|
|
251
|
+
* everything left is description
|
|
252
|
+
* @param {object} index A parsed index
|
|
253
|
+
* @param {string} name The project name, i.e. the title of the level-1 heading at the top of the file
|
|
254
|
+
* @param {string[]} columnNames The headings that are columns
|
|
255
|
+
* @return {string}
|
|
256
|
+
*/
|
|
257
|
+
function compileDescription(index, name, columnNames) {
|
|
258
|
+
const description = [];
|
|
259
|
+
for (const heading in index) {
|
|
260
|
+
if (heading === 'raw' || columnNames.indexOf(heading) !== -1) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The project name's heading line is stored separately as the name, so it isn't repeated in the
|
|
265
|
+
// description. Every other heading keeps its line
|
|
266
|
+
description.push(
|
|
267
|
+
heading === name ? '' : index[heading].heading,
|
|
268
|
+
index[heading].content
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return description.join('\n\n').trim();
|
|
272
|
+
}
|
|
273
|
+
|
|
234
274
|
/**
|
|
235
275
|
* Get the task id from a column list item, or null if the item isn't a task link
|
|
236
276
|
*
|
|
@@ -416,12 +456,9 @@ module.exports = {
|
|
|
416
456
|
// Get name
|
|
417
457
|
name = indexHeadings[0];
|
|
418
458
|
|
|
419
|
-
// Get description
|
|
420
|
-
description = name in index ? index[name].content.trim() : '';
|
|
421
|
-
|
|
422
459
|
// Parse options
|
|
423
460
|
// Options will be serialized back to front-matter, this check remains here for backwards-compatibility
|
|
424
|
-
if ('Options' in index) {
|
|
461
|
+
if ('Options' in index && isLevel2(index['Options'])) {
|
|
425
462
|
|
|
426
463
|
// Get embedded options and make sure it's an object
|
|
427
464
|
const embeddedOptions = yaml.parse(index['Options'].content.trim().replace(/```(yaml|yml)?/g, ''));
|
|
@@ -431,11 +468,15 @@ module.exports = {
|
|
|
431
468
|
|
|
432
469
|
// Merge with front matter options
|
|
433
470
|
options = Object.assign(options, embeddedOptions);
|
|
471
|
+
delete index['Options'];
|
|
434
472
|
}
|
|
435
473
|
validateOptions(options);
|
|
436
474
|
|
|
437
475
|
// Parse columns
|
|
438
|
-
|
|
476
|
+
// Only level-2 headings are columns, as documented - anything deeper belongs to the description
|
|
477
|
+
const columnNames = Object.keys(index).filter(
|
|
478
|
+
column => ['raw', name].indexOf(column) === -1 && isLevel2(index[column])
|
|
479
|
+
);
|
|
439
480
|
for (const columnName of columnNames) {
|
|
440
481
|
let parsed = null;
|
|
441
482
|
try {
|
|
@@ -452,6 +493,10 @@ module.exports = {
|
|
|
452
493
|
columnContent[columnName] = parsed.content;
|
|
453
494
|
}
|
|
454
495
|
}
|
|
496
|
+
|
|
497
|
+
// Get description
|
|
498
|
+
// Everything that isn't the Options section or a column is description, sub-headings included
|
|
499
|
+
description = compileDescription(index, name, columnNames);
|
|
455
500
|
} catch (error) {
|
|
456
501
|
throw new Error(`Unable to parse index: ${error.message}`);
|
|
457
502
|
}
|
package/src/parse-markdown.js
CHANGED
|
@@ -1,3 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match an ATX heading line, e.g. `## Title`
|
|
3
|
+
*
|
|
4
|
+
* The heading must start at column 0. CommonMark also allows up to 3 leading spaces, but kanbn has
|
|
5
|
+
* always required none, and loosening it now would start hoisting indented lines out of task files
|
|
6
|
+
* that parse correctly today
|
|
7
|
+
*/
|
|
8
|
+
const HEADING_REGEX = /^(#{1,6}) (.+)$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Match the opening or closing line of a fenced code block
|
|
12
|
+
*
|
|
13
|
+
* Up to 3 leading spaces are allowed, as in CommonMark - at 4 spaces the line is part of an indented
|
|
14
|
+
* code block instead, which can't contain a heading anyway because a heading must start at column 0
|
|
15
|
+
*/
|
|
16
|
+
const FENCE_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Find the ATX headings in a markdown document, ignoring anything inside a fenced code block
|
|
20
|
+
*
|
|
21
|
+
* Tracking fences is the whole reason this isn't a single regex: `# install deps` inside a ```bash
|
|
22
|
+
* block is a shell comment, not a heading, and hoisting it out of the block destroys the user's code.
|
|
23
|
+
* The same goes for Python comments, Ruby comments, C preprocessor directives and so on
|
|
24
|
+
* @param {string} markdown
|
|
25
|
+
* @return {object[]} Each heading's line, title, start index and the index its content starts at
|
|
26
|
+
*/
|
|
27
|
+
function findHeadings(markdown) {
|
|
28
|
+
const headings = [];
|
|
29
|
+
|
|
30
|
+
// The fence currently open, as { char, length }, or null if we're not inside a code block
|
|
31
|
+
let fence = null;
|
|
32
|
+
let index = 0;
|
|
33
|
+
for (const rawLine of markdown.split('\n')) {
|
|
34
|
+
|
|
35
|
+
// Tolerate CRLF line endings: without this the trailing carriage return ends up inside the
|
|
36
|
+
// heading title, and from there inside task names and ids
|
|
37
|
+
const line = rawLine.replace(/\r$/, '');
|
|
38
|
+
const fenceMatch = line.match(FENCE_REGEX);
|
|
39
|
+
if (fence === null) {
|
|
40
|
+
if (fenceMatch && isFenceStart(fenceMatch)) {
|
|
41
|
+
fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
42
|
+
} else {
|
|
43
|
+
const headingMatch = line.match(HEADING_REGEX);
|
|
44
|
+
if (headingMatch) {
|
|
45
|
+
headings.push({
|
|
46
|
+
heading: headingMatch[0],
|
|
47
|
+
title: headingMatch[2],
|
|
48
|
+
index,
|
|
49
|
+
|
|
50
|
+
// Where this heading's content starts: the line after the heading line
|
|
51
|
+
contentIndex: index + rawLine.length + 1
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} else if (isFenceEnd(fenceMatch, fence)) {
|
|
56
|
+
fence = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// + 1 for the newline that split() removed
|
|
60
|
+
index += rawLine.length + 1;
|
|
61
|
+
}
|
|
62
|
+
return headings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if a fence line opens a code block
|
|
67
|
+
*
|
|
68
|
+
* A backtick fence's info string can't itself contain a backtick - ```` ```a`b ```` is a paragraph
|
|
69
|
+
* containing code spans, not a code block. Tilde fences have no such restriction
|
|
70
|
+
* @param {object} fenceMatch A FENCE_REGEX match
|
|
71
|
+
* @return {boolean}
|
|
72
|
+
*/
|
|
73
|
+
function isFenceStart(fenceMatch) {
|
|
74
|
+
return fenceMatch[1][0] !== '`' || fenceMatch[2].indexOf('`') === -1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if a line closes the currently open code block
|
|
79
|
+
*
|
|
80
|
+
* A closing fence has to use the same character as the fence that opened the block, be at least as
|
|
81
|
+
* long, and carry no info string - so a ``` line doesn't close a ~~~ block, and neither does ```js
|
|
82
|
+
* @param {?object} fenceMatch A FENCE_REGEX match, or null if the line isn't a fence
|
|
83
|
+
* @param {object} fence The currently open fence
|
|
84
|
+
* @return {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function isFenceEnd(fenceMatch, fence) {
|
|
87
|
+
return (
|
|
88
|
+
fenceMatch !== null &&
|
|
89
|
+
fenceMatch[1][0] === fence.char &&
|
|
90
|
+
fenceMatch[1].length >= fence.length &&
|
|
91
|
+
fenceMatch[2].trim() === ''
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
1
95
|
module.exports = function parseMarkdown(markdown) {
|
|
2
96
|
if (!markdown) {
|
|
3
97
|
throw new Error('data is null, undefined or empty');
|
|
@@ -9,30 +103,39 @@ module.exports = function parseMarkdown(markdown) {
|
|
|
9
103
|
if (markdown === '') {
|
|
10
104
|
throw new Error('data is an empty string');
|
|
11
105
|
}
|
|
12
|
-
const headings =
|
|
13
|
-
heading,
|
|
14
|
-
title,
|
|
15
|
-
index
|
|
16
|
-
}));
|
|
106
|
+
const headings = findHeadings(markdown);
|
|
17
107
|
if (headings.length > 0 && headings[0].index > 0) {
|
|
18
108
|
headings.unshift({
|
|
19
109
|
heading: '',
|
|
20
110
|
title: 'raw',
|
|
21
|
-
index: 0
|
|
111
|
+
index: 0,
|
|
112
|
+
contentIndex: 0
|
|
22
113
|
});
|
|
23
114
|
}
|
|
24
115
|
const parsed = {};
|
|
25
116
|
for (let i = 0; i < headings.length; i++) {
|
|
117
|
+
const content = markdown.slice(
|
|
118
|
+
headings[i].contentIndex,
|
|
119
|
+
i < headings.length - 1
|
|
120
|
+
? headings[i + 1].index
|
|
121
|
+
: undefined
|
|
122
|
+
).trim();
|
|
123
|
+
|
|
124
|
+
// A repeated heading title used to overwrite the earlier one, silently discarding everything
|
|
125
|
+
// written under it - two `## Todo` columns meant losing every task in the first one. Merging the
|
|
126
|
+
// sections keeps the content, and keeping the repeated heading line means the section still
|
|
127
|
+
// looks the way the user wrote it after a read/write cycle
|
|
128
|
+
if (headings[i].title in parsed) {
|
|
129
|
+
parsed[headings[i].title].content = [
|
|
130
|
+
parsed[headings[i].title].content,
|
|
131
|
+
headings[i].heading,
|
|
132
|
+
content
|
|
133
|
+
].filter(part => part !== '').join('\n\n');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
26
136
|
parsed[headings[i].title] = {
|
|
27
137
|
heading: headings[i].heading,
|
|
28
|
-
content
|
|
29
|
-
// The + 1 skips the newline that ends the heading line; the synthetic 'raw' entry for
|
|
30
|
-
// content before the first heading has no heading line, so it starts at index 0
|
|
31
|
-
headings[i].index + headings[i].heading.length + (headings[i].heading ? 1 : 0),
|
|
32
|
-
i < headings.length - 1
|
|
33
|
-
? headings[i + 1].index
|
|
34
|
-
: undefined
|
|
35
|
-
).trim()
|
|
138
|
+
content
|
|
36
139
|
};
|
|
37
140
|
}
|
|
38
141
|
return parsed;
|
package/src/parse-task.js
CHANGED
|
@@ -6,21 +6,44 @@ const chrono = require('chrono-node');
|
|
|
6
6
|
const validate = require('jsonschema').validate;
|
|
7
7
|
const parseMarkdown = require('./parse-markdown');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Get a reserved section from a parsed task, or null if the task doesn't have one
|
|
11
|
+
*
|
|
12
|
+
* A section only counts as reserved if it's a level-2 heading, which is what the task format
|
|
13
|
+
* documents and what json2md writes. A `# Comments` or `### Sub-tasks` heading written into a
|
|
14
|
+
* description is description content, and used to hijack the section it happened to share a name
|
|
15
|
+
* with - turning a paragraph about comments into the task's comments, or failing the whole parse
|
|
16
|
+
* @param {object} task A parsed task
|
|
17
|
+
* @param {string} title A reserved section title
|
|
18
|
+
* @return {?object} The section, or null if the task doesn't have it at level 2
|
|
19
|
+
*/
|
|
20
|
+
function getReservedSection(task, title) {
|
|
21
|
+
return title in task && /^## /.test(task[title].heading) ? task[title] : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
/**
|
|
10
25
|
* Compile separate headings together into a task description
|
|
26
|
+
*
|
|
27
|
+
* Reserved sections have already been removed from the parsed task by this point, so everything
|
|
28
|
+
* left is description
|
|
11
29
|
* @param {object} data
|
|
30
|
+
* @param {string} name The task name, i.e. the title of the level-1 heading at the top of the file
|
|
12
31
|
*/
|
|
13
|
-
function compileDescription(data) {
|
|
32
|
+
function compileDescription(data, name) {
|
|
14
33
|
const description = [];
|
|
15
34
|
if ('raw' in data) {
|
|
16
35
|
description.push(data.raw.content.replace(/[\r\n]{3}/g, '\n\n').trim());
|
|
17
36
|
}
|
|
18
37
|
for (let heading in data) {
|
|
19
|
-
if (
|
|
38
|
+
if (heading === 'raw') {
|
|
20
39
|
continue;
|
|
21
40
|
}
|
|
41
|
+
|
|
42
|
+
// The task name's heading line is stored separately as the task name, so it isn't repeated in
|
|
43
|
+
// the description. Every other heading keeps its line: this used to drop the line from *every*
|
|
44
|
+
// level-1 heading, silently deleting the text of any `# Heading` written into a description
|
|
22
45
|
description.push(
|
|
23
|
-
|
|
46
|
+
heading === name ? '' : data[heading].heading,
|
|
24
47
|
data[heading].content
|
|
25
48
|
);
|
|
26
49
|
}
|
|
@@ -359,10 +382,11 @@ module.exports = {
|
|
|
359
382
|
|
|
360
383
|
// Parse metadata
|
|
361
384
|
// Metadata will be serialized back to front-matter, this check remains here for backwards compatibility
|
|
362
|
-
|
|
385
|
+
const metadataSection = getReservedSection(task, 'Metadata');
|
|
386
|
+
if (metadataSection !== null) {
|
|
363
387
|
|
|
364
388
|
// Get embedded metadata and make sure it's an object
|
|
365
|
-
const embeddedMetadata = yaml.parse(
|
|
389
|
+
const embeddedMetadata = yaml.parse(metadataSection.content.trim().replace(/```(yaml|yml)?/g, ''));
|
|
366
390
|
if (typeof embeddedMetadata !== 'object') {
|
|
367
391
|
throw new Error('invalid metadata content');
|
|
368
392
|
}
|
|
@@ -434,9 +458,10 @@ module.exports = {
|
|
|
434
458
|
}
|
|
435
459
|
|
|
436
460
|
// Parse sub-tasks
|
|
437
|
-
|
|
461
|
+
const subTasksSection = getReservedSection(task, 'Sub-tasks');
|
|
462
|
+
if (subTasksSection !== null) {
|
|
438
463
|
try {
|
|
439
|
-
subTasks = marked.lexer(
|
|
464
|
+
subTasks = marked.lexer(subTasksSection.content)[0].items.map(item => ({
|
|
440
465
|
text: item.text.trim(),
|
|
441
466
|
completed: item.checked || false
|
|
442
467
|
}));
|
|
@@ -447,9 +472,10 @@ module.exports = {
|
|
|
447
472
|
}
|
|
448
473
|
|
|
449
474
|
// Parse relations
|
|
450
|
-
|
|
475
|
+
const relationsSection = getReservedSection(task, 'Relations');
|
|
476
|
+
if (relationsSection !== null) {
|
|
451
477
|
try {
|
|
452
|
-
relations = marked.lexer(
|
|
478
|
+
relations = marked.lexer(relationsSection.content)[0].items.map(item => {
|
|
453
479
|
const parts = item.tokens[0].tokens[0].text.split(' ');
|
|
454
480
|
return parts.length === 1
|
|
455
481
|
? {
|
|
@@ -468,7 +494,8 @@ module.exports = {
|
|
|
468
494
|
}
|
|
469
495
|
|
|
470
496
|
// Parse comments
|
|
471
|
-
|
|
497
|
+
const commentsSection = getReservedSection(task, 'Comments');
|
|
498
|
+
if (commentsSection !== null) {
|
|
472
499
|
try {
|
|
473
500
|
// const commentsHeading = '## Comments';
|
|
474
501
|
// const start = data.indexOf(commentsHeading) + commentsHeading.length;
|
|
@@ -479,7 +506,7 @@ module.exports = {
|
|
|
479
506
|
// end = data.length;
|
|
480
507
|
// }
|
|
481
508
|
// const parsedComments = marked.lexer(data.slice(start, end).trim())[0].items;
|
|
482
|
-
const parsedComments = marked.lexer(
|
|
509
|
+
const parsedComments = marked.lexer(commentsSection.content)[0].items;
|
|
483
510
|
for (let parsedComment of parsedComments) {
|
|
484
511
|
const comment = { text: [] };
|
|
485
512
|
const parts = parsedComment.text.split('\n');
|
|
@@ -506,9 +533,10 @@ module.exports = {
|
|
|
506
533
|
}
|
|
507
534
|
|
|
508
535
|
// Parse history
|
|
509
|
-
|
|
536
|
+
const historySection = getReservedSection(task, 'History');
|
|
537
|
+
if (historySection !== null) {
|
|
510
538
|
try {
|
|
511
|
-
const parsedHistory = marked.lexer(
|
|
539
|
+
const parsedHistory = marked.lexer(historySection.content)[0].items;
|
|
512
540
|
for (let parsedHistoryEvent of parsedHistory) {
|
|
513
541
|
const historyEvent = {};
|
|
514
542
|
const parts = parsedHistoryEvent.text.split('\n');
|
|
@@ -563,7 +591,7 @@ module.exports = {
|
|
|
563
591
|
|
|
564
592
|
// Assemble description
|
|
565
593
|
// const descriptionParts = [];
|
|
566
|
-
description = compileDescription(task);
|
|
594
|
+
description = compileDescription(task, name);
|
|
567
595
|
// description = descriptionParts.join('\n\n');
|
|
568
596
|
} catch (error) {
|
|
569
597
|
throw new Error(`Unable to parse task: ${error.message}`);
|