railbow 0.4.0 → 0.6.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.
@@ -0,0 +1,562 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../config"
5
+ require_relative "../formatters/base"
6
+ require_relative "../migration_parser"
7
+ require_relative "../params"
8
+ require_relative "../table"
9
+ require_relative "../calendar"
10
+ require_relative "git_data"
11
+ require_relative "ghosts"
12
+
13
+ module Railbow
14
+ module Status
15
+ # One migrations directory's worth of db:migrate:status.
16
+ #
17
+ # Usually that is one database. Databases sharing a migrations_paths (the
18
+ # shape horizontal sharding takes) see the same files and differ only in
19
+ # what they have applied, so they merge into a single section carrying a
20
+ # status per database.
21
+ #
22
+ # Rows are built lazily, on first read, because merging has to finish
23
+ # before the status cells can be built. A section never prints: Printer
24
+ # owns output, which is what lets sections share column widths.
25
+ class Section
26
+ # Index of the Date column, where calendar week ticks are drawn.
27
+ TICK_COL = 2
28
+
29
+ # Width the Migration Name column is capped at once anything competes
30
+ # with it for horizontal space (tags, authors, table names).
31
+ NAME_COL_WIDTH = 60
32
+
33
+ # Floor the width budget may shrink the name column down to on a narrow
34
+ # terminal, before it starts dropping columns instead.
35
+ NAME_COL_MIN_WIDTH = 24
36
+
37
+ NO_FILE = "NO FILE"
38
+
39
+ # A version one database in a shard group has and another does not.
40
+ ABSENT = "·"
41
+
42
+ attr_reader :databases, :migrations_key, :state, :hidden_count, :total_count,
43
+ :since_value, :entries, :ghosts
44
+
45
+ # True when the time window left too few rows and the floor topped the
46
+ # section back up, which is worth saying out loud: the table then shows
47
+ # migrations from outside the window it advertises.
48
+ def floor_applied?
49
+ @floor_applied == true
50
+ end
51
+
52
+ def initialize(pool)
53
+ @formatter = Formatters::Base.new
54
+ @databases = [{name: pool.db_config.name, database: pool.db_config.database}]
55
+ @migrations_key = migrations_key_for(pool)
56
+ @entries = {}
57
+ @ghosts = {}
58
+ @hidden_count = 0
59
+ @total_count = 0
60
+ @window_count = 0
61
+ @floor_applied = false
62
+ @since_value = Railbow::Params.since
63
+
64
+ collect(pool)
65
+ end
66
+
67
+ # The database this section is named after. Multi-database sections use
68
+ # #databases; this is the single-database convenience.
69
+ def database
70
+ databases.first[:database]
71
+ end
72
+
73
+ def db_name
74
+ databases.first[:name]
75
+ end
76
+
77
+ def db_names
78
+ databases.map { |d| d[:name] }
79
+ end
80
+
81
+ def same_migrations?(other)
82
+ migrations_key == other.migrations_key
83
+ end
84
+
85
+ # Folds another database that runs the same migration files into this
86
+ # section: its statuses join the existing rows, its identity joins the
87
+ # header, and anything already built is discarded.
88
+ def merge!(other)
89
+ other.entries.each do |version, entry|
90
+ mine = (@entries[version] ||= {name: entry[:name], statuses: {}})
91
+ mine[:name] = entry[:name] if mine[:name].include?(NO_FILE)
92
+ mine[:statuses].merge!(entry[:statuses])
93
+ end
94
+ @databases.concat(other.databases)
95
+ @ghosts = other.ghosts.merge(@ghosts)
96
+ @total_count = [@total_count, other.total_count].max
97
+ @hidden_count = [@hidden_count, other.hidden_count].max
98
+ @pending_count = [pending_count, other.pending_count].max
99
+ @ghost_total = [ghost_count, other.ghost_count].max
100
+ @latest_applied_version = [latest_applied_version, other.latest_applied_version].compact.max
101
+ @state = other.state if state_rank(other.state) > state_rank(@state)
102
+ reset_built
103
+ self
104
+ end
105
+
106
+ def sharded?
107
+ databases.size > 1
108
+ end
109
+
110
+ # Whether any row this section would render is still pending. Answered
111
+ # from the collected entries, so asking costs no git work - which is the
112
+ # point, since it is asked in order to decide whether to build the rows
113
+ # at all.
114
+ def pending_in_view?
115
+ entries.any? { |_, entry| entry[:statuses].value?("down") }
116
+ end
117
+
118
+ def tick_col
119
+ TICK_COL
120
+ end
121
+
122
+ # Nothing in the window and nothing pending: the section has no table
123
+ # worth drawing, only a line saying so.
124
+ def quiet?
125
+ state != :ok
126
+ end
127
+
128
+ def pending_count
129
+ @pending_count ||= 0
130
+ end
131
+
132
+ def ghost_count
133
+ @ghost_total ||= 0
134
+ end
135
+
136
+ attr_reader :latest_applied_version
137
+
138
+ def latest_applied_date
139
+ version_date(latest_applied_version) if latest_applied_version
140
+ end
141
+
142
+ def columns
143
+ build! unless @built
144
+ @columns
145
+ end
146
+
147
+ def rows
148
+ build! unless @built
149
+ @rows
150
+ end
151
+
152
+ def highlight_rows
153
+ build! unless @built
154
+ @highlight_rows
155
+ end
156
+
157
+ def ghost_rows
158
+ build! unless @built
159
+ @ghost_rows
160
+ end
161
+
162
+ def down_rows
163
+ build! unless @built
164
+ @down_rows
165
+ end
166
+
167
+ def calendar
168
+ build! unless @built
169
+ @calendar
170
+ end
171
+
172
+ private
173
+
174
+ attr_reader :formatter, :git, :version_to_file
175
+
176
+ def migrations_key_for(pool)
177
+ paths = Array(pool.migration_context.migrations_paths)
178
+ paths.map { |p| File.expand_path(p.to_s) }.sort
179
+ end
180
+
181
+ def state_rank(state)
182
+ {no_migrations: 0, none_in_period: 1, ok: 2}.fetch(state, 0)
183
+ end
184
+
185
+ def reset_built
186
+ @built = false
187
+ end
188
+
189
+ def collect(pool)
190
+ db_list = pool.migration_context.migrations_status
191
+ @total_count = db_list.size
192
+ record_totals(db_list)
193
+
194
+ if db_list.empty?
195
+ @state = :no_migrations
196
+ return
197
+ end
198
+
199
+ db_list = apply_since_filter(db_list)
200
+
201
+ # State tracks recency, not row count: a section whose only rows were
202
+ # pulled in by the floor still has nothing recent to say, and should
203
+ # still collapse in a multi-database run.
204
+ @state = @window_count.zero? ? :none_in_period : :ok
205
+ return if db_list.empty?
206
+
207
+ @entries = db_list.to_h do |status, version, name|
208
+ [version.to_s, {name: name, statuses: {db_name => status}}]
209
+ end
210
+
211
+ @version_to_file = {}
212
+ pool.migration_context.migrations.each do |m|
213
+ @version_to_file[m.version.to_s] = m.filename
214
+ end
215
+ end
216
+
217
+ # Counted over every migration, not just the ones in the window, so a
218
+ # collapsed section can still report what it is hiding.
219
+ def record_totals(db_list)
220
+ @pending_count = db_list.count { |status, _, _| status == "down" }
221
+ @ghost_total = db_list.count { |_, _, name| name.to_s.include?(NO_FILE) }
222
+ applied = db_list.select { |status, _, _| status == "up" }
223
+ @latest_applied_version = applied.last&.dig(1)&.to_s
224
+ end
225
+
226
+ # The time window is a soft limit. Whatever it leaves, the floor tops the
227
+ # result back up to RBW_SINCE_MIN rows, so a database with five
228
+ # migrations shows all five rather than hiding the two that happen to be
229
+ # old. The window is still what decides whether the section reads as
230
+ # recent - see #collect.
231
+ def apply_since_filter(db_list)
232
+ since_cutoff = Railbow::Params.parse_since(since_value, context: "migrations")
233
+ unless since_cutoff
234
+ @window_count = db_list.size
235
+ return db_list
236
+ end
237
+
238
+ cutoff_version = since_cutoff.strftime("%Y%m%d%H%M%S").to_i
239
+ @window_count = db_list.count { |_, v, _| v.to_i >= cutoff_version }
240
+
241
+ # Not Comparable#clamp: the floor can exceed the total, and clamp
242
+ # raises when its lower bound sits above its upper one.
243
+ keep = [@window_count, floor].max
244
+ keep = db_list.size if keep > db_list.size
245
+ @floor_applied = keep > @window_count
246
+ if keep.zero?
247
+ @hidden_count = db_list.size
248
+ return []
249
+ end
250
+
251
+ # Taken by rank rather than by slicing the tail, so the same rows are
252
+ # kept whatever order Rails hands the list over in.
253
+ threshold = db_list.map { |_, v, _| v.to_i }.sort[-keep]
254
+ filtered = db_list.select { |_, v, _| v.to_i >= threshold }
255
+ @hidden_count = db_list.size - filtered.size
256
+ filtered
257
+ end
258
+
259
+ def floor
260
+ [Railbow::Params.since_min, 0].max
261
+ end
262
+
263
+ def load_git
264
+ sample_file = version_to_file.values.first
265
+ GitData.for(
266
+ migrate_dir: sample_file ? File.dirname(sample_file) : nil,
267
+ author_enabled: author_enabled?,
268
+ diff_enabled: Railbow::Params.git_diff?,
269
+ base_override: Railbow::Params.git_base,
270
+ branch_mask: Railbow::Params.git_mask
271
+ )
272
+ end
273
+
274
+ def load_ghosts
275
+ return {} unless Ghosts.available?
276
+
277
+ versions = entries.select { |_, e| e[:name].include?(NO_FILE) }.keys
278
+ return {} if versions.empty?
279
+
280
+ Ghosts.load(versions, with_content: tables_enabled?)
281
+ end
282
+
283
+ def author_mode
284
+ @author_mode ||= Railbow::Params.git_author
285
+ end
286
+
287
+ def author_enabled?
288
+ %w[all me].include?(author_mode)
289
+ end
290
+
291
+ def tables_enabled?
292
+ return @tables_enabled unless @tables_enabled.nil?
293
+
294
+ @tables_enabled = Railbow::Params.view_tables?
295
+ end
296
+
297
+ # Git lookups and ghost recovery happen here rather than during collect,
298
+ # so a section that ends up collapsed never pays for them.
299
+ def build!
300
+ @built = true
301
+ @columns = []
302
+ @rows = []
303
+ @highlight_rows = Set.new
304
+ @ghost_rows = Set.new
305
+ @down_rows = Set.new
306
+ @calendar = Railbow::Calendar.none
307
+ return if entries.empty?
308
+
309
+ @git = load_git
310
+ @ghosts = load_ghosts
311
+ @columns = build_columns
312
+ @rows = build_rows
313
+ @calendar = build_calendar
314
+ end
315
+
316
+ # The name column only needs capping when something else competes for the
317
+ # row: table tags, an author column, branch badges or landed badges.
318
+ #
319
+ # On a terminal too narrow for the full row the width budget degrades the
320
+ # table instead of letting it wrap: the name column shrinks first, then
321
+ # Tables is dropped, then the formatted date (the raw Migration ID keeps
322
+ # the timestamp), then Who.
323
+ def build_columns
324
+ @name_col_width = needs_name_truncation? ? NAME_COL_WIDTH : nil
325
+
326
+ cols = [
327
+ Table::Column.new(label: "Status", max_width: status_col_width,
328
+ sticky: true, accent: true, aliased: !sharded?),
329
+ Table::Column.new(label: "Migration ID", sticky: true),
330
+ Table::Column.new(label: (date_format == "full") ? "Created At" : "Date",
331
+ droppable: 2),
332
+ Table::Column.new(label: "Migration Name",
333
+ max_width: @name_col_width,
334
+ truncate: !@name_col_width.nil?,
335
+ shrinkable: true, shrink_floor: NAME_COL_MIN_WIDTH)
336
+ ]
337
+ cols << Table::Column.new(label: "Who", droppable: 3) if author_mode == "all"
338
+ if tables_enabled?
339
+ truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
340
+ cols << Table::Column.new(label: "Tables", droppable: 1,
341
+ truncate: Railbow::Params.compact_oneline?, truncate_fn: truncate_fn)
342
+ end
343
+ cols
344
+ end
345
+
346
+ # One glyph plus a space per database, and never narrower than the single
347
+ # database case, which also has to fit a trailing indicator.
348
+ def status_col_width
349
+ [6, 3 * databases.size].max
350
+ end
351
+
352
+ def date_format
353
+ @date_format ||= Railbow::Params.date_format
354
+ end
355
+
356
+ def needs_name_truncation?
357
+ tables_enabled? || author_mode == "all" || Railbow::Params.git_diff? || landed_tags?
358
+ end
359
+
360
+ # Only migrations that landed well after they were written earn a badge,
361
+ # so a repo that always merges promptly never pays for the column space.
362
+ def landed_tags?
363
+ git.landed_dates.any? do |basename, landed|
364
+ mig_date = version_date(basename[0..13])
365
+ mig_date && (landed - mig_date) > 7
366
+ end
367
+ end
368
+
369
+ def build_rows
370
+ versions = entries.keys.sort
371
+ latest_mig_date = version_date(versions.last)
372
+ author_display = build_author_display
373
+
374
+ versions.each_with_index.map do |version, idx|
375
+ entry = entries[version]
376
+ name = entry[:name]
377
+ statuses = entry[:statuses]
378
+
379
+ # A pending migration is not in effect yet: grey the whole row out so
380
+ # it reads as inactive next to the applied ones. In a shard group only
381
+ # a row pending everywhere reads as inactive.
382
+ @down_rows << idx if statuses.values.all? { |s| s == "down" }
383
+
384
+ ghost = name.include?(NO_FILE) ? ghosts[version] : nil
385
+ @ghost_rows << idx if ghost
386
+
387
+ status_cell = build_status_cell(statuses, ghost)
388
+ display_name = name_cell(
389
+ name: name, version: version, ghost: ghost, latest_mig_date: latest_mig_date
390
+ ) do |indicator|
391
+ status_cell = "#{status_cell} #{indicator}"
392
+ end
393
+
394
+ row = [status_cell, version, formatter.format_date(version, date_format), display_name]
395
+ row << author_cell(version, ghost, author_display) if author_mode == "all"
396
+ track_highlight(idx, version, ghost) if author_enabled?
397
+ row << formatter.table_tags(tables_for(version, ghost)) if tables_enabled?
398
+ row
399
+ end
400
+ end
401
+
402
+ def build_status_cell(statuses, ghost)
403
+ return status_glyph(statuses[db_name], ghost) unless sharded?
404
+
405
+ databases.map { |db| status_glyph(statuses[db[:name]], ghost) }.join(" ")
406
+ end
407
+
408
+ # A sharded section resolves the up/down aliases itself: the renderer
409
+ # applies them by matching the whole cell, which a cluster never is.
410
+ def status_glyph(status, ghost)
411
+ # A superseded ghost lives on under another version: stale bookkeeping,
412
+ # not a lost migration, so it gets a calmer glyph.
413
+ return ghost.superseded_by ? "🪦" : "👻" if ghost && status
414
+
415
+ case status
416
+ when "up" then formatter.green_bold(sharded? ? status_alias("up") : "up")
417
+ when "down" then formatter.yellow_bold(sharded? ? status_alias("down") : "down")
418
+ when nil then formatter.dim(ABSENT)
419
+ else status
420
+ end
421
+ end
422
+
423
+ def status_alias(status)
424
+ @status_aliases ||= Railbow::Config.value_aliases["Status"] || {}
425
+ @status_aliases[status] || status
426
+ end
427
+
428
+ # Builds the Migration Name cell and right-aligns whatever badges it
429
+ # carries. Yields a status indicator when the row earns one.
430
+ def name_cell(name:, version:, ghost:, latest_mig_date:)
431
+ if ghost
432
+ return with_tags(Ghosts.display_name(ghost), Ghosts.tag(ghost), strip: false)
433
+ end
434
+ return formatter.red(NO_FILE) if name.include?(NO_FILE)
435
+
436
+ basename = basename_for(version)
437
+ tags = []
438
+
439
+ diff_tag = nil
440
+ if Railbow::Params.git_diff? && basename
441
+ if git.incoming_merge_files.include?(basename)
442
+ yield "\e[38;5;213m⬇#{Formatters::Base::RESET}"
443
+ diff_tag = formatter.diff_tag_merging(git.merge_source_label || "merge")
444
+ elsif git.uncommitted_files.include?(basename)
445
+ yield "\e[38;5;220m◆#{Formatters::Base::RESET}"
446
+ end
447
+ diff_tag ||= formatter.diff_tag_branch(git.branch_origins[basename]) if git.branch_origins.key?(basename)
448
+ end
449
+
450
+ tags << landed_tag(basename, version, latest_mig_date)
451
+ tags << diff_tag
452
+
453
+ with_tags(name, tags.compact.join(" "))
454
+ end
455
+
456
+ # Landed badge: the migration reached the mainline more than a week after
457
+ # it was written, which usually means a long-lived branch.
458
+ def landed_tag(basename, version, latest_mig_date)
459
+ return nil unless basename
460
+
461
+ landed = git.landed_dates[basename]
462
+ return nil unless landed
463
+
464
+ mig_date = version_date(version)
465
+ return nil unless mig_date && (landed - mig_date) > 7
466
+
467
+ formatter.landed_tag(landed, fresh: latest_mig_date && landed >= latest_mig_date)
468
+ end
469
+
470
+ # Pads the name so its tags sit flush against the right edge of the
471
+ # column. Falls back to a two-space gap when the column is uncapped.
472
+ def with_tags(name, tags, strip: true)
473
+ return name if tags.nil? || tags.empty?
474
+ return "#{name} #{tags}" unless @name_col_width
475
+
476
+ tags_width = formatter.display_width(formatter.strip_ansi(tags))
477
+ name = formatter.truncate_str(name, @name_col_width - tags_width - 2)
478
+ name_width = formatter.display_width(strip ? formatter.strip_ansi(name) : name)
479
+ padding = @name_col_width - name_width - tags_width
480
+ "#{name}#{" " * [padding, 2].max}#{tags}"
481
+ end
482
+
483
+ def build_author_display
484
+ return {} unless author_mode == "all"
485
+
486
+ raw = git.author_names.values.compact
487
+ raw << git.git_name if git.git_name
488
+ ghosts.each_value { |g| raw << g.author_name if g.author_name }
489
+ Railbow::Params.format_authors(raw)
490
+ end
491
+
492
+ def author_cell(version, ghost, author_display)
493
+ raw = if ghost
494
+ ghost.author_name || ""
495
+ else
496
+ basename = basename_for(version)
497
+ author = basename ? git.author_names[basename] : nil
498
+ author || (basename ? git.git_name : "")
499
+ end
500
+ author_display[raw] || Railbow::Params.format_author(raw)
501
+ end
502
+
503
+ # Uncommitted migrations have no git author, so they are treated as mine.
504
+ # Match by email first, then by name, to survive a commit email that
505
+ # differs from git config (GitHub noreply addresses after a squash-merge,
506
+ # or a mailmap rewrite).
507
+ def track_highlight(idx, version, ghost)
508
+ return unless git.git_email
509
+
510
+ if ghost
511
+ email = ghost.author_email&.downcase
512
+ @highlight_rows << idx if email && email == git.git_email
513
+ return
514
+ end
515
+
516
+ basename = basename_for(version)
517
+ # A row with no file has no author to compare against, which the two
518
+ # modes read differently: author:all treats "nobody else's" as mine,
519
+ # author:me requires a real file before claiming anything.
520
+ return if author_mode == "me" && basename.nil?
521
+
522
+ email = git.author_emails[basename]
523
+ author = git.author_names[basename]
524
+ @highlight_rows << idx if email.nil? || email == git.git_email ||
525
+ (git.git_name && author && author.downcase == git.git_name.downcase)
526
+ end
527
+
528
+ def tables_for(version, ghost)
529
+ if ghost&.content && !ghost.content.empty?
530
+ Railbow::MigrationParser.extract_tables_from_content(ghost.content)
531
+ else
532
+ Railbow::MigrationParser.extract_tables(version_to_file[version])
533
+ end
534
+ end
535
+
536
+ def basename_for(version)
537
+ filepath = version_to_file[version]
538
+ filepath ? File.basename(filepath) : nil
539
+ end
540
+
541
+ def build_calendar
542
+ return Railbow::Calendar.none unless Railbow::Params.view_calendar?
543
+
544
+ Railbow::Calendar.build(
545
+ entries.keys.sort,
546
+ weeks: Railbow::Params.calendar_wdividers?,
547
+ ticks: Railbow::Params.calendar_wticks?,
548
+ counts: Railbow::Params.calendar_counts?,
549
+ month_label: Railbow::Params.calendar_label,
550
+ week_label: Railbow::Params.calendar_week_label
551
+ )
552
+ end
553
+
554
+ def version_date(version)
555
+ v = version.to_s
556
+ Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
557
+ rescue Date::Error
558
+ nil
559
+ end
560
+ end
561
+ end
562
+ end
@@ -3,11 +3,19 @@
3
3
  module Railbow
4
4
  module Table
5
5
  class Column
6
- attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn, :sticky, :accent
6
+ attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn,
7
+ :sticky, :accent, :aliased, :shrinkable, :shrink_floor, :droppable
7
8
 
8
9
  # accent: the column carries its own meaning through color (a status
9
10
  # glyph, say), so it keeps that color when the row is dimmed.
10
- def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false)
11
+ # aliased: value aliases from config may rewrite this column's cells.
12
+ # Off for cells the caller already resolved, such as a cluster of status
13
+ # glyphs, which no whole-cell alias could ever match.
14
+ # shrinkable: the width budget may narrow this column, down to
15
+ # shrink_floor, before it starts dropping columns.
16
+ # droppable: rank in the order the width budget drops columns entirely
17
+ # when shrinking is not enough - lower ranks go first. Nil: never dropped.
18
+ def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false, aliased: true, shrinkable: false, shrink_floor: nil, droppable: nil)
11
19
  @label = label
12
20
  @width = width
13
21
  @min_width = min_width
@@ -17,6 +25,10 @@ module Railbow
17
25
  @truncate_fn = truncate_fn
18
26
  @sticky = sticky
19
27
  @accent = accent
28
+ @aliased = aliased
29
+ @shrinkable = shrinkable
30
+ @shrink_floor = shrink_floor
31
+ @droppable = droppable
20
32
  end
21
33
 
22
34
  def fixed?