bacon-tracker 1.0.0 → 1.1.1
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 +4 -4
- data/CHANGELOG.md +22 -0
- data/lib/bacon_tracker/commands/tracker.md +6 -6
- data/lib/bacon_tracker/dashboard.rb +6 -2
- data/lib/bacon_tracker/tasks.rb +27 -4
- data/lib/bacon_tracker/version.rb +1 -1
- data/lib/bacon_tracker.rb +194 -36
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42440d405b85d2a4c75b2d12dc9f76a5519e694ecd88e10b208d008bc8f8a6ad
|
|
4
|
+
data.tar.gz: 2a7a38d3156579ec88136713ecc60f8b1bf35a35d57f4e9141000be7a9ac2946
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c85377256c359817bf27bae870babd0f34fe724d5b1f117eb0a61c216ea50fc18dbb5bfbded6d105286f9d146ffe9c315e83b1d7b5bfd70e1ae5014e2c865f1d
|
|
7
|
+
data.tar.gz: d57fc7d4fb694b8729751b92d04d34921de3099ded4fcd2a52eadd2e665ceae33f66129663198279dd489b30c242f41f683f081ec5bea48e730d0f506eba5d82
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [1.1.1] - 2026-10-21
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- `rake story:commit` (and every other path that adds a line to `backlog.md`) writes the story's own title to the line, the same one the board shows, instead of the humanized filename slug. The slug is the fallback only when the file has neither a title line nor an opening heading. Retitling a committed story rewrites its line even when the filename does not change. `stats.next_task` follows.
|
|
11
|
+
- `rake story:lint` reports a `backlog.md` line whose title is not the story's (`title-drift`), on the line in `backlog.md`, and fails on it - the same comparison the shared CI lint makes, so the local check and CI agree on the same tree.
|
|
12
|
+
- A story created from a `_template.md` or `_template.feature` that starts with a header block of its own (`id`, `type`, `status` with a placeholder id) now has exactly one header and the title as typed. It used to carry both headers, the template's placeholder id and the template's placeholder title; the typed title reached only the filename. The title is filled into the template's first `Title:`/`Feature:` line wherever it sits, and a template without one gets the line added. `rake story:lint` points out such a template (`template-header`) as a notice, so the project can drop the block.
|
|
13
|
+
- `rake story:feature|bug|chore` with an unknown `size=` (say `XS`) is refused before a story is created, like the API and the board already were. It used to create the story, spend its number, and only then report "could not set fields", leaving an unsized story behind. One list of sizes (`S`, `M`, `L`) now backs creating, editing, showing and linting.
|
|
14
|
+
- `rake story:lint` reports a story whose file carries a size the tracker does not know (`unknown-size`), naming the story and its `size:` line. It is a notice, not a failure: the board shows such a story without a size, and old files are not made to be swept. With `LINT_FORMAT=github` it is a `::notice` annotation.
|
|
15
|
+
- A bug or chore with no `Title:` line that opens with a markdown heading now takes the heading as its title on the board, the dashboard and the API, the way decision records do. It used to fall back to the humanized filename slug, which loses dashes, punctuation, capitals and umlauts ("# Fix the Ä-key — today" showed as "fix the ae key today"). Retitling such a story rewrites the heading. The slug remains the fallback when there is neither.
|
|
16
|
+
|
|
17
|
+
## [1.1.0] - 2026-10-01
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Filename slugs spell out `ä`, `ö`, `ü`, `ß` as `ae`, `oe`, `ue`, `ss` and drop accents from other letters, so "Datenschutzerklärung" becomes `datenschutzerklaerung` instead of `datenschutzerkl-rung`. Existing files keep their names until the story is retitled.
|
|
22
|
+
- A title with nothing the slug rule keeps (all-CJK, all-punctuation) now slugs to `non-latin-REPLACE-ME` instead of `untitled`, for story files, decision records and dashboard project slugs alike. The dashboard used to get an empty project slug in that case.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Story titles on the board and in the API now come from the in-file `Title:`/`Feature:` line, so umlauts, dots and capitals survive. They were humanized from the filename slug, which keeps only ASCII letters and digits ("Datenschutzerklärung" showed as "datenschutzerkl rung", "bybacon.com" as "bybacon com"). The slug remains the fallback when the line is missing.
|
|
27
|
+
|
|
6
28
|
## [1.0.0] - 2026-09-25
|
|
7
29
|
|
|
8
30
|
First public release.
|
|
@@ -105,9 +105,9 @@ type must be `feature`, `bug`, or `chore`. The title is everything after the typ
|
|
|
105
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
106
|
2. Write `N+1` back to `.next-id`
|
|
107
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 `
|
|
108
|
+
4. Slugify the title: spell out `ä`/`ö`/`ü`/`ß` as `ae`/`oe`/`ue`/`ss`, drop accents from other letters (`é` → `e`), 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 `non-latin-REPLACE-ME`
|
|
109
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
|
|
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 into its first `Feature:` / `Title:` line (add the line at the top if the template has none). If the template itself starts with a header block (`---` … `---`, or `# id:` lines), leave that block out - the new story gets exactly one header, written next. 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
111
|
7. If any `field=value` tokens were given, set them in the frontmatter (same rules as `edit`)
|
|
112
112
|
8. Confirm the new ID and file path
|
|
113
113
|
|
|
@@ -122,7 +122,7 @@ Move a story from icebox → backlog (committing to do it).
|
|
|
122
122
|
1. Find the file: `find $TRACKER_ROOT -path "*/1_icebox/${ID}-*"`
|
|
123
123
|
2. `mv` it to the corresponding `2_backlog/` directory
|
|
124
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)
|
|
125
|
+
4. Append `- <ID> <title>` to `backlog.md`, where the title is the story's own: its `Title:`/`Feature:` line, or the opening `#` heading of a `.md` without one, or the filename slug with hyphens as spaces when there is neither (bottom = lowest priority, unless user specifies a position)
|
|
126
126
|
5. Confirm new stage
|
|
127
127
|
|
|
128
128
|
---
|
|
@@ -158,7 +158,7 @@ Update story fields in place. Fields: `title`, `size` (S/M/L), `assignee` (initi
|
|
|
158
158
|
|
|
159
159
|
1. Find the file: `find $TRACKER_ROOT -name "${ID}-*"`
|
|
160
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)
|
|
161
|
+
3. `title`: rewrite the `Title:`/`Feature:` line (or the opening `#` heading of a `.md` that has no `Title:` line), 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
162
|
4. Reject values containing newlines - the gem treats these as invalid everywhere
|
|
163
163
|
5. Confirm what changed
|
|
164
164
|
|
|
@@ -183,7 +183,7 @@ Display a story's content:
|
|
|
183
183
|
|
|
184
184
|
1. `find $TRACKER_ROOT -name "${ID}-*"` to locate the file
|
|
185
185
|
2. Read it, strip the frontmatter block
|
|
186
|
-
3. Print: ID, type, stage, title (humanised from filename), any set metadata fields, then body
|
|
186
|
+
3. Print: ID, type, stage, title (the `Title:`/`Feature:` line; for a `.md` without one, its opening `#` heading; humanised from the filename if there is neither), any set metadata fields, then body
|
|
187
187
|
|
|
188
188
|
```
|
|
189
189
|
APP-003 - implement user auth
|
|
@@ -264,6 +264,6 @@ Report each finding with the ID and file path, or confirm all checks pass. Don't
|
|
|
264
264
|
|
|
265
265
|
- Expand `~` in any paths using shell: `eval echo <path>` or `realpath`.
|
|
266
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 `
|
|
267
|
+
- Slugify: `echo "$TITLE" | sed 's/ä/ae/g; s/ö/oe/g; s/ü/ue/g; s/ß/ss/g; s/Ä/Ae/g; s/Ö/Oe/g; s/Ü/Ue/g' | perl -CS -MUnicode::Normalize -pe '$_ = NFKD($_); s/\p{Mn}//g' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'` - and use `non-latin-REPLACE-ME` if the result is empty
|
|
268
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
269
|
- Keep output concise - one line per story, full content only for `show`.
|
|
@@ -136,11 +136,15 @@ module BaconTracker
|
|
|
136
136
|
return nil
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
+
# A name with nothing the slug rule keeps gets the EMPTY_SLUG placeholder
|
|
140
|
+
# as its route (never an empty "/projects/"), but a namespace must not be
|
|
141
|
+
# guessed from it: stories are matched by "<NS>-NNN", so an explicit
|
|
142
|
+
# 'namespace:' is required.
|
|
139
143
|
slug = BaconTracker.slugify(item[:name])
|
|
140
|
-
namespace = item[:attrs]['namespace'] || slug.upcase.gsub('-', '_')
|
|
144
|
+
namespace = item[:attrs]['namespace'] || (slug == BaconTracker::EMPTY_SLUG ? '' : slug.upcase.gsub('-', '_'))
|
|
141
145
|
if namespace.empty?
|
|
142
146
|
warn "[BaconTracker] dashboard.md: skipping project #{item[:name].inspect} - " \
|
|
143
|
-
"
|
|
147
|
+
"no usable namespace (its name has no letters/digits; add an explicit 'namespace:')"
|
|
144
148
|
return nil
|
|
145
149
|
end
|
|
146
150
|
|
data/lib/bacon_tracker/tasks.rb
CHANGED
|
@@ -288,12 +288,13 @@ module BaconTracker
|
|
|
288
288
|
puts lines.first.strip
|
|
289
289
|
end
|
|
290
290
|
|
|
291
|
-
desc 'Check backlog.md against 2_backlog story files for drift, flag duplicate IDs across stages, check blocked_by/linked_to, and
|
|
291
|
+
desc 'Check backlog.md against 2_backlog story files for drift (membership and titles), flag duplicate IDs across stages, check blocked_by/linked_to, sizes, templates and .next-id'
|
|
292
292
|
task :lint do
|
|
293
293
|
listed = core.backlog_ids
|
|
294
294
|
on_disk = core.backlog_story_files.filter_map { |f| File.basename(f)[core.filename_id_pattern] }
|
|
295
295
|
phantom = listed - on_disk
|
|
296
296
|
unlisted = on_disk - listed
|
|
297
|
+
titles = core.backlog_title_drift
|
|
297
298
|
dupes = core.duplicate_id_stages
|
|
298
299
|
drift = core.status_drift
|
|
299
300
|
max_id = core.max_story_id
|
|
@@ -302,13 +303,15 @@ module BaconTracker
|
|
|
302
303
|
|
|
303
304
|
rel = core.relationship_findings
|
|
304
305
|
rel_warn, rel_err = rel.partition { |f| BaconTracker::Core::RELATIONSHIP_WARNINGS.include?(f[:kind]) }
|
|
306
|
+
sizes = core.unknown_sizes
|
|
307
|
+
templates = core.template_headers
|
|
305
308
|
|
|
306
309
|
# Flow signals (rel_warn) are reported but never fail the build: a
|
|
307
310
|
# started story waiting on a blocker is a legitimate state per
|
|
308
311
|
# docs/flow.md, so failing CI on it would train people to ignore lint.
|
|
309
|
-
broken = !(phantom.empty? && unlisted.empty? && dupes.empty? && drift.empty? && rel_err.empty?) || stale_id
|
|
312
|
+
broken = !(phantom.empty? && unlisted.empty? && titles.empty? && dupes.empty? && drift.empty? && rel_err.empty?) || stale_id
|
|
310
313
|
|
|
311
|
-
if !broken && rel_warn.empty?
|
|
314
|
+
if !broken && rel_warn.empty? && sizes.empty? && templates.empty?
|
|
312
315
|
puts "Backlog is clean - #{listed.size} stories."
|
|
313
316
|
else
|
|
314
317
|
# One list, two renderings. Each entry carries the text (identical
|
|
@@ -332,6 +335,12 @@ module BaconTracker
|
|
|
332
335
|
add.call("unlisted: #{id} has file in 2_backlog/ but missing from backlog.md",
|
|
333
336
|
core.backlog_story_files.find { |f| File.basename(f).start_with?(id) } || backlog)
|
|
334
337
|
end
|
|
338
|
+
# The estate's CI lint fails on this; reporting it here keeps the
|
|
339
|
+
# local check and CI in agreement on the same tree (BT-186).
|
|
340
|
+
titles.each do |t|
|
|
341
|
+
add.call("title-drift: #{t[:id]} is listed in backlog.md as '#{t[:listed]}' but the story is titled '#{t[:title]}'",
|
|
342
|
+
backlog, t[:line])
|
|
343
|
+
end
|
|
335
344
|
dupes.each do |id, paths|
|
|
336
345
|
text = "duplicate: #{id} found in #{paths.map { |p| p.sub("#{core.config.tracker_root}/", '') }.join(' and ')}"
|
|
337
346
|
paths.each { |dup| add.call(text, dup) }
|
|
@@ -351,6 +360,20 @@ module BaconTracker
|
|
|
351
360
|
BaconTracker::Tasks.line_matching(f[:path], /^#?\s*#{field}:/),
|
|
352
361
|
BaconTracker::Core::RELATIONSHIP_WARNINGS.include?(f[:kind]) ? :notice : :error)
|
|
353
362
|
end
|
|
363
|
+
# A size the tracker does not know is reported, not failed (BT-184):
|
|
364
|
+
# the board shows the story without one, and a project is not made
|
|
365
|
+
# to sweep its old stories to get a green build.
|
|
366
|
+
sizes.each do |f|
|
|
367
|
+
add.call("unknown-size: #{f[:id]} says size '#{f[:size]}' - sizes are #{BaconTracker::Core::SIZES.join(', ')}, the board shows none",
|
|
368
|
+
f[:path], BaconTracker::Tasks.line_matching(f[:path], /^#?\s*size:/), :notice)
|
|
369
|
+
end
|
|
370
|
+
# A template that starts with its own header (BT-185): the rake
|
|
371
|
+
# task and the API drop it, but a hand copy would not. Reported,
|
|
372
|
+
# not failed - nothing on the board is wrong.
|
|
373
|
+
templates.each do |f|
|
|
374
|
+
add.call("template-header: #{File.basename(f[:path])} in #{f[:dir]}/ starts with a header block - new stories get their own, take it out of the template",
|
|
375
|
+
f[:path], 1, :notice)
|
|
376
|
+
end
|
|
354
377
|
|
|
355
378
|
if ENV['LINT_FORMAT'] == 'github'
|
|
356
379
|
# Duplicates share one text across two files; each still gets its
|
|
@@ -360,7 +383,7 @@ module BaconTracker
|
|
|
360
383
|
else
|
|
361
384
|
findings.uniq { |f| f[:text] }.each { |f| warn " #{f[:text]}" }
|
|
362
385
|
end
|
|
363
|
-
puts 'No integrity problems - the note above
|
|
386
|
+
puts 'No integrity problems - the note above does not fail the check.' if !broken && (rel_warn.any? || sizes.any? || templates.any?)
|
|
364
387
|
exit 1 if broken
|
|
365
388
|
end
|
|
366
389
|
end
|
data/lib/bacon_tracker.rb
CHANGED
|
@@ -170,11 +170,32 @@ module BaconTracker
|
|
|
170
170
|
@config || configure
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
# German letters have a spelled-out ASCII form that readers expect in a
|
|
174
|
+
# filename ("datenschutzerklaerung", not "datenschutzerkl-rung"); every
|
|
175
|
+
# other accented letter loses its mark via NFKD decomposition (é -> e).
|
|
176
|
+
TRANSLITERATIONS = {
|
|
177
|
+
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
|
|
178
|
+
'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ẞ' => 'SS'
|
|
179
|
+
}.freeze
|
|
180
|
+
|
|
181
|
+
# What slugify returns when nothing of the title survives the ASCII rule
|
|
182
|
+
# (all-CJK, all-punctuation). Never "": an empty slug makes a dangling
|
|
183
|
+
# "<id>-.md" that is not a record (BT-095) and a dashboard route of
|
|
184
|
+
# "/projects/". The placeholder says what happened and asks for a hand-picked
|
|
185
|
+
# slug; the real title stays in the file's Title:/Feature: line.
|
|
186
|
+
EMPTY_SLUG = 'non-latin-REPLACE-ME'
|
|
187
|
+
|
|
173
188
|
# Filesystem-safe slug from a human title - the single source shared by
|
|
174
189
|
# Core (story filenames) and Dashboard (project slugs) so the two can never
|
|
175
|
-
# drift apart.
|
|
190
|
+
# drift apart. ASCII-only: the German letters are transliterated, other
|
|
191
|
+
# accents stripped, and whatever is still not [a-z0-9] becomes a hyphen.
|
|
192
|
+
# Never empty - see EMPTY_SLUG.
|
|
176
193
|
def self.slugify(title)
|
|
177
|
-
title.
|
|
194
|
+
slug = title.to_s
|
|
195
|
+
.gsub(/[äöüßÄÖÜẞ]/, TRANSLITERATIONS)
|
|
196
|
+
.unicode_normalize(:nfkd).gsub(/\p{Mn}/, '')
|
|
197
|
+
.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')
|
|
198
|
+
slug.empty? ? EMPTY_SLUG : slug
|
|
178
199
|
end
|
|
179
200
|
|
|
180
201
|
class Core
|
|
@@ -313,16 +334,6 @@ module BaconTracker
|
|
|
313
334
|
BaconTracker.slugify(title)
|
|
314
335
|
end
|
|
315
336
|
|
|
316
|
-
# A non-empty slug for a filename. slugify keeps only [a-z0-9], so a title
|
|
317
|
-
# with no ASCII alphanumerics (all-CJK, all-punctuation) slugs to "" - fall
|
|
318
|
-
# back to 'untitled' so the file is "<id>-untitled.ext", not a dangling
|
|
319
|
-
# "<id>-.ext" with a blank humanized name (BT-095). The real title stays in
|
|
320
|
-
# the file's Title:/Feature: line.
|
|
321
|
-
def filename_slug(title)
|
|
322
|
-
slug = slugify(title)
|
|
323
|
-
slug.empty? ? 'untitled' : slug
|
|
324
|
-
end
|
|
325
|
-
|
|
326
337
|
def story_dirs
|
|
327
338
|
STORY_DIRS.flat_map do |dir, meta|
|
|
328
339
|
STAGES.map do |stage|
|
|
@@ -404,6 +415,42 @@ module BaconTracker
|
|
|
404
415
|
base.sub(/\A#{id_pattern}-/, '').gsub('-', ' ')
|
|
405
416
|
end
|
|
406
417
|
|
|
418
|
+
# The in-file title: the "Feature:" line of a .feature, the "Title:" line
|
|
419
|
+
# of a .md. The filename slug is ASCII-only, so a humanized slug loses
|
|
420
|
+
# umlauts, dots and case ("Datenschutzerklärung" shows as
|
|
421
|
+
# "datenschutzerklaerung"); the in-file line is the title as typed. A .md
|
|
422
|
+
# story without a Title: line that opens with a markdown heading takes that
|
|
423
|
+
# heading, the way decision records do (BT-183) - a third of the stories
|
|
424
|
+
# across Ingo's projects are written that way. nil when the line is missing
|
|
425
|
+
# or blank and the story does not open with a heading, so callers fall back
|
|
426
|
+
# to the slug.
|
|
427
|
+
def story_title(body, ext)
|
|
428
|
+
line = ext == '.feature' ? /^Feature:[ \t]*(.*)$/ : /^Title:[ \t]*(.*)$/
|
|
429
|
+
title = body[line, 1]&.strip
|
|
430
|
+
title = body[LEADING_HEADING, 'text']&.strip if (title.nil? || title.empty?) && ext != '.feature'
|
|
431
|
+
title unless title.nil? || title.empty?
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# A markdown heading (any level) as the first non-blank line of a story
|
|
435
|
+
# body: the title of a .md story that has no Title: line (BT-183). Only the
|
|
436
|
+
# opening line counts, so a "## Description" section further down, or a
|
|
437
|
+
# "# comment" inside a code fence, is never mistaken for the title.
|
|
438
|
+
LEADING_HEADING = /\A(?<lead>\s*\#{1,6}[ \t]+)(?<text>.+?)(?<tail>[ \t#]*)$/
|
|
439
|
+
|
|
440
|
+
# Rewrite the opening heading of a .md story (see LEADING_HEADING) to
|
|
441
|
+
# `title`, leaving any YAML frontmatter untouched. Content without such a
|
|
442
|
+
# heading is returned as is.
|
|
443
|
+
def retitle_leading_heading(content, title)
|
|
444
|
+
fm, body =
|
|
445
|
+
if content.start_with?("---\n")
|
|
446
|
+
close = content.index(/^---\n/, 4)
|
|
447
|
+
close ? [content[0...(close + 4)], content[(close + 4)..]] : ['', content]
|
|
448
|
+
else
|
|
449
|
+
['', content]
|
|
450
|
+
end
|
|
451
|
+
fm + body.sub(LEADING_HEADING) { "#{$~[:lead]}#{title}#{$~[:tail]}" }
|
|
452
|
+
end
|
|
453
|
+
|
|
407
454
|
# Delegates to set_frontmatter_field: the substitution stays scoped to the
|
|
408
455
|
# frontmatter block (a body line like "status: healthy" is never touched)
|
|
409
456
|
# and a missing status field is added instead of silently skipped.
|
|
@@ -500,7 +547,14 @@ module BaconTracker
|
|
|
500
547
|
raise
|
|
501
548
|
end
|
|
502
549
|
|
|
503
|
-
#
|
|
550
|
+
# The T-shirt sizes the tracker knows. The one list behind creating,
|
|
551
|
+
# editing, showing and linting a story, so no entry point can accept a
|
|
552
|
+
# size another one hides (BT-184 - XS was refused on edit, created by the
|
|
553
|
+
# rake task, and shown as nothing by the board).
|
|
554
|
+
SIZES = %w[S M L].freeze
|
|
555
|
+
SIZE_RULE = "size must be #{SIZES[0...-1].join(', ')}, or #{SIZES.last}".freeze
|
|
556
|
+
|
|
557
|
+
# Normalize a T-shirt size to SIZES (upcased), treating nil/"" as "unset"
|
|
504
558
|
# and raising on anything else - the one rule shared by create_story and
|
|
505
559
|
# update_story so the two entry points can't disagree (BT-101).
|
|
506
560
|
def normalized_size!(size)
|
|
@@ -509,11 +563,25 @@ module BaconTracker
|
|
|
509
563
|
return nil if size.empty?
|
|
510
564
|
|
|
511
565
|
up = size.upcase
|
|
512
|
-
raise ArgumentError,
|
|
566
|
+
raise ArgumentError, SIZE_RULE unless SIZES.include?(up)
|
|
513
567
|
|
|
514
568
|
up
|
|
515
569
|
end
|
|
516
570
|
|
|
571
|
+
# Stories whose size: is not one the tracker knows (BT-184). story:lint
|
|
572
|
+
# reports them as a notice, not a failure: the value came from a hand edit
|
|
573
|
+
# or an older tool, the board already shows the story without a size, and
|
|
574
|
+
# a project is not made to sweep its old stories for a green build. Done
|
|
575
|
+
# stories are a record and are skipped, as relationship_findings does.
|
|
576
|
+
def unknown_sizes
|
|
577
|
+
all_stories.reject { |s| s[:stage] == '4_done' }.sort_by { |s| s[:id] }.filter_map do |s|
|
|
578
|
+
declared = s[:declared_size]
|
|
579
|
+
next if declared.nil? || SIZES.include?(declared.upcase)
|
|
580
|
+
|
|
581
|
+
{ id: s[:id], size: declared, path: s[:path] }
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
517
585
|
def move_to_stage(result, new_stage)
|
|
518
586
|
old_path = result[:file]
|
|
519
587
|
new_dir = File.join(@config.tracker_root, result[:dir], new_stage)
|
|
@@ -527,8 +595,44 @@ module BaconTracker
|
|
|
527
595
|
new_path
|
|
528
596
|
end
|
|
529
597
|
|
|
598
|
+
# The title a backlog.md line carries: the story's in-file title, the same
|
|
599
|
+
# one the board shows, so the line and the card never disagree (BT-186).
|
|
600
|
+
# The humanized slug is what parse_story_file falls back to when the file
|
|
601
|
+
# has no title line and no opening heading.
|
|
602
|
+
def backlog_title(path)
|
|
603
|
+
meta = story_dirs.find { |d| d[:path] == File.dirname(path) }
|
|
604
|
+
story = meta && parse_story_file(path, meta)
|
|
605
|
+
story ? story[:title] : humanize_slug(path)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# The title part of a backlog ENTRY line ("- NS-123 title" -> "title"),
|
|
609
|
+
# nil for a non-entry line. Compared against backlog_title by story:lint.
|
|
610
|
+
def backlog_line_title(line)
|
|
611
|
+
m = line.match(/\A\s*-\s+#{id_pattern}[ \t]*(.*?)\s*\z/)
|
|
612
|
+
m && m[1]
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Backlog entry lines whose title is not the story's (BT-186): the same
|
|
616
|
+
# comparison the estate's CI lint makes, so a tree that passes locally
|
|
617
|
+
# passes there. Lines without a file are phantoms and reported as such.
|
|
618
|
+
def backlog_title_drift
|
|
619
|
+
return [] unless File.exist?(@config.backlog_path)
|
|
620
|
+
|
|
621
|
+
on_disk = backlog_story_files.to_h { |f| [File.basename(f)[filename_id_pattern], f] }
|
|
622
|
+
File.readlines(@config.backlog_path, encoding: 'utf-8').each_with_index.filter_map do |line, i|
|
|
623
|
+
id = backlog_line_id(line)
|
|
624
|
+
next unless id && on_disk.key?(id)
|
|
625
|
+
|
|
626
|
+
listed = backlog_line_title(line)
|
|
627
|
+
title = backlog_title(on_disk[id])
|
|
628
|
+
next if listed == title
|
|
629
|
+
|
|
630
|
+
{ id: id, listed: listed, title: title, path: on_disk[id], line: i + 1 }
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
530
634
|
def backlog_add(id, filename)
|
|
531
|
-
title =
|
|
635
|
+
title = backlog_title(filename)
|
|
532
636
|
existing = File.exist?(@config.backlog_path) ? File.read(@config.backlog_path, encoding: 'utf-8') : ''
|
|
533
637
|
# Normalize the trailing newline first so a hand-edited backlog.md that
|
|
534
638
|
# doesn't end in one doesn't glue the new entry onto the last line
|
|
@@ -554,7 +658,7 @@ module BaconTracker
|
|
|
554
658
|
lines = File.exist?(@config.backlog_path) ? File.readlines(@config.backlog_path, encoding: 'utf-8') : []
|
|
555
659
|
kept = lines.select { |l| (id = backlog_line_id(l)).nil? || on_disk.key?(id) }
|
|
556
660
|
listed = kept.filter_map { |l| backlog_line_id(l) }
|
|
557
|
-
additions = (on_disk.keys - listed).map { |id| "- #{id} #{
|
|
661
|
+
additions = (on_disk.keys - listed).map { |id| "- #{id} #{backlog_title(on_disk[id])}\n" }
|
|
558
662
|
atomic_write(@config.backlog_path, (kept + additions).join) if kept != lines || additions.any?
|
|
559
663
|
end
|
|
560
664
|
|
|
@@ -1324,7 +1428,7 @@ module BaconTracker
|
|
|
1324
1428
|
with_decisions_lock do
|
|
1325
1429
|
heal_proposed!
|
|
1326
1430
|
id = format_decision_id(consume_decision_id)
|
|
1327
|
-
slug =
|
|
1431
|
+
slug = slugify(title) # never "<id>-.md", which is not a record (EMPTY_SLUG)
|
|
1328
1432
|
template_path = File.join(root, '_template.md')
|
|
1329
1433
|
body = File.exist?(template_path) ? File.read(template_path, encoding: 'utf-8') : Templates::DECISION
|
|
1330
1434
|
today = Date.today.iso8601
|
|
@@ -1550,7 +1654,11 @@ module BaconTracker
|
|
|
1550
1654
|
# same set the `story:edit` task parses. A field-validation failure is
|
|
1551
1655
|
# reported against the just-created story so its ID isn't lost.
|
|
1552
1656
|
def create(kind, title, fields = {})
|
|
1553
|
-
|
|
1657
|
+
fields = fields.dup
|
|
1658
|
+
# The size goes through create_story, which validates it before an ID is
|
|
1659
|
+
# consumed (BT-184): a refused size leaves no unsized story behind. The
|
|
1660
|
+
# remaining fields are set afterwards, as before.
|
|
1661
|
+
story = create_story(kind, title, size: fields.delete(:size))
|
|
1554
1662
|
path =
|
|
1555
1663
|
if fields.empty?
|
|
1556
1664
|
story[:path]
|
|
@@ -1647,8 +1755,11 @@ module BaconTracker
|
|
|
1647
1755
|
[{}, content]
|
|
1648
1756
|
end
|
|
1649
1757
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1758
|
+
# declared_size is the value as written; size is nil unless it is one
|
|
1759
|
+
# the tracker knows, and story:lint reports the difference (BT-184).
|
|
1760
|
+
declared_size = fm['size']&.to_s&.strip
|
|
1761
|
+
declared_size = nil if declared_size&.empty?
|
|
1762
|
+
size = declared_size && SIZES.include?(declared_size.upcase) ? declared_size.upcase : nil
|
|
1652
1763
|
|
|
1653
1764
|
blocked_by = decision_refs(fm['blocked_by'])
|
|
1654
1765
|
linked_to = decision_refs(fm['linked_to'])
|
|
@@ -1660,12 +1771,12 @@ module BaconTracker
|
|
|
1660
1771
|
subtasks = subtask_counts(body, ext)
|
|
1661
1772
|
|
|
1662
1773
|
{ id: id, type: dir_meta[:type], stage: dir_meta[:stage], dir: dir_meta[:dir],
|
|
1663
|
-
path: path, title: humanize_slug(path), body: body, size: size,
|
|
1774
|
+
path: path, title: story_title(body, ext) || humanize_slug(path), body: body, size: size,
|
|
1664
1775
|
blocked_by: blocked_by, linked_to: linked_to, assignee: assignee, subtasks: subtasks,
|
|
1665
1776
|
# Body-line addresses of the subtasks - the client renders/toggles by
|
|
1666
1777
|
# these instead of re-deriving fence rules (the BT-049 drift class).
|
|
1667
1778
|
subtask_lines: st_lines.empty? ? nil : st_lines,
|
|
1668
|
-
declared_status: fm['status']&.to_s }
|
|
1779
|
+
declared_status: fm['status']&.to_s, declared_size: declared_size }
|
|
1669
1780
|
end
|
|
1670
1781
|
|
|
1671
1782
|
# Indices of body lines that are subtasks, skipping fenced content
|
|
@@ -1851,7 +1962,7 @@ module BaconTracker
|
|
|
1851
1962
|
with_lock do
|
|
1852
1963
|
id_num = consume_id
|
|
1853
1964
|
id = format_id(id_num)
|
|
1854
|
-
slug =
|
|
1965
|
+
slug = slugify(title)
|
|
1855
1966
|
|
|
1856
1967
|
filename = "#{id}-#{slug}#{ext}"
|
|
1857
1968
|
dest_dir = File.join(@config.tracker_root, dir, stage)
|
|
@@ -1865,13 +1976,8 @@ module BaconTracker
|
|
|
1865
1976
|
else
|
|
1866
1977
|
Templates.for(ext, kind)
|
|
1867
1978
|
end
|
|
1868
|
-
filled = if ext == '.feature'
|
|
1869
|
-
template.sub('Name the Feature') { title }
|
|
1870
|
-
else
|
|
1871
|
-
template.sub(/\ATitle:.*/) { "Title: #{title}" }
|
|
1872
|
-
end
|
|
1873
1979
|
fm = kind == 'feature' ? frontmatter_feature(id, kind, status) : frontmatter_md(id, kind, status)
|
|
1874
|
-
content = fm +
|
|
1980
|
+
content = fm + fill_template(template, ext, title)
|
|
1875
1981
|
|
|
1876
1982
|
atomic_write(dest, content)
|
|
1877
1983
|
backlog_add(id, dest) if stage == '2_backlog'
|
|
@@ -1881,6 +1987,52 @@ module BaconTracker
|
|
|
1881
1987
|
end
|
|
1882
1988
|
end
|
|
1883
1989
|
|
|
1990
|
+
# The body of a new story: the template without any header block of its
|
|
1991
|
+
# own, with the title filled into its first Title:/Feature: line. A
|
|
1992
|
+
# template that starts with a header (id/type/status, a placeholder id)
|
|
1993
|
+
# used to be copied as is, so the new story carried two headers and the
|
|
1994
|
+
# template's placeholder title (BT-185). A template without a title line
|
|
1995
|
+
# gets one, so the title as typed always reaches the file.
|
|
1996
|
+
def fill_template(template, ext, title)
|
|
1997
|
+
body = strip_template_header(template)
|
|
1998
|
+
line = ext == '.feature' ? 'Feature' : 'Title'
|
|
1999
|
+
if body.match?(/^#{line}:.*$/)
|
|
2000
|
+
body.sub(/^#{line}:.*$/) { "#{line}: #{title}" }
|
|
2001
|
+
else
|
|
2002
|
+
"#{line}: #{title}\n\n#{body}"
|
|
2003
|
+
end
|
|
2004
|
+
end
|
|
2005
|
+
|
|
2006
|
+
# The header block a template starts with, if any - a YAML '---' block in
|
|
2007
|
+
# either dialect, or the '# key: value' lines of a .feature. Returns the
|
|
2008
|
+
# body after it (leading blank lines dropped), or the template unchanged.
|
|
2009
|
+
def strip_template_header(template)
|
|
2010
|
+
if template.start_with?("---\n")
|
|
2011
|
+
close = template.index(/^---\n/, 4)
|
|
2012
|
+
close ? template[(close + 4)..].lstrip : template
|
|
2013
|
+
elsif template.match?(/\A#{FEATURE_HEADER}/)
|
|
2014
|
+
template.lines.drop_while { |l| l.match?(FEATURE_HEADER) }.join.lstrip
|
|
2015
|
+
else
|
|
2016
|
+
template
|
|
2017
|
+
end
|
|
2018
|
+
end
|
|
2019
|
+
|
|
2020
|
+
# Story templates that start with a header block of their own (BT-185).
|
|
2021
|
+
# create_story drops it, but the /tracker command's file steps and anyone
|
|
2022
|
+
# copying the template by hand would not, so story:lint points it out and
|
|
2023
|
+
# the project can take it out of the template.
|
|
2024
|
+
def template_headers
|
|
2025
|
+
STORY_DIRS.filter_map do |dir, m|
|
|
2026
|
+
path = File.join(@config.tracker_root, dir, "_template#{m[:ext]}")
|
|
2027
|
+
next unless File.exist?(path)
|
|
2028
|
+
|
|
2029
|
+
template = File.read(path, encoding: 'utf-8')
|
|
2030
|
+
next if strip_template_header(template).equal?(template)
|
|
2031
|
+
|
|
2032
|
+
{ path: path, dir: dir }
|
|
2033
|
+
end
|
|
2034
|
+
end
|
|
2035
|
+
|
|
1884
2036
|
def delete_story(id)
|
|
1885
2037
|
with_lock do
|
|
1886
2038
|
result = find_story(id)
|
|
@@ -1942,8 +2094,12 @@ module BaconTracker
|
|
|
1942
2094
|
if title
|
|
1943
2095
|
content = if ext == '.feature'
|
|
1944
2096
|
content.sub(/^Feature: .+/) { "Feature: #{title}" }
|
|
1945
|
-
|
|
2097
|
+
elsif content.match?(/^Title: .+/)
|
|
1946
2098
|
content.sub(/^Title: .+/) { "Title: #{title}" }
|
|
2099
|
+
else
|
|
2100
|
+
# A story titled by its opening heading (BT-183) is retitled
|
|
2101
|
+
# in that heading, so the card keeps following the file.
|
|
2102
|
+
retitle_leading_heading(content, title)
|
|
1947
2103
|
end
|
|
1948
2104
|
end
|
|
1949
2105
|
|
|
@@ -1975,15 +2131,17 @@ module BaconTracker
|
|
|
1975
2131
|
|
|
1976
2132
|
if title || body || !size.nil? || !blocked_by.nil? || !linked_to.nil? || !assignee.nil?
|
|
1977
2133
|
atomic_write(path, content)
|
|
1978
|
-
new_path = title ? File.join(File.dirname(path), "#{id}-#{
|
|
2134
|
+
new_path = title ? File.join(File.dirname(path), "#{id}-#{slugify(title)}#{ext}") : path
|
|
1979
2135
|
if path != new_path
|
|
1980
2136
|
FileUtils.mv(path, new_path)
|
|
1981
2137
|
path = new_path
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2138
|
+
end
|
|
2139
|
+
# The backlog line follows the title even when the slug, and so the
|
|
2140
|
+
# filename, did not change ("Fix bug" -> "Fix bug!") - BT-186.
|
|
2141
|
+
if title && result[:stage] == '2_backlog' && File.exist?(@config.backlog_path)
|
|
2142
|
+
lines = File.readlines(@config.backlog_path, encoding: 'utf-8')
|
|
2143
|
+
atomic_write(@config.backlog_path,
|
|
2144
|
+
lines.map { |l| backlog_line_id(l) == id ? "- #{id} #{title}\n" : l }.join)
|
|
1987
2145
|
end
|
|
1988
2146
|
end
|
|
1989
2147
|
|