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,465 @@
|
|
|
1
|
+
module BaconTracker
|
|
2
|
+
module Tasks
|
|
3
|
+
extend Rake::DSL
|
|
4
|
+
|
|
5
|
+
# Parse the server port from $PORT, failing loudly on a non-integer instead
|
|
6
|
+
# of String#to_i silently yielding 0 and binding a random port (BT-087).
|
|
7
|
+
def self.env_port
|
|
8
|
+
Integer(ENV.fetch('PORT', '4567'))
|
|
9
|
+
rescue ArgumentError
|
|
10
|
+
abort "PORT must be an integer, got: #{ENV['PORT'].inspect}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# 1-indexed line number of the first line matching `re`, or nil. Used to
|
|
14
|
+
# anchor a GitHub annotation to the offending frontmatter line rather than
|
|
15
|
+
# to the top of the file; nil is fine, the annotation just lands file-wide.
|
|
16
|
+
def self.line_matching(path, re)
|
|
17
|
+
return nil unless path && File.file?(path)
|
|
18
|
+
|
|
19
|
+
File.foreach(path).with_index(1) { |line, n| return n if line.match?(re) }
|
|
20
|
+
nil
|
|
21
|
+
rescue SystemCallError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A GitHub Actions workflow command, so a finding renders as an annotation
|
|
26
|
+
# on the file that caused it. Errors fail the check; flow signals are
|
|
27
|
+
# notices, which annotate without failing - the same severity split the
|
|
28
|
+
# terminal output makes (BT-121/BT-123).
|
|
29
|
+
#
|
|
30
|
+
# Paths must be workspace-relative or GitHub will not match them to the
|
|
31
|
+
# diff. The message and property values are escaped per GitHub's rules:
|
|
32
|
+
# messages escape %/CR/LF, properties additionally escape , and :.
|
|
33
|
+
def self.github_annotation(finding)
|
|
34
|
+
esc = ->(s) { s.to_s.gsub('%', '%25').gsub("\r", '%0D').gsub("\n", '%0A') }
|
|
35
|
+
prop = ->(s) { esc.call(s).gsub(',', '%2C').gsub(':', '%3A') }
|
|
36
|
+
|
|
37
|
+
props = []
|
|
38
|
+
if (rel = relative_path(finding[:path]))
|
|
39
|
+
props << "file=#{prop.call(rel)}"
|
|
40
|
+
props << "line=#{finding[:line]}" if finding[:line]
|
|
41
|
+
end
|
|
42
|
+
command = finding[:severity] == :notice ? 'notice' : 'error'
|
|
43
|
+
"::#{command}#{props.empty? ? '' : " #{props.join(',')}"}::#{esc.call(finding[:text])}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Path relative to the workspace GitHub checked out (falling back to the
|
|
47
|
+
# working directory), since annotations are matched against repo-relative
|
|
48
|
+
# paths. Returns nil for a path outside it - better no `file=` than a wrong
|
|
49
|
+
# one, which would annotate an unrelated file.
|
|
50
|
+
def self.relative_path(path)
|
|
51
|
+
return nil unless path
|
|
52
|
+
|
|
53
|
+
root = File.expand_path(ENV.fetch('GITHUB_WORKSPACE', Dir.pwd))
|
|
54
|
+
full = File.expand_path(path)
|
|
55
|
+
return nil unless full.start_with?("#{root}/")
|
|
56
|
+
|
|
57
|
+
full.delete_prefix("#{root}/")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One line per relationship finding (BT-121). Each says what is wrong and,
|
|
61
|
+
# where there is an obvious next move, what to do about it - a lint line
|
|
62
|
+
# nobody knows how to act on is a lint line people learn to skip.
|
|
63
|
+
def self.relationship_message(finding)
|
|
64
|
+
id, ref = finding[:id], finding[:ref]
|
|
65
|
+
case finding[:kind]
|
|
66
|
+
when :stale_blocker
|
|
67
|
+
"stale-blocker: #{id} waits on #{ref}, which is done - clear it from blocked_by"
|
|
68
|
+
when :dangling
|
|
69
|
+
"dangling-ref: #{id} #{finding[:field]} names #{ref}, which has no story file"
|
|
70
|
+
when :cycle
|
|
71
|
+
chain = (finding[:cycle] + [finding[:cycle].first]).join(' → ')
|
|
72
|
+
"cycle: #{chain} - none of these can ever start"
|
|
73
|
+
when :started_while_blocked
|
|
74
|
+
"blocked-started: #{id} is started but waits on #{ref} (#{finding[:ref_stage]})"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.install_for_dashboard(dashboard_path)
|
|
79
|
+
require 'bacon_tracker/dashboard'
|
|
80
|
+
|
|
81
|
+
dashboard = BaconTracker::Dashboard.new(dashboard_path)
|
|
82
|
+
ns = ENV['NS'] || ENV['NAMESPACE']
|
|
83
|
+
|
|
84
|
+
# Without NS the per-project story tasks can't be targeted, but rake
|
|
85
|
+
# itself must keep working (rake -T, story:dashboard_server) - aborting
|
|
86
|
+
# here would kill every rake invocation at Rakefile-load time.
|
|
87
|
+
unless ns
|
|
88
|
+
install_dashboard_server_task(dashboard_path)
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
proj = dashboard.projects.find { |p| p.namespace.casecmp(ns) == 0 }
|
|
93
|
+
unless proj
|
|
94
|
+
list = dashboard.projects.map { |p| "#{p.namespace} (#{p.name})" }.join(', ')
|
|
95
|
+
abort "No project with namespace '#{ns}'.\n Available: #{list}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Same Configuration the server builds for this project - tracker, docs
|
|
99
|
+
# and decisions roots included - so `rake story:*` and the board always
|
|
100
|
+
# read and write the same directories (BT-179).
|
|
101
|
+
BaconTracker.instance_variable_set(:@config, dashboard.config_for(proj))
|
|
102
|
+
|
|
103
|
+
install
|
|
104
|
+
install_dashboard_server_task(dashboard_path) # install's copy has no path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.install_version_tasks
|
|
108
|
+
version_file = File.expand_path('../bacon_tracker/version.rb', __dir__)
|
|
109
|
+
|
|
110
|
+
# Same replace-don't-stack rule as install (BT-057) - a doubled bump
|
|
111
|
+
# task would bump the version twice per invocation.
|
|
112
|
+
Rake.application.tasks.each { |t| t.clear if t.name.start_with?('version:') }
|
|
113
|
+
|
|
114
|
+
namespace :version do
|
|
115
|
+
desc 'Print the current gem version'
|
|
116
|
+
task :current do
|
|
117
|
+
require 'bacon_tracker/version'
|
|
118
|
+
puts BaconTracker::VERSION
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
%w[patch minor major].each do |level|
|
|
122
|
+
desc "Bump #{level} version in lib/bacon_tracker/version.rb"
|
|
123
|
+
task level do
|
|
124
|
+
content = File.read(version_file)
|
|
125
|
+
current = content[/VERSION = "(\d+\.\d+\.\d+)"/, 1]
|
|
126
|
+
abort "Could not read VERSION from #{version_file}" unless current
|
|
127
|
+
parts = current.split('.').map(&:to_i)
|
|
128
|
+
case level
|
|
129
|
+
when 'patch' then parts[2] += 1
|
|
130
|
+
when 'minor' then parts[1] += 1; parts[2] = 0
|
|
131
|
+
when 'major' then parts[0] += 1; parts[1] = 0; parts[2] = 0
|
|
132
|
+
end
|
|
133
|
+
new_version = parts.join('.')
|
|
134
|
+
File.write(version_file, content.sub(/VERSION = "#{Regexp.escape(current)}"/, %(VERSION = "#{new_version}")))
|
|
135
|
+
puts "#{current} → #{new_version}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
desc 'Tag HEAD as the current version and push the tag'
|
|
140
|
+
task :release do
|
|
141
|
+
require 'bacon_tracker/version'
|
|
142
|
+
tag = "v#{BaconTracker::VERSION}"
|
|
143
|
+
# Refuse to tag a dirty tree: BaconTracker::VERSION is read from the
|
|
144
|
+
# working copy, so an uncommitted `version:patch` would tag a HEAD
|
|
145
|
+
# whose committed version.rb still says the old number (BT-110).
|
|
146
|
+
unless `git status --porcelain`.strip.empty?
|
|
147
|
+
abort "Working tree is dirty - commit the version bump before releasing #{tag}."
|
|
148
|
+
end
|
|
149
|
+
system('git', 'tag', tag) || abort('git tag failed')
|
|
150
|
+
system('git', 'push', 'origin', tag) || abort('git push failed')
|
|
151
|
+
puts "Released #{tag}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.install
|
|
157
|
+
# Re-install replaces earlier story tasks - repeated installs used to
|
|
158
|
+
# stack duplicate actions (Rake appends on redefinition). The config is
|
|
159
|
+
# snapshotted so later configure calls can't retarget installed tasks.
|
|
160
|
+
Rake.application.tasks.each { |t| t.clear if t.name.start_with?('story:', 'decision:') }
|
|
161
|
+
core = BaconTracker::Core.new(BaconTracker.config.dup)
|
|
162
|
+
ns = core.config.namespace
|
|
163
|
+
|
|
164
|
+
namespace :decision do
|
|
165
|
+
desc "Create a proposed decision from the template: rake \"decision:new[My decision]\""
|
|
166
|
+
task :new, [:title] do |_, args|
|
|
167
|
+
path = core.create_decision(args[:title])
|
|
168
|
+
puts "#{File.basename(path, '.md')[/\A[A-Z][A-Z0-9]*-ADR-\d+/]} → #{path}"
|
|
169
|
+
rescue ArgumentError => e
|
|
170
|
+
abort e.message
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
desc "Accept a proposed decision: rake \"decision:accept[#{ns}-ADR-0001]\""
|
|
174
|
+
task :accept, [:id] do |_, args|
|
|
175
|
+
core.transition_decision(args[:id], 'accepted')
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
desc "Reject a proposed decision: rake \"decision:reject[#{ns}-ADR-0001]\""
|
|
179
|
+
task :reject, [:id] do |_, args|
|
|
180
|
+
core.transition_decision(args[:id], 'rejected')
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
desc "Deprecate an accepted decision: rake \"decision:deprecate[#{ns}-ADR-0001]\""
|
|
184
|
+
task :deprecate, [:id] do |_, args|
|
|
185
|
+
core.transition_decision(args[:id], 'deprecated')
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
desc "Supersede an accepted decision - the second argument is required: rake \"decision:supersede[#{ns}-ADR-0001,#{ns}-ADR-0002]\""
|
|
189
|
+
task :supersede, [:id, :by] do |_, args|
|
|
190
|
+
core.transition_decision(args[:id], 'superseded', superseded_by: args[:by])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
desc 'Check the decision records: frontmatter, status vs directory, ids, supersession, proposed.md'
|
|
194
|
+
task :lint do
|
|
195
|
+
unless core.config.decisions_root
|
|
196
|
+
puts 'No decisions_root configured - nothing to lint.'
|
|
197
|
+
next
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
findings = core.decision_findings
|
|
201
|
+
failures = findings.select { |f| f[:severity] == :failure }
|
|
202
|
+
warnings = findings.select { |f| f[:severity] == :warning }
|
|
203
|
+
|
|
204
|
+
if findings.empty?
|
|
205
|
+
puts "Decisions are clean - #{core.decisions.size} records."
|
|
206
|
+
next
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
failures.each { |f| puts " #{f[:kind]}: #{[f[:id], f[:message]].compact.join(' - ')}" }
|
|
210
|
+
warnings.each { |f| puts " warning #{f[:kind]}: #{[f[:id], f[:message]].compact.join(' - ')}" }
|
|
211
|
+
puts
|
|
212
|
+
puts "#{failures.size} failure(s), #{warnings.size} warning(s) over #{core.decisions.size} records."
|
|
213
|
+
|
|
214
|
+
# Severity split follows BT-ADR-0013: integrity fails the build,
|
|
215
|
+
# hygiene reports and exits clean.
|
|
216
|
+
abort 'Decision lint failed.' if failures.any?
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
namespace :story do
|
|
221
|
+
# Parse trailing field=value tokens (size/assignee/blocked_by/linked_to/title/body)
|
|
222
|
+
# into update_story kwargs, aborting cleanly on an unknown field. Shared
|
|
223
|
+
# by the create tasks and story:edit.
|
|
224
|
+
parse_fields = lambda do |extras|
|
|
225
|
+
core.edit_assignments(extras)
|
|
226
|
+
rescue ArgumentError => e
|
|
227
|
+
abort e.message
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
desc "Create a feature in features/1_icebox, optional fields: rake \"story:feature[My Title,size=M,assignee=AB]\""
|
|
231
|
+
task :feature, [:title] do |_, args|
|
|
232
|
+
abort "Usage: rake \"story:feature[My title[,size=M,assignee=AB]]\"" if args[:title].nil?
|
|
233
|
+
core.create('feature', args[:title], parse_fields.call(args.extras))
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
desc "Create a bug in bugs/1_icebox, optional fields: rake \"story:bug[My Title,size=S]\""
|
|
237
|
+
task :bug, [:title] do |_, args|
|
|
238
|
+
abort "Usage: rake \"story:bug[My title[,size=M,assignee=AB]]\"" if args[:title].nil?
|
|
239
|
+
core.create('bug', args[:title], parse_fields.call(args.extras))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
desc "Create a chore in chores/1_icebox, optional fields: rake \"story:chore[My Title,assignee=AB]\""
|
|
243
|
+
task :chore, [:title] do |_, args|
|
|
244
|
+
abort "Usage: rake \"story:chore[My title[,size=M,assignee=AB]]\"" if args[:title].nil?
|
|
245
|
+
core.create('chore', args[:title], parse_fields.call(args.extras))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
desc "Move a story from 1_icebox to 2_backlog and add to backlog.md (commit to it): rake \"story:commit[#{ns}-001]\""
|
|
249
|
+
task :commit, [:story_id] do |_, args|
|
|
250
|
+
id = args[:story_id]&.strip
|
|
251
|
+
abort "Usage: rake \"story:commit[#{ns}-001]\"" if id.nil?
|
|
252
|
+
core.commit(id)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
desc "Move a story from 2_backlog to 3_started (start work on it): rake \"story:start[#{ns}-001]\""
|
|
256
|
+
task :start, [:story_id] do |_, args|
|
|
257
|
+
id = args[:story_id]&.strip
|
|
258
|
+
abort "Usage: rake \"story:start[#{ns}-001]\"" if id.nil?
|
|
259
|
+
core.start(id)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
desc "Move a story to 4_done and remove from backlog.md: rake \"story:done[#{ns}-001]\""
|
|
263
|
+
task :done, [:story_id] do |_, args|
|
|
264
|
+
id = args[:story_id]&.strip
|
|
265
|
+
abort "Usage: rake \"story:done[#{ns}-001]\"" if id.nil?
|
|
266
|
+
core.done(id)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
desc "Set story fields (size/assignee/blocked_by/linked_to/title/body): rake \"story:edit[#{ns}-001,size=M,assignee=AB]\""
|
|
270
|
+
task :edit, [:story_id] do |_, args|
|
|
271
|
+
id = args[:story_id]&.strip
|
|
272
|
+
abort "Usage: rake \"story:edit[#{ns}-001,size=M,assignee=AB]\"" if id.nil?
|
|
273
|
+
kwargs = parse_fields.call(args.extras)
|
|
274
|
+
abort "No fields given. Example: rake \"story:edit[#{ns}-001,size=M]\"" if kwargs.empty?
|
|
275
|
+
warn "Note: body= replaces the entire body of #{id}." if kwargs.key?(:body)
|
|
276
|
+
begin
|
|
277
|
+
core.update_story(id, **kwargs)
|
|
278
|
+
puts "Updated #{id}: #{kwargs.keys.join(', ')}"
|
|
279
|
+
rescue ArgumentError => e
|
|
280
|
+
abort e.message
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
desc 'Print the top item in backlog.md'
|
|
285
|
+
task :next do
|
|
286
|
+
lines = core.backlog_lines
|
|
287
|
+
abort 'Backlog is empty.' if lines.empty?
|
|
288
|
+
puts lines.first.strip
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
desc 'Check backlog.md against 2_backlog story files for drift, flag duplicate IDs across stages, check blocked_by/linked_to, and verify .next-id'
|
|
292
|
+
task :lint do
|
|
293
|
+
listed = core.backlog_ids
|
|
294
|
+
on_disk = core.backlog_story_files.filter_map { |f| File.basename(f)[core.filename_id_pattern] }
|
|
295
|
+
phantom = listed - on_disk
|
|
296
|
+
unlisted = on_disk - listed
|
|
297
|
+
dupes = core.duplicate_id_stages
|
|
298
|
+
drift = core.status_drift
|
|
299
|
+
max_id = core.max_story_id
|
|
300
|
+
next_id = core.next_id_value
|
|
301
|
+
stale_id = next_id <= max_id
|
|
302
|
+
|
|
303
|
+
rel = core.relationship_findings
|
|
304
|
+
rel_warn, rel_err = rel.partition { |f| BaconTracker::Core::RELATIONSHIP_WARNINGS.include?(f[:kind]) }
|
|
305
|
+
|
|
306
|
+
# Flow signals (rel_warn) are reported but never fail the build: a
|
|
307
|
+
# started story waiting on a blocker is a legitimate state per
|
|
308
|
+
# 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
|
|
310
|
+
|
|
311
|
+
if !broken && rel_warn.empty?
|
|
312
|
+
puts "Backlog is clean - #{listed.size} stories."
|
|
313
|
+
else
|
|
314
|
+
# One list, two renderings. Each entry carries the text (identical
|
|
315
|
+
# to what the terminal has always printed) plus where it came from,
|
|
316
|
+
# so LINT_FORMAT=github can anchor an annotation to the offending
|
|
317
|
+
# file and line instead of burying it in a log.
|
|
318
|
+
findings = []
|
|
319
|
+
add = ->(text, path, line = nil, severity = :error) do
|
|
320
|
+
findings << { text: text, path: path, line: line, severity: severity }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
backlog = core.config.backlog_path
|
|
324
|
+
phantom.each do |id|
|
|
325
|
+
add.call("phantom: #{id} in backlog.md but no file in 2_backlog/",
|
|
326
|
+
backlog, BaconTracker::Tasks.line_matching(backlog, /\b#{Regexp.escape(id)}\b/))
|
|
327
|
+
end
|
|
328
|
+
unlisted.each do |id|
|
|
329
|
+
# Anchored to the story file, not backlog.md: the pull request
|
|
330
|
+
# that forgot the backlog line is the one that added this file,
|
|
331
|
+
# so this is the side that appears in the diff.
|
|
332
|
+
add.call("unlisted: #{id} has file in 2_backlog/ but missing from backlog.md",
|
|
333
|
+
core.backlog_story_files.find { |f| File.basename(f).start_with?(id) } || backlog)
|
|
334
|
+
end
|
|
335
|
+
dupes.each do |id, paths|
|
|
336
|
+
text = "duplicate: #{id} found in #{paths.map { |p| p.sub("#{core.config.tracker_root}/", '') }.join(' and ')}"
|
|
337
|
+
paths.each { |dup| add.call(text, dup) }
|
|
338
|
+
end
|
|
339
|
+
drift.each do |d|
|
|
340
|
+
path = d[:path]
|
|
341
|
+
add.call("status-drift: #{d[:id]} frontmatter says '#{d[:declared]}' but the file lives in the #{d[:actual]} stage dir",
|
|
342
|
+
path, BaconTracker::Tasks.line_matching(path, /^#?\s*status:/))
|
|
343
|
+
end
|
|
344
|
+
if stale_id
|
|
345
|
+
add.call("stale-id: .next-id is #{next_id} but the highest story is #{core.format_id(max_id)} - expected #{max_id + 1}",
|
|
346
|
+
core.config.next_id_path)
|
|
347
|
+
end
|
|
348
|
+
(rel_err + rel_warn).each do |f|
|
|
349
|
+
field = f[:kind] == :dangling ? f[:field] : 'blocked_by'
|
|
350
|
+
add.call(BaconTracker::Tasks.relationship_message(f), f[:path],
|
|
351
|
+
BaconTracker::Tasks.line_matching(f[:path], /^#?\s*#{field}:/),
|
|
352
|
+
BaconTracker::Core::RELATIONSHIP_WARNINGS.include?(f[:kind]) ? :notice : :error)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
if ENV['LINT_FORMAT'] == 'github'
|
|
356
|
+
# Duplicates share one text across two files; each still gets its
|
|
357
|
+
# own annotation, but say it once per file, not once per pairing.
|
|
358
|
+
findings.uniq { |f| [f[:text], f[:path]] }
|
|
359
|
+
.each { |f| puts BaconTracker::Tasks.github_annotation(f) }
|
|
360
|
+
else
|
|
361
|
+
findings.uniq { |f| f[:text] }.each { |f| warn " #{f[:text]}" }
|
|
362
|
+
end
|
|
363
|
+
puts 'No integrity problems - the note above is a flow signal, not a failure.' if !broken && rel_warn.any?
|
|
364
|
+
exit 1 if broken
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
desc 'Start the tracker board on http://localhost:4567'
|
|
369
|
+
task :server do
|
|
370
|
+
require 'bacon_tracker/server'
|
|
371
|
+
port = BaconTracker::Tasks.env_port
|
|
372
|
+
puts "Bacon Tracker running at http://localhost:#{port}"
|
|
373
|
+
puts 'Ctrl-C to stop.'
|
|
374
|
+
BaconTracker::Server.boot(core.config).run!(port: port, bind: 'localhost', quiet: true)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
desc "Assign #{ns} IDs to all existing stories (oldest git commit first)"
|
|
378
|
+
task :migrate do
|
|
379
|
+
first_commits = core.git_first_commit_times
|
|
380
|
+
all_files = core.story_dirs.flat_map do |d|
|
|
381
|
+
core.safe_glob(d[:path], '*.{md,feature}').filter_map do |f|
|
|
382
|
+
next if File.basename(f).start_with?('_')
|
|
383
|
+
next if core.already_migrated?(f)
|
|
384
|
+
|
|
385
|
+
content = File.read(f, encoding: 'utf-8')
|
|
386
|
+
# Skip a file that already carries a frontmatter block (but no ID
|
|
387
|
+
# in its name) - prepending again would double the frontmatter and
|
|
388
|
+
# bury the old block in the body (BT-105).
|
|
389
|
+
next if content.start_with?("---\n") || content.match?(/\A# \w+:/)
|
|
390
|
+
|
|
391
|
+
relative = f.sub("#{core.config.tracker_root}/", '')
|
|
392
|
+
d.merge(file: f, content: content, ts: first_commits[relative] || File.mtime(f).to_i)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
all_files.sort_by! { |f| [f[:ts], f[:file]] }
|
|
397
|
+
|
|
398
|
+
if all_files.empty?
|
|
399
|
+
puts 'Nothing to migrate - all stories already have IDs.'
|
|
400
|
+
next
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
all_files.each do |info|
|
|
404
|
+
path = info[:file]
|
|
405
|
+
ext = File.extname(path)
|
|
406
|
+
base = File.basename(path, ext)
|
|
407
|
+
status = BaconTracker::STATUS_MAP[info[:stage]]
|
|
408
|
+
id = core.format_id(core.consume_id)
|
|
409
|
+
|
|
410
|
+
new_name = "#{id}-#{base}#{ext}"
|
|
411
|
+
new_path = File.join(File.dirname(path), new_name)
|
|
412
|
+
|
|
413
|
+
new_content = if ext == '.feature'
|
|
414
|
+
core.frontmatter_feature(id, info[:type], status) + info[:content]
|
|
415
|
+
else
|
|
416
|
+
core.frontmatter_md(id, info[:type], status) + info[:content]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
File.write(new_path, new_content)
|
|
420
|
+
File.delete(path) unless path == new_path
|
|
421
|
+
puts " #{File.basename(path)} -> #{new_name}"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Renamed 2_backlog files got new IDs - reconcile backlog.md so they're
|
|
425
|
+
# listed (lint reported them 'unlisted' before) instead of leaving the
|
|
426
|
+
# backlog inconsistent (BT-105).
|
|
427
|
+
core.heal_backlog!
|
|
428
|
+
puts "\nMigrated #{all_files.size} stories. Next ID: #{core.format_id(core.next_id_value)}"
|
|
429
|
+
puts 'backlog.md reconciled - review it for any pre-migration lines without an ID.'
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
install_dashboard_server_task
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# NS-independent: the dashboard server serves every project, so it is
|
|
437
|
+
# installed even when no NS is set.
|
|
438
|
+
# `default_path` is the dashboard the Rakefile names; $DASHBOARD still wins,
|
|
439
|
+
# then ./dashboard.md. Without it the task only worked from the Rakefile's
|
|
440
|
+
# own directory (BT-179).
|
|
441
|
+
def self.install_dashboard_server_task(default_path = nil)
|
|
442
|
+
Rake.application.tasks.each { |t| t.clear if t.name == 'story:dashboard_server' }
|
|
443
|
+
|
|
444
|
+
namespace :story do
|
|
445
|
+
desc 'Start the centralized dashboard server (reads dashboard.md): rake story:dashboard_server'
|
|
446
|
+
task :dashboard_server do
|
|
447
|
+
require 'bacon_tracker/server'
|
|
448
|
+
require 'bacon_tracker/dashboard'
|
|
449
|
+
|
|
450
|
+
dashboard_path = ENV['DASHBOARD'] || default_path || File.join(Dir.pwd, 'dashboard.md')
|
|
451
|
+
abort "dashboard.md not found at #{dashboard_path}\n\nCreate it with:\n\n ## My Project\n path: /path/to/my-project # holds tracker/ (and docs/ if you have one)\n namespace: MYP\n" unless File.exist?(dashboard_path)
|
|
452
|
+
|
|
453
|
+
dashboard = BaconTracker::Dashboard.new(dashboard_path)
|
|
454
|
+
abort "No projects found in #{dashboard_path}" if dashboard.projects.empty?
|
|
455
|
+
|
|
456
|
+
port = BaconTracker::Tasks.env_port
|
|
457
|
+
puts "Bacon Dashboard running at http://localhost:#{port}"
|
|
458
|
+
puts "Projects: #{dashboard.projects.map(&:name).join(', ')}"
|
|
459
|
+
puts 'Ctrl-C to stop.'
|
|
460
|
+
BaconTracker::Server.boot_dashboard(dashboard).run!(port: port, bind: 'localhost', quiet: true)
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* Structural board CSS shared by the story board and the decisions board
|
|
2
|
+
(BT-137/BT-144). Colour rules stay per-view: [data-stage] in index,
|
|
3
|
+
[data-status] in decisions. */
|
|
4
|
+
.board {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex: 1;
|
|
7
|
+
gap: 8px;
|
|
8
|
+
padding: 10px;
|
|
9
|
+
overflow: hidden;
|
|
10
|
+
min-height: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* ── Columns ─────────────────────────────────────────────────── */
|
|
14
|
+
|
|
15
|
+
.column {
|
|
16
|
+
flex: 1;
|
|
17
|
+
border-radius: var(--radius-card);
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
min-width: 0;
|
|
21
|
+
border: 1px solid transparent;
|
|
22
|
+
transition: border-color 0.15s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.column.collapsed {
|
|
26
|
+
flex: 0 0 auto;
|
|
27
|
+
width: 108px;
|
|
28
|
+
}
|
|
29
|
+
.column.collapsed .cards,
|
|
30
|
+
.column.collapsed .pagination,
|
|
31
|
+
.column.collapsed .create-form {
|
|
32
|
+
display: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.col-header {
|
|
36
|
+
padding: 10px 12px 6px;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: space-between;
|
|
40
|
+
flex-shrink: 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.col-label {
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-direction: column;
|
|
46
|
+
gap: 2px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.col-header h2 {
|
|
50
|
+
font-size: 0.65rem;
|
|
51
|
+
font-weight: 400;
|
|
52
|
+
letter-spacing: 0.12em;
|
|
53
|
+
text-transform: uppercase;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.col-sublabel {
|
|
57
|
+
font-size: 0.5rem;
|
|
58
|
+
letter-spacing: 0.09em;
|
|
59
|
+
text-transform: uppercase;
|
|
60
|
+
opacity: 0.5;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.cards {
|
|
64
|
+
flex: 1;
|
|
65
|
+
overflow-y: auto;
|
|
66
|
+
padding: 0 8px 8px;
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
gap: 6px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.card {
|
|
73
|
+
background: #fff;
|
|
74
|
+
border-radius: var(--radius-sm);
|
|
75
|
+
border: 1px solid rgba(195, 195, 195, 0.4);
|
|
76
|
+
border-left: 3px solid transparent;
|
|
77
|
+
cursor: grab;
|
|
78
|
+
transition: box-shadow 0.15s;
|
|
79
|
+
user-select: none;
|
|
80
|
+
}
|
|
81
|
+
.card:hover {
|
|
82
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.pagination {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
gap: 8px;
|
|
90
|
+
padding: 7px 8px;
|
|
91
|
+
font-size: 0.68rem;
|
|
92
|
+
color: var(--fg-muted);
|
|
93
|
+
flex-shrink: 0;
|
|
94
|
+
}
|
|
95
|
+
.pagination button {
|
|
96
|
+
background: rgba(255, 255, 255, 0.6);
|
|
97
|
+
border: 1px solid var(--border);
|
|
98
|
+
color: var(--fg);
|
|
99
|
+
padding: 2px 8px;
|
|
100
|
+
border-radius: 4px;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
font-family: var(--font-mono);
|
|
103
|
+
font-size: 0.63rem;
|
|
104
|
+
}
|
|
105
|
+
.pagination button:hover {
|
|
106
|
+
border-color: var(--accent);
|
|
107
|
+
color: var(--accent);
|
|
108
|
+
}
|
|
109
|
+
/* Dark theme for the shared components - these lived page-locally in
|
|
110
|
+
index.erb, which is why the decisions board rendered white cards on a
|
|
111
|
+
dark board (BT-177). */
|
|
112
|
+
[data-theme="dark"] .card {
|
|
113
|
+
background: #2a1d26;
|
|
114
|
+
border-color: rgba(255, 255, 255, 0.07);
|
|
115
|
+
}
|
|
116
|
+
[data-theme="dark"] .card:hover {
|
|
117
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
|
118
|
+
}
|
|
119
|
+
[data-theme="dark"] .pagination button {
|
|
120
|
+
background: rgba(255, 255, 255, 0.07);
|
|
121
|
+
color: var(--fg);
|
|
122
|
+
}
|