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,476 @@
|
|
|
1
|
+
require 'sinatra/base'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'bacon_tracker/launcher'
|
|
5
|
+
|
|
6
|
+
module BaconTracker
|
|
7
|
+
class Server < Sinatra::Base
|
|
8
|
+
# Cap request bodies - stories are tiny; anything larger is a mistake or an
|
|
9
|
+
# attempt to exhaust memory via request.body.read (BT-116).
|
|
10
|
+
MAX_BODY_BYTES = 1_000_000
|
|
11
|
+
|
|
12
|
+
# Origins allowed to make state-changing (POST/PUT/DELETE) requests. The
|
|
13
|
+
# board is same-origin on localhost; this blocks cross-site CSRF (BT-085).
|
|
14
|
+
PERMITTED_ORIGIN_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
15
|
+
|
|
16
|
+
# The client is served as static assets (extracted from the inline templates
|
|
17
|
+
# so it can be linted, syntax-checked, and unit-tested - BT-112). Read once
|
|
18
|
+
# at load; they never change at runtime.
|
|
19
|
+
ASSETS_DIR = File.expand_path('assets', __dir__).freeze
|
|
20
|
+
APP_JS = File.read(File.join(ASSETS_DIR, 'app.js')).freeze
|
|
21
|
+
THEME_JS = File.read(File.join(ASSETS_DIR, 'theme.js')).freeze
|
|
22
|
+
LOGIC_JS = File.read(File.join(ASSETS_DIR, 'logic.js')).freeze
|
|
23
|
+
DOCS_JS = File.read(File.join(ASSETS_DIR, 'docs.js')).freeze
|
|
24
|
+
DECISIONS_JS = File.read(File.join(ASSETS_DIR, 'decisions.js')).freeze
|
|
25
|
+
|
|
26
|
+
def self.boot(config)
|
|
27
|
+
@mode = :single
|
|
28
|
+
@bt_core = Core.new(config)
|
|
29
|
+
@dashboard = nil
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.boot_dashboard(dashboard)
|
|
34
|
+
@mode = :dashboard
|
|
35
|
+
@dashboard = dashboard
|
|
36
|
+
@bt_core = nil
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Route patterns are registered for both single mode and the
|
|
41
|
+
# dashboard-mode /projects/:slug prefix.
|
|
42
|
+
def self.scoped(path)
|
|
43
|
+
[path, "/projects/:slug#{path}"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.bt_core = @bt_core
|
|
47
|
+
def self.bt_dashboard = @dashboard
|
|
48
|
+
def self.dashboard_mode? = @mode == :dashboard
|
|
49
|
+
|
|
50
|
+
configure do
|
|
51
|
+
# Templates are files in views/ (BT-137) - __END__ had grown to a
|
|
52
|
+
# thousand lines, and every docs surface adds more. The gemspec must ship
|
|
53
|
+
# *.erb, or the views 404 from the registry while working fine in a path
|
|
54
|
+
# checkout (the troubleshooting.md failure class).
|
|
55
|
+
set :views, File.expand_path('views', __dir__)
|
|
56
|
+
disable :logging
|
|
57
|
+
set :host_authorization, permitted_hosts: %w[localhost 127.0.0.1 ::1]
|
|
58
|
+
# Compile templates once. This also used to guard a real corruption bug:
|
|
59
|
+
# erubi escapes its input in place, and with inline __END__ templates the
|
|
60
|
+
# input WAS the stored template, so every dev-mode recompile corrupted it
|
|
61
|
+
# a little more (see BT-112 and the old transform_values! "fresh copy"
|
|
62
|
+
# belt, removed with the inline registry in BT-137). A file-backed view is
|
|
63
|
+
# re-read from disk per compile, so that failure mode is structurally gone
|
|
64
|
+
# - this is now just the cheap setting.
|
|
65
|
+
set :reload_templates, false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Security headers on every response: forbid framing (clickjacking) and
|
|
69
|
+
# content-sniffing (BT-116). For state-changing methods, reject a request
|
|
70
|
+
# carrying a cross-origin Origin header - a page on another site can fire a
|
|
71
|
+
# CORS-simple POST at localhost, but the browser always attaches its Origin,
|
|
72
|
+
# so this blocks CSRF while leaving same-origin and non-browser (no Origin)
|
|
73
|
+
# callers untouched (BT-085).
|
|
74
|
+
before do
|
|
75
|
+
headers 'X-Frame-Options' => 'DENY',
|
|
76
|
+
'X-Content-Type-Options' => 'nosniff',
|
|
77
|
+
'Content-Security-Policy' => "frame-ancestors 'none'"
|
|
78
|
+
if %w[POST PUT DELETE].include?(request.request_method)
|
|
79
|
+
origin = request.env['HTTP_ORIGIN']
|
|
80
|
+
halt 403, json({ error: 'cross-origin request rejected' }) if origin && !permitted_origin?(origin)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
error do
|
|
85
|
+
e = env['sinatra.error']
|
|
86
|
+
warn "[BaconTracker] #{e.class}: #{e.message}"
|
|
87
|
+
warn e.backtrace.first(10).join("\n")
|
|
88
|
+
content_type :json
|
|
89
|
+
status 500
|
|
90
|
+
{ error: 'Internal Server Error' }.to_json
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
helpers do
|
|
94
|
+
def resolve_core
|
|
95
|
+
if self.class.dashboard_mode?
|
|
96
|
+
self.class.bt_dashboard&.project_core(params[:slug]) || halt(404, json({ error: 'Project not found' }))
|
|
97
|
+
else
|
|
98
|
+
halt(404, json({ error: 'Project not found' })) if request.path_info.start_with?('/projects/')
|
|
99
|
+
self.class.bt_core || halt(503, json({ error: 'Not in single-project mode' }))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# An Origin header is same-origin on localhost. rescue → nil host → rejected.
|
|
104
|
+
def permitted_origin?(origin)
|
|
105
|
+
host = begin
|
|
106
|
+
URI(origin).host
|
|
107
|
+
rescue URI::InvalidURIError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
PERMITTED_ORIGIN_HOSTS.include?(host)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def h(text)
|
|
114
|
+
Rack::Utils.escape_html(text.to_s)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def json_body
|
|
118
|
+
len = request.content_length
|
|
119
|
+
halt 413, json({ error: 'request body too large' }) if len && len.to_i > MAX_BODY_BYTES
|
|
120
|
+
raw = request.body.read(MAX_BODY_BYTES + 1) || ''
|
|
121
|
+
halt 413, json({ error: 'request body too large' }) if raw.bytesize > MAX_BODY_BYTES
|
|
122
|
+
|
|
123
|
+
data = JSON.parse(raw)
|
|
124
|
+
halt 400, json({ error: 'JSON body must be an object' }) unless data.is_a?(Hash)
|
|
125
|
+
data
|
|
126
|
+
rescue JSON::ParserError
|
|
127
|
+
halt 400, json({ error: 'Invalid JSON body' })
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def json(obj)
|
|
131
|
+
content_type :json
|
|
132
|
+
obj.to_json
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Internal-only story keys never serialized to clients.
|
|
136
|
+
HIDDEN_KEYS = %i[dir declared_status].freeze
|
|
137
|
+
|
|
138
|
+
def public_story(story)
|
|
139
|
+
story.reject { |k, _| HIDDEN_KEYS.include?(k) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def stories_json(c)
|
|
143
|
+
# with_reverse_links adds the derived `blocks`/`linked_from` ends so the
|
|
144
|
+
# board can show both sides of a relationship the frontmatter only stores
|
|
145
|
+
# once (a file-based tracker can't write the far story).
|
|
146
|
+
stories = c.with_reverse_links(c.all_stories)
|
|
147
|
+
# Decisions citing each story (BT-148) - the reverse of the record's
|
|
148
|
+
# stories: key, derived here for the same reason blocks/linked_from
|
|
149
|
+
# are: a file-based tracker stores each relation on one side only.
|
|
150
|
+
cited = Hash.new { |h, k| h[k] = [] }
|
|
151
|
+
if c.config.decisions_root
|
|
152
|
+
c.decisions_board.each { |r| r[:stories].each { |sid| cited[sid] << r[:id] } }
|
|
153
|
+
end
|
|
154
|
+
stories = stories.map { |st| st.merge(decisions: cited[st[:id]]) }
|
|
155
|
+
by_stage = stories.group_by { |s| s[:stage] }
|
|
156
|
+
# id => backlog position, so the backlog sort is O(1) per lookup rather
|
|
157
|
+
# than order.index's O(n) scan inside sort_by (O(n^2) on the column) (BT-094).
|
|
158
|
+
order_pos = c.backlog_ids.each_with_index.to_h
|
|
159
|
+
|
|
160
|
+
# Sort on the trailing number, not the first digit run - a namespace
|
|
161
|
+
# that itself contains a digit (e.g. "B2B") would otherwise key every
|
|
162
|
+
# story on that digit and collapse the column ordering (BT-104).
|
|
163
|
+
by_number = ->(s) { (s[:id][/(\d+)\z/] || '0').to_i }
|
|
164
|
+
icebox = (by_stage['1_icebox'] || []).sort_by(&by_number)
|
|
165
|
+
backlog = (by_stage['2_backlog'] || []).sort_by { |s| order_pos[s[:id]] || 9999 }
|
|
166
|
+
started = (by_stage['3_started'] || []).sort_by(&by_number)
|
|
167
|
+
done = (by_stage['4_done'] || []).sort_by(&by_number).reverse
|
|
168
|
+
|
|
169
|
+
json({
|
|
170
|
+
meta: { tracker_root: c.config.tracker_root, backlog_path: c.config.backlog_path },
|
|
171
|
+
icebox: icebox.map { |s| public_story(s) },
|
|
172
|
+
backlog: backlog.map { |s| public_story(s) },
|
|
173
|
+
started: started.map { |s| public_story(s) },
|
|
174
|
+
done: done.map { |s| public_story(s) }
|
|
175
|
+
})
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# ── Static client assets (served globally; the JS uses window.BT_API_BASE
|
|
180
|
+
# for API calls, so one copy works in both single and dashboard mode) ──
|
|
181
|
+
get '/decisions.js' do
|
|
182
|
+
content_type 'application/javascript'
|
|
183
|
+
DECISIONS_JS
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
get '/docs.js' do
|
|
187
|
+
content_type 'application/javascript'
|
|
188
|
+
DOCS_JS
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
get '/app.js' do
|
|
192
|
+
content_type 'application/javascript'
|
|
193
|
+
APP_JS
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
get '/theme.js' do
|
|
197
|
+
content_type 'application/javascript'
|
|
198
|
+
THEME_JS
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
get '/logic.js' do
|
|
202
|
+
content_type 'application/javascript'
|
|
203
|
+
LOGIC_JS
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ── Dashboard index ────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
get '/' do
|
|
209
|
+
if self.class.dashboard_mode?
|
|
210
|
+
@project_stats = self.class.bt_dashboard.project_stats
|
|
211
|
+
erb :dashboard
|
|
212
|
+
else
|
|
213
|
+
@namespace = resolve_core.config.namespace
|
|
214
|
+
@api_base = ''
|
|
215
|
+
erb :index
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# ── Per-project board (dashboard mode) ───────────────────────────────────
|
|
220
|
+
|
|
221
|
+
get '/projects/:slug' do
|
|
222
|
+
slug = params[:slug]
|
|
223
|
+
proj = self.class.bt_dashboard&.projects&.find { |p| p.slug == slug }
|
|
224
|
+
halt 404, 'Project not found' unless proj
|
|
225
|
+
@namespace = proj.namespace
|
|
226
|
+
@project_name = proj.name
|
|
227
|
+
@api_base = "/projects/#{slug}"
|
|
228
|
+
@show_back = true
|
|
229
|
+
erb :index
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# ── Stats (consumed by BaconTrackerMenu) ─────────────────────────────────
|
|
233
|
+
|
|
234
|
+
get '/api/stats' do
|
|
235
|
+
if self.class.dashboard_mode?
|
|
236
|
+
json(self.class.bt_dashboard.project_stats)
|
|
237
|
+
else
|
|
238
|
+
c = resolve_core
|
|
239
|
+
s = c.stats
|
|
240
|
+
# slug is nil in single mode (there is no /projects/:slug board) but
|
|
241
|
+
# the key is always present so both modes share one response shape.
|
|
242
|
+
json([s.merge(name: c.config.namespace, namespace: c.config.namespace, slug: nil)])
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# ── Reveal in the file manager ───────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
scoped('/api/reveal').each do |pat|
|
|
249
|
+
post pat do
|
|
250
|
+
raw = json_body['path'].to_s
|
|
251
|
+
halt 400, json({ error: 'path required' }) if raw.empty?
|
|
252
|
+
path = File.expand_path(raw)
|
|
253
|
+
# Scope to THIS request's project only - resolve_core honors :slug in
|
|
254
|
+
# dashboard mode, so one project's reveal endpoint can't reach another
|
|
255
|
+
# project's files (BT-085). A project now has more than one root
|
|
256
|
+
# (BT-ADR-0016), so the test is "inside ANY of this project's roots" -
|
|
257
|
+
# widened, not loosened. The separator matters: without it, a root of
|
|
258
|
+
# /a/tracker would also admit /a/tracker-evil.
|
|
259
|
+
roots = resolve_core.config.roots
|
|
260
|
+
inside = roots.any? { |r| path == r || path.start_with?(r + File::SEPARATOR) }
|
|
261
|
+
halt 400, json({ error: 'path is outside tracked directories' }) unless inside
|
|
262
|
+
target = File.exist?(path) ? path : File.dirname(path)
|
|
263
|
+
begin
|
|
264
|
+
Launcher.run(Launcher.reveal_argv(target))
|
|
265
|
+
rescue Launcher::Unavailable => e
|
|
266
|
+
halt 501, json({ error: e.message })
|
|
267
|
+
end
|
|
268
|
+
json({ ok: true })
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# ── Stories API ───────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
scoped('/api/stories').each do |pat|
|
|
275
|
+
get pat do
|
|
276
|
+
stories_json(resolve_core)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
post pat do
|
|
280
|
+
data = json_body
|
|
281
|
+
story = resolve_core.create_story(data['type'], data['title'],
|
|
282
|
+
stage: data['stage'] || '1_icebox',
|
|
283
|
+
size: data['size'])
|
|
284
|
+
status 201
|
|
285
|
+
json(public_story(story)) # full Story incl. path, matching GET/PUT (BT-089)
|
|
286
|
+
rescue ArgumentError => e
|
|
287
|
+
status 400; json({ error: e.message })
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
scoped('/api/stories/backlog/order').each do |pat|
|
|
292
|
+
put pat do
|
|
293
|
+
resolve_core.backlog_reorder(json_body['ids'])
|
|
294
|
+
json({ ok: true })
|
|
295
|
+
rescue ArgumentError, TypeError => e
|
|
296
|
+
status 400; json({ error: e.message })
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
scoped('/api/stories/:id/stage').each do |pat|
|
|
301
|
+
put pat do
|
|
302
|
+
resolve_core.set_stage(params[:id], json_body['stage'])
|
|
303
|
+
json({ ok: true })
|
|
304
|
+
rescue ArgumentError => e
|
|
305
|
+
status 400; json({ error: e.message })
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# ── Docs surface (BT-138; the browser itself is BT-139) ─────────────────
|
|
310
|
+
scoped('/docs').each do |pat|
|
|
311
|
+
get pat do
|
|
312
|
+
core = resolve_core
|
|
313
|
+
halt 404, 'no docs surface' unless core.config.docs_root && Dir.exist?(core.config.docs_root)
|
|
314
|
+
|
|
315
|
+
@doc_stats = core.docs_stats
|
|
316
|
+
@doc_title = if self.class.dashboard_mode?
|
|
317
|
+
self.class.bt_dashboard.projects.find { |p| p.slug == params[:slug] }&.name
|
|
318
|
+
end || core.config.namespace
|
|
319
|
+
@doc_slug = params[:slug]
|
|
320
|
+
erb :docs
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
scoped('/api/docs/search').each do |pat|
|
|
325
|
+
get pat do
|
|
326
|
+
json resolve_core.docs_search(params[:q])
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
scoped('/api/docs/recent').each do |pat|
|
|
331
|
+
get pat do
|
|
332
|
+
json resolve_core.docs_recent
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
scoped('/api/docs/backlinks').each do |pat|
|
|
337
|
+
get pat do
|
|
338
|
+
json resolve_core.docs_backlinks(params[:path].to_s)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
scoped('/api/docs/front').each do |pat|
|
|
343
|
+
get pat do
|
|
344
|
+
json resolve_core.project_front
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
scoped('/api/docs/tree').each do |pat|
|
|
349
|
+
get pat do
|
|
350
|
+
json resolve_core.docs_tree
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
scoped('/api/docs/page').each do |pat|
|
|
355
|
+
get pat do
|
|
356
|
+
json resolve_core.render_page(params[:path])
|
|
357
|
+
rescue ArgumentError => e
|
|
358
|
+
status 400
|
|
359
|
+
json({ error: e.message })
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Act on the real file (BT-142, BT-143). Same scope check as the page
|
|
364
|
+
# read; the launch mirrors /api/reveal - fire and detach, and a missing
|
|
365
|
+
# launcher is a 501 with the reason rather than a false ok.
|
|
366
|
+
%w[editor reveal].each do |action|
|
|
367
|
+
scoped("/api/docs/#{action}").each do |pat|
|
|
368
|
+
post pat do
|
|
369
|
+
target = resolve_core.docs_file!(json_body['path'].to_s, allow_dir: action == 'reveal')
|
|
370
|
+
argv = action == 'editor' ? Launcher.open_argv(target) : Launcher.reveal_argv(target)
|
|
371
|
+
begin
|
|
372
|
+
Launcher.run(argv)
|
|
373
|
+
rescue Launcher::Unavailable => e
|
|
374
|
+
halt 501, json({ error: e.message })
|
|
375
|
+
end
|
|
376
|
+
json({ ok: true })
|
|
377
|
+
rescue ArgumentError => e
|
|
378
|
+
status 400
|
|
379
|
+
json({ error: e.message })
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
scoped('/api/decisions/proposed/order').each do |pat|
|
|
385
|
+
put pat do
|
|
386
|
+
resolve_core.proposed_reorder(json_body['ids'])
|
|
387
|
+
json({ ok: true })
|
|
388
|
+
rescue ArgumentError => e
|
|
389
|
+
status 400
|
|
390
|
+
json({ error: e.message })
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
scoped('/api/decisions').each do |pat|
|
|
395
|
+
get pat do
|
|
396
|
+
json resolve_core.decisions_board
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
post pat do
|
|
400
|
+
path = resolve_core.create_decision(json_body['title'])
|
|
401
|
+
json({ ok: true, path: File.basename(path) })
|
|
402
|
+
rescue ArgumentError => e
|
|
403
|
+
status 400
|
|
404
|
+
json({ error: e.message })
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
scoped('/docs/decisions').each do |pat|
|
|
409
|
+
get pat do
|
|
410
|
+
core = resolve_core
|
|
411
|
+
halt 404, 'no decisions' unless core.config.decisions_root && Dir.exist?(core.config.decisions_root)
|
|
412
|
+
|
|
413
|
+
@doc_title = if self.class.dashboard_mode?
|
|
414
|
+
self.class.bt_dashboard.projects.find { |p| p.slug == params[:slug] }&.name
|
|
415
|
+
end || core.config.namespace
|
|
416
|
+
@doc_slug = params[:slug]
|
|
417
|
+
erb :decisions
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# ── Decisions API (BT-135 / BT-ADR-0017) ─────────────────────────────────
|
|
422
|
+
# One transition endpoint mirroring the stage route. Validation is the
|
|
423
|
+
# primitive's: a missing or unresolvable supersede target is a 400, never a
|
|
424
|
+
# 200 with a partial write.
|
|
425
|
+
scoped('/api/decisions/:id/status').each do |pat|
|
|
426
|
+
put pat do
|
|
427
|
+
body = json_body
|
|
428
|
+
begin
|
|
429
|
+
result = resolve_core.set_status(params[:id], body['status'].to_s,
|
|
430
|
+
superseded_by: body['superseded_by'])
|
|
431
|
+
json result
|
|
432
|
+
rescue ArgumentError => e
|
|
433
|
+
halt 400, json({ error: e.message })
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
scoped('/api/stories/:id/subtasks').each do |pat|
|
|
439
|
+
put pat do
|
|
440
|
+
data = json_body
|
|
441
|
+
counts = resolve_core.toggle_subtask(params[:id], data['index'], data['done'])
|
|
442
|
+
json({ ok: true, subtasks: counts })
|
|
443
|
+
rescue ArgumentError => e
|
|
444
|
+
status 400; json({ error: e.message })
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
scoped('/api/stories/:id').each do |pat|
|
|
449
|
+
put pat do
|
|
450
|
+
data = json_body
|
|
451
|
+
core = resolve_core
|
|
452
|
+
core.update_story(params[:id], title: data['title'], body: data['body'],
|
|
453
|
+
size: data['size'], blocked_by: data['blocked_by'],
|
|
454
|
+
linked_to: data['linked_to'], assignee: data['assignee'])
|
|
455
|
+
# Return the re-parsed story so the client gets fresh derived fields
|
|
456
|
+
# (subtasks, subtask_lines, path after a rename) without a refetch. The
|
|
457
|
+
# re-read runs after update_story's lock released, so guard against the
|
|
458
|
+
# story having been deleted/renamed out from under us - a nil here would
|
|
459
|
+
# otherwise NoMethodError into a 500 instead of a clean 404 (BT-080).
|
|
460
|
+
result = core.find_story(params[:id])
|
|
461
|
+
story = result && core.parse_story_file(result[:file], result)
|
|
462
|
+
halt 404, json({ error: "Story #{params[:id]} not found." }) unless story
|
|
463
|
+
json(public_story(story))
|
|
464
|
+
rescue ArgumentError => e
|
|
465
|
+
status 400; json({ error: e.message })
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
delete pat do
|
|
469
|
+
resolve_core.delete_story(params[:id])
|
|
470
|
+
json({ ok: true })
|
|
471
|
+
rescue ArgumentError => e
|
|
472
|
+
status 400; json({ error: e.message })
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|