bacon-tracker 1.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +430 -0
- data/bin/tracker-dashboard +47 -0
- data/bin/tracker-init +267 -0
- data/lib/bacon_tracker/assets/app.js +1258 -0
- data/lib/bacon_tracker/assets/decisions.js +382 -0
- data/lib/bacon_tracker/assets/docs.js +456 -0
- data/lib/bacon_tracker/assets/logic.js +85 -0
- data/lib/bacon_tracker/assets/theme.js +33 -0
- data/lib/bacon_tracker/commands/tracker.md +269 -0
- data/lib/bacon_tracker/dashboard.rb +168 -0
- data/lib/bacon_tracker/launcher.rb +64 -0
- data/lib/bacon_tracker/server.rb +476 -0
- data/lib/bacon_tracker/tasks.rb +465 -0
- data/lib/bacon_tracker/version.rb +3 -0
- data/lib/bacon_tracker/views/_board_css.erb +122 -0
- data/lib/bacon_tracker/views/_detail_css.erb +109 -0
- data/lib/bacon_tracker/views/_header_css.erb +82 -0
- data/lib/bacon_tracker/views/_markdown_css.erb +81 -0
- data/lib/bacon_tracker/views/_root_css.erb +41 -0
- data/lib/bacon_tracker/views/_theme_boot.erb +1 -0
- data/lib/bacon_tracker/views/dashboard.erb +281 -0
- data/lib/bacon_tracker/views/decisions.erb +211 -0
- data/lib/bacon_tracker/views/docs.erb +234 -0
- data/lib/bacon_tracker/views/index.erb +1065 -0
- data/lib/bacon_tracker.rb +1993 -0
- metadata +213 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tracker
|
|
3
|
+
description: Manage bacon-tracker stories - create features/ bugs/ chores, move them through workflow, edit fields, delete, check the backlog, and view story content. Use when the user mentions stories, the backlog, creating features/ bugs/ chores, or workflow transitions like commit, start, done, next.
|
|
4
|
+
argument-hint: "[new feature|bug|chore <title> [field=value]] [commit|start|done|show <ID>] [edit <ID> <field>=<value>] [delete <ID>] [list [stage]] [next] [lint]"
|
|
5
|
+
user-invocable: true
|
|
6
|
+
allowed-tools:
|
|
7
|
+
- Read
|
|
8
|
+
- Write
|
|
9
|
+
- Edit
|
|
10
|
+
- Bash(find *)
|
|
11
|
+
- Bash(grep *)
|
|
12
|
+
- Bash(ls *)
|
|
13
|
+
- Bash(mv *)
|
|
14
|
+
- Bash(rm *)
|
|
15
|
+
- Bash(rake *)
|
|
16
|
+
- Bash(bundle exec rake *)
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# /tracker - Bacon Tracker
|
|
20
|
+
|
|
21
|
+
Manage stories for a project using bacon-tracker. Arguments: `$ARGUMENTS`
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Vocabulary - use these words
|
|
26
|
+
|
|
27
|
+
The work item is a **story** (a file). Never call it a card, ticket, issue, or task. A **card** is only the board's rendering of a story. A **column** is only the board's rendering of a stage. The board is the *tracker board*, not a kanban board.
|
|
28
|
+
|
|
29
|
+
- **type** - `feature`, `bug`, or `chore`. The directory is the plural.
|
|
30
|
+
- **stage** - the directory: `1_icebox`, `2_backlog`, `3_started`, `4_done`. Authoritative. `status:` in frontmatter merely mirrors it and can drift.
|
|
31
|
+
- **commit / start / done** - the workflow verbs. `commit` means icebox → backlog, *not* a git commit; say which when both are in play.
|
|
32
|
+
- **subtask** - a `- [ ]` line in a body. Not a child story; there is no hierarchy, no epics, no parents.
|
|
33
|
+
- **blocked_by / blocks**, **linked_to / linked_from** - you write the first of each pair; the second is derived and never typed.
|
|
34
|
+
- Stories are **moved**, never assigned or transitioned. Nothing is closed or resolved - it is **done**, and done is append-only.
|
|
35
|
+
|
|
36
|
+
There are no sprints, points, priorities, or epics. Position in `backlog.md` is the priority. Full glossary: `docs/vocabulary.md` in the bacon-tracker repo.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Step 1 - find the tracker root
|
|
41
|
+
|
|
42
|
+
**Dashboard setup.** If the current directory holds a `dashboard.md` (the tracker home - where `tracker-init` installs this command), each `## Heading` in it is a project with a `path:` (the project directory) and a `namespace:`. The stories are in `<path>/tracker`, unless the entry has a `tracker:` key, which is relative to `path:`. Relative paths are relative to `dashboard.md`'s directory. An older entry may point `path:` straight at a tracker directory (one holding `backlog.md` or `.next-id`); use it as is. Pick the project the user named (by title or namespace); if they didn't name one and there's more than one, ask.
|
|
43
|
+
|
|
44
|
+
**Single project.** Otherwise, search the current repo for a directory containing `backlog.md` and `.next-id`. Common locations, in order: `tracker/` (the convention), then `docs/tracker/` (an older layout, still supported). If not found, tell the user and stop.
|
|
45
|
+
|
|
46
|
+
Decisions, if the project has them, live in a **separate** tree: `docs/decisions/`, with one directory per status and its own `.next-id`. Never conflate the two - a decision is not a story, and `/tracker` does not manage them.
|
|
47
|
+
|
|
48
|
+
Once found, set `TRACKER_ROOT` to that path.
|
|
49
|
+
|
|
50
|
+
**Find the namespace** - from the `namespace:` in `dashboard.md`, or from existing files in `TRACKER_ROOT`:
|
|
51
|
+
1. Read the first entry in `backlog.md` - e.g. `- APP-003 implement user auth` → namespace is `APP`
|
|
52
|
+
2. Or find any story file - e.g. `find $TRACKER_ROOT -name '[A-Z]*-[0-9]*.md' -o -name '[A-Z]*-[0-9]*.feature' | head -1` → parse the prefix before the first `-[0-9]`
|
|
53
|
+
|
|
54
|
+
**Prefer the rake tasks.** If a `Rakefile` that loads bacon-tracker is available - next to `dashboard.md` in a dashboard setup, or in the project directory in a single-project setup - run the matching rake task instead of editing files by hand. It allocates IDs under a lock and applies exactly the gem's rules. Run it from the Rakefile's directory, with `NS=<namespace>` in a dashboard setup (a single-project Rakefile needs no `NS`), and always **quote a task that has brackets** - zsh treats `[ ]` as a glob:
|
|
55
|
+
|
|
56
|
+
| Subcommand | Rake task |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `new <type> <title> [field=value ...]` | `rake "story:<type>[<title>,field=value,...]"` |
|
|
59
|
+
| `commit` / `start` / `done <ID>` | `rake "story:commit[<ID>]"` (and `story:start`, `story:done`) |
|
|
60
|
+
| `edit <ID> field=value ...` | `rake "story:edit[<ID>,field=value,...]"` |
|
|
61
|
+
| `next` | `rake story:next` |
|
|
62
|
+
| `lint` | `rake story:lint` |
|
|
63
|
+
|
|
64
|
+
Rake splits bracket arguments on commas, so a title or value containing a comma can't go through rake - fall back to the file steps below for that one (or set the title afterwards). Prefix with `bundle exec` if the Rakefile's directory has a `Gemfile`. If rake fails, relay its message; don't retry by hand-editing unless the user asks. The file steps below are the fallback when no Rakefile is available, and the only way for `show`, `list`, `delete`, and multi-line body edits.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Step 2 - dispatch on arguments
|
|
69
|
+
|
|
70
|
+
Parse the first word of `$ARGUMENTS` as the subcommand. Match case-insensitively.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### No arguments - status
|
|
75
|
+
|
|
76
|
+
Show a compact overview by reading the filesystem directly:
|
|
77
|
+
- Count files matching `<NS>-*.{md,feature}` in each of `features/`, `bugs/`, `chores/` × `1_icebox/`, `2_backlog/`, `3_started/`, `4_done/`
|
|
78
|
+
- Read `backlog.md` for the ordered backlog IDs and titles
|
|
79
|
+
|
|
80
|
+
Output format:
|
|
81
|
+
```
|
|
82
|
+
<Project> (<NS>)
|
|
83
|
+
|
|
84
|
+
done 12
|
|
85
|
+
started 1
|
|
86
|
+
backlog 3
|
|
87
|
+
icebox 5
|
|
88
|
+
|
|
89
|
+
next: APP-003 - implement user auth
|
|
90
|
+
|
|
91
|
+
backlog:
|
|
92
|
+
APP-003 implement user auth
|
|
93
|
+
APP-007 add email notifications
|
|
94
|
+
APP-001 set up CI
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If backlog is empty, say so. If no stories exist at all, suggest `/tracker new feature My First Story`.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### `new <type> <title> [field=value ...]`
|
|
102
|
+
|
|
103
|
+
type must be `feature`, `bug`, or `chore`. The title is everything after the type, except for any trailing `field=value` tokens (`size=`, `assignee=`, `blocked_by=`, `linked_to=`) - those set fields at creation, the same fields as `edit`. The directory for a type is its PLURAL: feature → `features/`, bug → `bugs/`, chore → `chores/` - these are the only directories the tracker scans.
|
|
104
|
+
|
|
105
|
+
1. Determine N: read the integer from `$TRACKER_ROOT/.next-id`, then **floor it above the highest `<NS>-NNN` already on disk** (`N = max(read, highest + 1)`). A stale `.next-id` from a merge or hand edit must never reissue an existing ID. When `rake` is available, prefer `rake story:<type>['title',field=…]`, which allocates the ID atomically (with this floor, under a file lock).
|
|
106
|
+
2. Write `N+1` back to `.next-id`
|
|
107
|
+
3. Pad N to 3 digits: `NNN`
|
|
108
|
+
4. Slugify the title: lowercase it, turn each run of characters that aren't `a-z` or `0-9` into a single hyphen, and trim hyphens from both ends. If nothing is left, the slug is `untitled`
|
|
109
|
+
5. Extension: `.feature` for features, `.md` for bugs and chores
|
|
110
|
+
6. Create `$TRACKER_ROOT/<plural-type>/1_icebox/<NS>-<NNN>-<slug>.<ext>` (e.g. `features/1_icebox/...`). The body is the type's `_template.feature` / `_template.md` if one exists, with the title filled in (`Feature: <title>`, or `Title: <title>` on the first line for `.md`). Write frontmatter **in the file's format**: for `.md`, a YAML block `---` / `id: <NS>-<NNN>` / `type: <type>` / `status: icebox` / `---`; for `.feature`, comment-style header lines `# id: <NS>-<NNN>` / `# type: <type>` / `# status: icebox`. (A `.feature` file is parsed by its `# key:` lines **or** a leading YAML `---` block if one is present - but a file mixes only one style, so match the sibling files in the same tracker rather than introducing the other.)
|
|
111
|
+
7. If any `field=value` tokens were given, set them in the frontmatter (same rules as `edit`)
|
|
112
|
+
8. Confirm the new ID and file path
|
|
113
|
+
|
|
114
|
+
If type is missing or unrecognised, list valid types and stop. If title is missing, ask for one.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### `commit <ID>`
|
|
119
|
+
|
|
120
|
+
Move a story from icebox → backlog (committing to do it).
|
|
121
|
+
|
|
122
|
+
1. Find the file: `find $TRACKER_ROOT -path "*/1_icebox/${ID}-*"`
|
|
123
|
+
2. `mv` it to the corresponding `2_backlog/` directory
|
|
124
|
+
3. Update `status:` in the frontmatter to `backlog`
|
|
125
|
+
4. Append `- <ID> <title>` to `backlog.md`, where the title is the filename slug with hyphens as spaces (bottom = lowest priority, unless user specifies a position)
|
|
126
|
+
5. Confirm new stage
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
### `start <ID>`
|
|
131
|
+
|
|
132
|
+
Move a story from backlog → started (actively working on it).
|
|
133
|
+
|
|
134
|
+
1. Find the file: `find $TRACKER_ROOT -path "*/2_backlog/${ID}-*"`
|
|
135
|
+
2. `mv` it to the corresponding `3_started/` directory
|
|
136
|
+
3. Update `status:` in the frontmatter to `started`
|
|
137
|
+
4. Remove the ID line from `backlog.md` - it mirrors `2_backlog/` only (`rake story:lint` flags leftovers as phantom)
|
|
138
|
+
5. Confirm new stage
|
|
139
|
+
6. If > 2 stories are now in started across all types, flag it
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### `done <ID>`
|
|
144
|
+
|
|
145
|
+
Mark a story as done.
|
|
146
|
+
|
|
147
|
+
1. Find the file in any non-done stage (`1_icebox/`, `2_backlog/`, or `3_started/`) - `done` may be called from any stage, matching `rake story:done` / `Core#done` (which only refuse a story already in `4_done/`)
|
|
148
|
+
2. `mv` it to the corresponding `4_done/` directory
|
|
149
|
+
3. Update `status:` in the frontmatter to `done`
|
|
150
|
+
4. Remove the ID line from `backlog.md` if present
|
|
151
|
+
5. Confirm
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
### `edit <ID> <field>=<value> [...]`
|
|
156
|
+
|
|
157
|
+
Update story fields in place. Fields: `title`, `size` (S/M/L), `assignee` (initials), `blocked_by` (comma-separated IDs this story waits on), `linked_to` (comma-separated IDs of related stories - a symmetric "see also"), or free-form body edits described in plain language - including adding or ticking off [subtasks](#subtasks).
|
|
158
|
+
|
|
159
|
+
1. Find the file: `find $TRACKER_ROOT -name "${ID}-*"`
|
|
160
|
+
2. Frontmatter fields: update the existing line, or add it if missing - matching the file's format (comment-style for `.feature`, YAML for `.md`). An empty value (`size=`) removes the line.
|
|
161
|
+
3. `title`: re-slugify the new title, `mv` the file to `<ID>-<new-slug>.<ext>` in its current stage directory, and update the story's `backlog.md` line if it has one (keep the `- <ID> <title>` format)
|
|
162
|
+
4. Reject values containing newlines - the gem treats these as invalid everywhere
|
|
163
|
+
5. Confirm what changed
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### `delete <ID>`
|
|
168
|
+
|
|
169
|
+
Delete a story permanently.
|
|
170
|
+
|
|
171
|
+
1. Find the file: `find $TRACKER_ROOT -name "${ID}-*"`
|
|
172
|
+
2. If the story is in `4_done/`, refuse and stop: done is a permanent record, and every bacon-tracker interface refuses to delete it. A regression is a new bug, not a reopened or removed story.
|
|
173
|
+
3. Show its ID, title, and stage, and confirm with the user before deleting
|
|
174
|
+
4. `rm` the file
|
|
175
|
+
5. Remove the story's line from `backlog.md` if present
|
|
176
|
+
6. Confirm what was deleted
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
### `show <ID>`
|
|
181
|
+
|
|
182
|
+
Display a story's content:
|
|
183
|
+
|
|
184
|
+
1. `find $TRACKER_ROOT -name "${ID}-*"` to locate the file
|
|
185
|
+
2. Read it, strip the frontmatter block
|
|
186
|
+
3. Print: ID, type, stage, title (humanised from filename), any set metadata fields, then body
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
APP-003 - implement user auth
|
|
190
|
+
type: feature stage: backlog
|
|
191
|
+
size: M
|
|
192
|
+
assignee: AB
|
|
193
|
+
blocked by: APP-005
|
|
194
|
+
linked to: APP-012
|
|
195
|
+
|
|
196
|
+
Feature: Implement User Auth
|
|
197
|
+
...
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Omit `size`, `assignee`, `blocked by`, and `linked to` when not set. If the story has subtasks, show its `done/total` count under the metadata.
|
|
201
|
+
|
|
202
|
+
Those four fields live in the story's own frontmatter. The reverse ends - which stories *this* one blocks, and which link back - aren't stored anywhere; the board derives them across all files. To surface them here, `grep -rl "blocked_by:.*${ID}\|linked_to:.*${ID}" $TRACKER_ROOT` and list the matches as `blocks:` / `linked from:`.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
### Subtasks
|
|
207
|
+
|
|
208
|
+
A story tracks its own checklist through Markdown checkbox lines in the **body** - `- [ ]` (open) and `- [x]` (done). They are just body text, so there is no separate command: manage them through `edit`.
|
|
209
|
+
|
|
210
|
+
- **Add** subtasks: append `- [ ]` lines to the body (via `edit <ID>` with a plain-language body change).
|
|
211
|
+
- **Tick off / reopen** a subtask: flip its `- [ ]` ↔ `- [x]` in the body.
|
|
212
|
+
- **Count** for `show`/`status`: subtasks are checkbox lines in the body, *excluding* any inside fenced blocks (```` ``` ```` in Markdown, `"""` in Gherkin) - those are examples, not real subtasks. Report `done/total`.
|
|
213
|
+
|
|
214
|
+
The web UI renders these as clickable checkboxes with a `done/total` bar; from `/tracker` they are ordinary body edits.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
### `list [stage]`
|
|
219
|
+
|
|
220
|
+
List stories. Optional stage filter: `icebox`, `backlog`, `started`, or `done`.
|
|
221
|
+
|
|
222
|
+
Read filenames directly from `$TRACKER_ROOT/{features,bugs,chores}/<stage>/`. Print a compact table:
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
Started (1):
|
|
226
|
+
APP-003 feature M AB implement user auth
|
|
227
|
+
|
|
228
|
+
Backlog (3):
|
|
229
|
+
APP-007 bug crash on empty cart
|
|
230
|
+
APP-001 chore upgrade ruby
|
|
231
|
+
APP-009 feature S set up CI
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Show size and assignee when present in the filename or frontmatter. Default: all stages (done capped at 10 most recent).
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
### `next`
|
|
239
|
+
|
|
240
|
+
Show the top item from `backlog.md`: read the first ID line, then `show` that story.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
### `lint`
|
|
245
|
+
|
|
246
|
+
Check tracker consistency. If a Rakefile with story tasks is available, prefer running `rake story:lint` (with `NS=<namespace>` in a dashboard setup) and relay its output. Otherwise perform the same checks directly on the filesystem:
|
|
247
|
+
|
|
248
|
+
1. **Backlog drift** - every ID in `backlog.md` must have a file in a `2_backlog/` directory (phantom lines), and every file in `2_backlog/` must have a line in `backlog.md` (unlisted stories)
|
|
249
|
+
2. **Duplicate IDs** - no ID may have files in more than one stage directory
|
|
250
|
+
3. **Stale `.next-id`** - the counter must exceed the highest existing story number
|
|
251
|
+
4. **Status drift** - each story's `status:` frontmatter must match its stage directory (the directory is authoritative)
|
|
252
|
+
5. **Stale blocker** - a `blocked_by` naming a story that is now in `4_done`. The blocker shipped and the blocked story never got the memo; clear it from `blocked_by`
|
|
253
|
+
6. **Dangling reference** - a `blocked_by` or `linked_to` naming an ID with no story file
|
|
254
|
+
7. **Blocker cycle** - `A` waits on `B` waits on `A`, or a story naming itself. Nothing in the cycle can ever start
|
|
255
|
+
8. **Started while blocked** - a story in `3_started` with an unresolved blocker. Report it, but this is a flow signal, not corruption: `rake story:lint` deliberately does not fail on it
|
|
256
|
+
|
|
257
|
+
Checks 5–7 only apply to values shaped like a story ID for this namespace. `blocked_by` may legitimately name something outside the tracker (an upstream release, another team's ticket) - leave those alone. Stories already in `4_done` are never reported as the subject of checks 5–8: done is append-only, so their relationships are record, not a to-do.
|
|
258
|
+
|
|
259
|
+
Report each finding with the ID and file path, or confirm all checks pass. Don't fix anything without being asked; when asked, the directory is the source of truth.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Implementation notes
|
|
264
|
+
|
|
265
|
+
- Expand `~` in any paths using shell: `eval echo <path>` or `realpath`.
|
|
266
|
+
- When editing frontmatter, match the existing format (comment-style for `.feature`, YAML for `.md`).
|
|
267
|
+
- Slugify: `echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'` - and use `untitled` if the result is empty
|
|
268
|
+
- `backlog.md` line format: `- <NS>-NNN <title>` (leading dash, single space - exactly what the gem writes; the stats and next-task parsers depend on it). Preserve this when adding/removing entries.
|
|
269
|
+
- Keep output concise - one line per story, full content only for `show`.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module BaconTracker
|
|
2
|
+
class Dashboard
|
|
3
|
+
Project = Struct.new(:name, :slug, :path, :namespace, :tracker_root, :docs_root, :version_path,
|
|
4
|
+
keyword_init: true) do
|
|
5
|
+
# A surface exists if its directory does. Absent means "this project has
|
|
6
|
+
# none" - not an error (BT-ADR-0016).
|
|
7
|
+
def tracker? = Dir.exist?(tracker_root.to_s)
|
|
8
|
+
def docs? = Dir.exist?(docs_root.to_s)
|
|
9
|
+
|
|
10
|
+
# Derived, never configured: a repo that keeps its tracker inside its docs
|
|
11
|
+
# tree still works, the docs surface just skips it. Both paths are already
|
|
12
|
+
# known, so there is nothing to ask twice.
|
|
13
|
+
def tracker_inside_docs?
|
|
14
|
+
return false unless tracker_root && docs_root
|
|
15
|
+
|
|
16
|
+
tracker_root.start_with?(docs_root + File::SEPARATOR)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(path)
|
|
21
|
+
@path = path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Re-parses when dashboard.md's mtime changes, so edits apply without
|
|
25
|
+
# restarting the server.
|
|
26
|
+
def projects
|
|
27
|
+
mtime = begin
|
|
28
|
+
File.mtime(@path)
|
|
29
|
+
rescue Errno::ENOENT
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
if @projects.nil? || mtime != @parsed_mtime
|
|
33
|
+
@projects = parse
|
|
34
|
+
@parsed_mtime = mtime
|
|
35
|
+
end
|
|
36
|
+
@projects
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def project_core(slug)
|
|
40
|
+
proj = projects.find { |p| p.slug == slug }
|
|
41
|
+
proj && core_for(proj)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def project_stats
|
|
45
|
+
projects.map do |proj|
|
|
46
|
+
core = core_for(proj)
|
|
47
|
+
core.stats
|
|
48
|
+
.merge(core.docs_stats)
|
|
49
|
+
.merge(name: proj.name, slug: proj.slug, namespace: proj.namespace,
|
|
50
|
+
tracker: proj.tracker?, docs: proj.docs?,
|
|
51
|
+
# resolve_version alone - project_front would also render
|
|
52
|
+
# README and CHANGELOG on every stats poll (BT-179).
|
|
53
|
+
version: core.resolve_version(proj.path).first)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The one place a registry entry becomes a Configuration. The server and
|
|
58
|
+
# the Rake tasks both go through it, so they cannot disagree about which
|
|
59
|
+
# directory holds the stories (BT-179: the tasks used to take the project
|
|
60
|
+
# root for the tracker root and write stories beside tracker/ instead of
|
|
61
|
+
# inside it).
|
|
62
|
+
def config_for(proj)
|
|
63
|
+
config = Configuration.new
|
|
64
|
+
config.namespace = proj.namespace
|
|
65
|
+
config.tracker_root = proj.tracker_root
|
|
66
|
+
config.project_root = proj.path
|
|
67
|
+
config.version_path = proj.version_path
|
|
68
|
+
if proj.docs?
|
|
69
|
+
config.docs_root = proj.docs_root
|
|
70
|
+
config.decisions_root = File.join(proj.docs_root, 'decisions')
|
|
71
|
+
end
|
|
72
|
+
config
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def core_for(proj)
|
|
78
|
+
Core.new(config_for(proj))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse
|
|
82
|
+
return [] unless File.exist?(@path)
|
|
83
|
+
|
|
84
|
+
entries = []
|
|
85
|
+
current = nil
|
|
86
|
+
|
|
87
|
+
# Explicit UTF-8: the locale default (US-ASCII under LANG=C, the norm in
|
|
88
|
+
# containers and CI) raises on a non-ASCII project name (BT-179).
|
|
89
|
+
File.foreach(@path, encoding: 'utf-8') do |raw|
|
|
90
|
+
line = raw.scrub
|
|
91
|
+
if (m = line.match(/^## (.+)/))
|
|
92
|
+
entries << current if current
|
|
93
|
+
current = { name: m[1].strip, attrs: {} }
|
|
94
|
+
elsif current && (m = line.match(/^(\w+):\s*(.+)/))
|
|
95
|
+
current[:attrs][m[1]] = m[2].strip
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
entries << current if current
|
|
99
|
+
|
|
100
|
+
# build_project warns and returns nil for an unusable entry (no path, or a
|
|
101
|
+
# name that yields an empty namespace) instead of dropping it silently or
|
|
102
|
+
# building a broken project (BT-086).
|
|
103
|
+
results = entries.filter_map { |e| build_project(e) }
|
|
104
|
+
|
|
105
|
+
# Colliding slugs would route every request to the first match - suffix
|
|
106
|
+
# later duplicates so each project keeps a working board.
|
|
107
|
+
seen = Hash.new(0)
|
|
108
|
+
results.each do |p|
|
|
109
|
+
n = (seen[p.slug] += 1)
|
|
110
|
+
p.slug = "#{p.slug}-#{n}" if n > 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
results
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def tracker_dir?(dir)
|
|
117
|
+
File.exist?(File.join(dir, 'backlog.md')) || File.exist?(File.join(dir, '.next-id'))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# An absent default root is silent - the project simply has no such surface.
|
|
121
|
+
# An explicit key that resolves nowhere is a typo and warns, the same way a
|
|
122
|
+
# missing 'path:' does (BT-ADR-0016).
|
|
123
|
+
def resolve_root(item, key, default, path)
|
|
124
|
+
given = item[:attrs][key]
|
|
125
|
+
root = File.expand_path(given || default, path)
|
|
126
|
+
if given && !Dir.exist?(root)
|
|
127
|
+
warn "[BaconTracker] dashboard.md: #{item[:name].inspect} sets #{key}: #{given.inspect}, " \
|
|
128
|
+
"which does not exist (#{root})"
|
|
129
|
+
end
|
|
130
|
+
root
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_project(item)
|
|
134
|
+
unless item[:attrs]['path']
|
|
135
|
+
warn "[BaconTracker] dashboard.md: skipping project #{item[:name].inspect} - no 'path:'"
|
|
136
|
+
return nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
slug = BaconTracker.slugify(item[:name])
|
|
140
|
+
namespace = item[:attrs]['namespace'] || slug.upcase.gsub('-', '_')
|
|
141
|
+
if namespace.empty?
|
|
142
|
+
warn "[BaconTracker] dashboard.md: skipping project #{item[:name].inspect} - " \
|
|
143
|
+
"empty namespace (its name has no letters/digits; add an explicit 'namespace:')"
|
|
144
|
+
return nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Relative paths are relative to dashboard.md, not the process CWD.
|
|
148
|
+
path = File.expand_path(item[:attrs]['path'], File.dirname(@path))
|
|
149
|
+
|
|
150
|
+
# Compatibility (BT-134): a registry written before BT-ADR-0016 points
|
|
151
|
+
# 'path:' straight at the tracker directory. Detect that - a directory
|
|
152
|
+
# holding backlog.md or .next-id IS the tracker root - rather than
|
|
153
|
+
# silently showing an empty board, which is this codebase's worst failure
|
|
154
|
+
# mode. An explicit 'tracker:' always wins.
|
|
155
|
+
legacy = item[:attrs]['tracker'].nil? && tracker_dir?(path)
|
|
156
|
+
|
|
157
|
+
Project.new(
|
|
158
|
+
name: item[:name],
|
|
159
|
+
slug: slug,
|
|
160
|
+
path: legacy ? File.dirname(path) : path,
|
|
161
|
+
namespace: namespace,
|
|
162
|
+
version_path: item[:attrs]['version'],
|
|
163
|
+
tracker_root: legacy ? path : resolve_root(item, 'tracker', 'tracker', path),
|
|
164
|
+
docs_root: resolve_root(item, 'docs', 'docs', legacy ? File.dirname(path) : path)
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require 'rbconfig'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
|
|
4
|
+
module BaconTracker
|
|
5
|
+
# Opening a file in an editor, or revealing it in the file manager, is the
|
|
6
|
+
# one thing the board does outside its own files - and the only part that
|
|
7
|
+
# differs by platform. Everything else is plain Ruby file I/O.
|
|
8
|
+
#
|
|
9
|
+
# A missing launcher is reported, never swallowed: the endpoints answer 501
|
|
10
|
+
# with the reason, so the board can say so instead of claiming success.
|
|
11
|
+
module Launcher
|
|
12
|
+
class Unavailable < StandardError; end
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def host_os = RbConfig::CONFIG['host_os']
|
|
17
|
+
|
|
18
|
+
def windows? = host_os.match?(/mswin|mingw|cygwin/)
|
|
19
|
+
def macos? = host_os.include?('darwin')
|
|
20
|
+
|
|
21
|
+
# Show a file (or folder) in the file manager.
|
|
22
|
+
def reveal_argv(target)
|
|
23
|
+
if macos?
|
|
24
|
+
['open', '-R', target]
|
|
25
|
+
elsif windows?
|
|
26
|
+
['explorer.exe', "/select,#{target}"]
|
|
27
|
+
elsif which('xdg-open')
|
|
28
|
+
# xdg-open has no "select this file" - open the folder that holds it.
|
|
29
|
+
['xdg-open', File.directory?(target) ? target : File.dirname(target)]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Open a file in the user's editor. BACON_EDITOR wins and may carry
|
|
34
|
+
# arguments ("code -w"); otherwise the platform's default handler.
|
|
35
|
+
def open_argv(target)
|
|
36
|
+
editor = ENV['BACON_EDITOR'].to_s.strip
|
|
37
|
+
return Shellwords.split(editor) + [target] unless editor.empty?
|
|
38
|
+
|
|
39
|
+
if macos?
|
|
40
|
+
['open', target]
|
|
41
|
+
elsif windows?
|
|
42
|
+
['cmd.exe', '/c', 'start', '', target]
|
|
43
|
+
elsif which('xdg-open')
|
|
44
|
+
['xdg-open', target]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fire and forget: the board never waits on an editor. No shell is
|
|
49
|
+
# involved - argv goes straight to exec, so a path is never interpreted.
|
|
50
|
+
def run(argv)
|
|
51
|
+
raise Unavailable, 'no launcher found on this system - install xdg-open or set BACON_EDITOR' unless argv
|
|
52
|
+
|
|
53
|
+
pid = Process.spawn(*argv, out: File::NULL, err: File::NULL)
|
|
54
|
+
Process.detach(pid)
|
|
55
|
+
nil
|
|
56
|
+
rescue SystemCallError => e
|
|
57
|
+
raise Unavailable, "could not run #{argv.first}: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def which(cmd)
|
|
61
|
+
ENV['PATH'].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, cmd)) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|