bacon-tracker 1.1.0 → 1.1.2
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 +18 -0
- data/lib/bacon_tracker/commands/tracker.md +4 -4
- data/lib/bacon_tracker/tasks.rb +27 -4
- data/lib/bacon_tracker/version.rb +1 -1
- data/lib/bacon_tracker.rb +166 -23
- 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: 322d2ee34076ea624f44901ff5f2a67ced37ddc3e0d0677ead1cf40c39fc4a94
|
|
4
|
+
data.tar.gz: 1501ba1a4b4aea4e4cd7d6c7e3e8e8bb565e4b99d18624765e06e374e2982dc2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a3ea03b467fb292aec46df928970edf1949b21aa6f8cf1a5d8c76b6426ebc931bec202a07a02985c83620d369aea37e45a21eff7b0f373d80d0a9b0b3bea899
|
|
7
|
+
data.tar.gz: ead2444ec10dd2fd5d16f22e1c243b9f98de2b0b614db513b08d5e7b92cf92e5403b782bbc55841f999b0c9c42f89ba1a0c7f8359c58d7f8d481f37850b201ae
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
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
|
+
|
|
7
|
+
## [1.1.2] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Docs pages and decision records no longer all show "could not load" after the first page with a fenced code block. kramdown required Rouge to highlight the block whenever Rouge was installed, and Rouge loaded a second copy of `strscan`, which broke every markdown render after it until the server restarted. Code blocks are now rendered plain, without a syntax highlighter, which is how the page already showed them.
|
|
12
|
+
|
|
13
|
+
## [1.1.1] - 2026-10-21
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `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.
|
|
18
|
+
- `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.
|
|
19
|
+
- 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.
|
|
20
|
+
- `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.
|
|
21
|
+
- `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.
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
6
24
|
## [1.1.0] - 2026-10-01
|
|
7
25
|
|
|
8
26
|
### Changed
|
|
@@ -107,7 +107,7 @@ type must be `feature`, `bug`, or `chore`. The title is everything after the typ
|
|
|
107
107
|
3. Pad N to 3 digits: `NNN`
|
|
108
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 (the `Title:`/`Feature:` line; humanised from the filename if
|
|
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
|
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
|
@@ -418,14 +418,39 @@ module BaconTracker
|
|
|
418
418
|
# The in-file title: the "Feature:" line of a .feature, the "Title:" line
|
|
419
419
|
# of a .md. The filename slug is ASCII-only, so a humanized slug loses
|
|
420
420
|
# umlauts, dots and case ("Datenschutzerklärung" shows as
|
|
421
|
-
# "datenschutzerklaerung"); the in-file line is the title as typed.
|
|
422
|
-
#
|
|
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.
|
|
423
427
|
def story_title(body, ext)
|
|
424
428
|
line = ext == '.feature' ? /^Feature:[ \t]*(.*)$/ : /^Title:[ \t]*(.*)$/
|
|
425
429
|
title = body[line, 1]&.strip
|
|
430
|
+
title = body[LEADING_HEADING, 'text']&.strip if (title.nil? || title.empty?) && ext != '.feature'
|
|
426
431
|
title unless title.nil? || title.empty?
|
|
427
432
|
end
|
|
428
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
|
+
|
|
429
454
|
# Delegates to set_frontmatter_field: the substitution stays scoped to the
|
|
430
455
|
# frontmatter block (a body line like "status: healthy" is never touched)
|
|
431
456
|
# and a missing status field is added instead of silently skipped.
|
|
@@ -522,7 +547,14 @@ module BaconTracker
|
|
|
522
547
|
raise
|
|
523
548
|
end
|
|
524
549
|
|
|
525
|
-
#
|
|
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"
|
|
526
558
|
# and raising on anything else - the one rule shared by create_story and
|
|
527
559
|
# update_story so the two entry points can't disagree (BT-101).
|
|
528
560
|
def normalized_size!(size)
|
|
@@ -531,11 +563,25 @@ module BaconTracker
|
|
|
531
563
|
return nil if size.empty?
|
|
532
564
|
|
|
533
565
|
up = size.upcase
|
|
534
|
-
raise ArgumentError,
|
|
566
|
+
raise ArgumentError, SIZE_RULE unless SIZES.include?(up)
|
|
535
567
|
|
|
536
568
|
up
|
|
537
569
|
end
|
|
538
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
|
+
|
|
539
585
|
def move_to_stage(result, new_stage)
|
|
540
586
|
old_path = result[:file]
|
|
541
587
|
new_dir = File.join(@config.tracker_root, result[:dir], new_stage)
|
|
@@ -549,8 +595,44 @@ module BaconTracker
|
|
|
549
595
|
new_path
|
|
550
596
|
end
|
|
551
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
|
+
|
|
552
634
|
def backlog_add(id, filename)
|
|
553
|
-
title =
|
|
635
|
+
title = backlog_title(filename)
|
|
554
636
|
existing = File.exist?(@config.backlog_path) ? File.read(@config.backlog_path, encoding: 'utf-8') : ''
|
|
555
637
|
# Normalize the trailing newline first so a hand-edited backlog.md that
|
|
556
638
|
# doesn't end in one doesn't glue the new entry onto the last line
|
|
@@ -576,7 +658,7 @@ module BaconTracker
|
|
|
576
658
|
lines = File.exist?(@config.backlog_path) ? File.readlines(@config.backlog_path, encoding: 'utf-8') : []
|
|
577
659
|
kept = lines.select { |l| (id = backlog_line_id(l)).nil? || on_disk.key?(id) }
|
|
578
660
|
listed = kept.filter_map { |l| backlog_line_id(l) }
|
|
579
|
-
additions = (on_disk.keys - listed).map { |id| "- #{id} #{
|
|
661
|
+
additions = (on_disk.keys - listed).map { |id| "- #{id} #{backlog_title(on_disk[id])}\n" }
|
|
580
662
|
atomic_write(@config.backlog_path, (kept + additions).join) if kept != lines || additions.any?
|
|
581
663
|
end
|
|
582
664
|
|
|
@@ -1090,8 +1172,15 @@ module BaconTracker
|
|
|
1090
1172
|
}
|
|
1091
1173
|
end
|
|
1092
1174
|
|
|
1175
|
+
# No syntax highlighter: kramdown defaults to Rouge and requires it on the
|
|
1176
|
+
# first fenced code block if it happens to be installed. Rouge activates
|
|
1177
|
+
# the strscan gem over the already-loaded default one, two StringScanner
|
|
1178
|
+
# types then live in the process, and every parse after that raises - the
|
|
1179
|
+
# whole docs surface goes dark until a restart. Plain <pre><code> is all
|
|
1180
|
+
# the page styles anyway.
|
|
1093
1181
|
def render_markdown(text)
|
|
1094
|
-
doc = Kramdown::Document.new(gfm_table_compat(text), input: 'GFM', hard_wrap: false
|
|
1182
|
+
doc = Kramdown::Document.new(gfm_table_compat(text), input: 'GFM', hard_wrap: false,
|
|
1183
|
+
syntax_highlighter: nil)
|
|
1095
1184
|
sanitize_tree!(doc.root)
|
|
1096
1185
|
doc.to_html
|
|
1097
1186
|
end
|
|
@@ -1572,7 +1661,11 @@ module BaconTracker
|
|
|
1572
1661
|
# same set the `story:edit` task parses. A field-validation failure is
|
|
1573
1662
|
# reported against the just-created story so its ID isn't lost.
|
|
1574
1663
|
def create(kind, title, fields = {})
|
|
1575
|
-
|
|
1664
|
+
fields = fields.dup
|
|
1665
|
+
# The size goes through create_story, which validates it before an ID is
|
|
1666
|
+
# consumed (BT-184): a refused size leaves no unsized story behind. The
|
|
1667
|
+
# remaining fields are set afterwards, as before.
|
|
1668
|
+
story = create_story(kind, title, size: fields.delete(:size))
|
|
1576
1669
|
path =
|
|
1577
1670
|
if fields.empty?
|
|
1578
1671
|
story[:path]
|
|
@@ -1669,8 +1762,11 @@ module BaconTracker
|
|
|
1669
1762
|
[{}, content]
|
|
1670
1763
|
end
|
|
1671
1764
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1765
|
+
# declared_size is the value as written; size is nil unless it is one
|
|
1766
|
+
# the tracker knows, and story:lint reports the difference (BT-184).
|
|
1767
|
+
declared_size = fm['size']&.to_s&.strip
|
|
1768
|
+
declared_size = nil if declared_size&.empty?
|
|
1769
|
+
size = declared_size && SIZES.include?(declared_size.upcase) ? declared_size.upcase : nil
|
|
1674
1770
|
|
|
1675
1771
|
blocked_by = decision_refs(fm['blocked_by'])
|
|
1676
1772
|
linked_to = decision_refs(fm['linked_to'])
|
|
@@ -1687,7 +1783,7 @@ module BaconTracker
|
|
|
1687
1783
|
# Body-line addresses of the subtasks - the client renders/toggles by
|
|
1688
1784
|
# these instead of re-deriving fence rules (the BT-049 drift class).
|
|
1689
1785
|
subtask_lines: st_lines.empty? ? nil : st_lines,
|
|
1690
|
-
declared_status: fm['status']&.to_s }
|
|
1786
|
+
declared_status: fm['status']&.to_s, declared_size: declared_size }
|
|
1691
1787
|
end
|
|
1692
1788
|
|
|
1693
1789
|
# Indices of body lines that are subtasks, skipping fenced content
|
|
@@ -1887,13 +1983,8 @@ module BaconTracker
|
|
|
1887
1983
|
else
|
|
1888
1984
|
Templates.for(ext, kind)
|
|
1889
1985
|
end
|
|
1890
|
-
filled = if ext == '.feature'
|
|
1891
|
-
template.sub('Name the Feature') { title }
|
|
1892
|
-
else
|
|
1893
|
-
template.sub(/\ATitle:.*/) { "Title: #{title}" }
|
|
1894
|
-
end
|
|
1895
1986
|
fm = kind == 'feature' ? frontmatter_feature(id, kind, status) : frontmatter_md(id, kind, status)
|
|
1896
|
-
content = fm +
|
|
1987
|
+
content = fm + fill_template(template, ext, title)
|
|
1897
1988
|
|
|
1898
1989
|
atomic_write(dest, content)
|
|
1899
1990
|
backlog_add(id, dest) if stage == '2_backlog'
|
|
@@ -1903,6 +1994,52 @@ module BaconTracker
|
|
|
1903
1994
|
end
|
|
1904
1995
|
end
|
|
1905
1996
|
|
|
1997
|
+
# The body of a new story: the template without any header block of its
|
|
1998
|
+
# own, with the title filled into its first Title:/Feature: line. A
|
|
1999
|
+
# template that starts with a header (id/type/status, a placeholder id)
|
|
2000
|
+
# used to be copied as is, so the new story carried two headers and the
|
|
2001
|
+
# template's placeholder title (BT-185). A template without a title line
|
|
2002
|
+
# gets one, so the title as typed always reaches the file.
|
|
2003
|
+
def fill_template(template, ext, title)
|
|
2004
|
+
body = strip_template_header(template)
|
|
2005
|
+
line = ext == '.feature' ? 'Feature' : 'Title'
|
|
2006
|
+
if body.match?(/^#{line}:.*$/)
|
|
2007
|
+
body.sub(/^#{line}:.*$/) { "#{line}: #{title}" }
|
|
2008
|
+
else
|
|
2009
|
+
"#{line}: #{title}\n\n#{body}"
|
|
2010
|
+
end
|
|
2011
|
+
end
|
|
2012
|
+
|
|
2013
|
+
# The header block a template starts with, if any - a YAML '---' block in
|
|
2014
|
+
# either dialect, or the '# key: value' lines of a .feature. Returns the
|
|
2015
|
+
# body after it (leading blank lines dropped), or the template unchanged.
|
|
2016
|
+
def strip_template_header(template)
|
|
2017
|
+
if template.start_with?("---\n")
|
|
2018
|
+
close = template.index(/^---\n/, 4)
|
|
2019
|
+
close ? template[(close + 4)..].lstrip : template
|
|
2020
|
+
elsif template.match?(/\A#{FEATURE_HEADER}/)
|
|
2021
|
+
template.lines.drop_while { |l| l.match?(FEATURE_HEADER) }.join.lstrip
|
|
2022
|
+
else
|
|
2023
|
+
template
|
|
2024
|
+
end
|
|
2025
|
+
end
|
|
2026
|
+
|
|
2027
|
+
# Story templates that start with a header block of their own (BT-185).
|
|
2028
|
+
# create_story drops it, but the /tracker command's file steps and anyone
|
|
2029
|
+
# copying the template by hand would not, so story:lint points it out and
|
|
2030
|
+
# the project can take it out of the template.
|
|
2031
|
+
def template_headers
|
|
2032
|
+
STORY_DIRS.filter_map do |dir, m|
|
|
2033
|
+
path = File.join(@config.tracker_root, dir, "_template#{m[:ext]}")
|
|
2034
|
+
next unless File.exist?(path)
|
|
2035
|
+
|
|
2036
|
+
template = File.read(path, encoding: 'utf-8')
|
|
2037
|
+
next if strip_template_header(template).equal?(template)
|
|
2038
|
+
|
|
2039
|
+
{ path: path, dir: dir }
|
|
2040
|
+
end
|
|
2041
|
+
end
|
|
2042
|
+
|
|
1906
2043
|
def delete_story(id)
|
|
1907
2044
|
with_lock do
|
|
1908
2045
|
result = find_story(id)
|
|
@@ -1964,8 +2101,12 @@ module BaconTracker
|
|
|
1964
2101
|
if title
|
|
1965
2102
|
content = if ext == '.feature'
|
|
1966
2103
|
content.sub(/^Feature: .+/) { "Feature: #{title}" }
|
|
1967
|
-
|
|
2104
|
+
elsif content.match?(/^Title: .+/)
|
|
1968
2105
|
content.sub(/^Title: .+/) { "Title: #{title}" }
|
|
2106
|
+
else
|
|
2107
|
+
# A story titled by its opening heading (BT-183) is retitled
|
|
2108
|
+
# in that heading, so the card keeps following the file.
|
|
2109
|
+
retitle_leading_heading(content, title)
|
|
1969
2110
|
end
|
|
1970
2111
|
end
|
|
1971
2112
|
|
|
@@ -2001,11 +2142,13 @@ module BaconTracker
|
|
|
2001
2142
|
if path != new_path
|
|
2002
2143
|
FileUtils.mv(path, new_path)
|
|
2003
2144
|
path = new_path
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2145
|
+
end
|
|
2146
|
+
# The backlog line follows the title even when the slug, and so the
|
|
2147
|
+
# filename, did not change ("Fix bug" -> "Fix bug!") - BT-186.
|
|
2148
|
+
if title && result[:stage] == '2_backlog' && File.exist?(@config.backlog_path)
|
|
2149
|
+
lines = File.readlines(@config.backlog_path, encoding: 'utf-8')
|
|
2150
|
+
atomic_write(@config.backlog_path,
|
|
2151
|
+
lines.map { |l| backlog_line_id(l) == id ? "- #{id} #{title}\n" : l }.join)
|
|
2009
2152
|
end
|
|
2010
2153
|
end
|
|
2011
2154
|
|