railbow 0.1.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 +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +243 -0
- data/exe/railbow +110 -0
- data/lib/railbow/about_formatter.rb +37 -0
- data/lib/railbow/color_assigner.rb +53 -0
- data/lib/railbow/config.rb +113 -0
- data/lib/railbow/demo/fixtures.rb +121 -0
- data/lib/railbow/demo/migrate_demo.rb +35 -0
- data/lib/railbow/demo/routes_demo.rb +94 -0
- data/lib/railbow/demo/runner.rb +51 -0
- data/lib/railbow/demo/status_demo.rb +164 -0
- data/lib/railbow/formatters/base.rb +195 -0
- data/lib/railbow/git_utils.rb +29 -0
- data/lib/railbow/init.rb +112 -0
- data/lib/railbow/logo.rb +27 -0
- data/lib/railbow/migration_formatter.rb +64 -0
- data/lib/railbow/migration_parser.rb +87 -0
- data/lib/railbow/name_collision_resolver.rb +155 -0
- data/lib/railbow/name_formatter.rb +82 -0
- data/lib/railbow/notes_formatter.rb +311 -0
- data/lib/railbow/params.rb +229 -0
- data/lib/railbow/railtie.rb +44 -0
- data/lib/railbow/routes_formatter.rb +163 -0
- data/lib/railbow/stats_formatter.rb +99 -0
- data/lib/railbow/table/column.rb +24 -0
- data/lib/railbow/table/renderer.rb +441 -0
- data/lib/railbow/table/theme.rb +54 -0
- data/lib/railbow/table.rb +5 -0
- data/lib/railbow/tasks/init.rake +10 -0
- data/lib/railbow/tasks/migrate_status.rake +694 -0
- data/lib/railbow/tasks/stats.rake +85 -0
- data/lib/railbow/text_utils.rb +30 -0
- data/lib/railbow/version.rb +5 -0
- data/lib/railbow.rb +22 -0
- data/sig/railbow.rbs +4 -0
- metadata +137 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Railbow
|
|
6
|
+
module Demo
|
|
7
|
+
module Fixtures
|
|
8
|
+
# Fictional veterinary SaaS — "PawClinic"
|
|
9
|
+
# 4 authors: Alice Chen, Bob Wilson, Carol Davis, Me (current user)
|
|
10
|
+
DEMO_USER_NAME = "Me"
|
|
11
|
+
DEMO_USER_EMAIL = "me@example.com"
|
|
12
|
+
|
|
13
|
+
AUTHORS = {
|
|
14
|
+
alice: {name: "Alice Chen", email: "alice@pawclinic.dev"},
|
|
15
|
+
bob: {name: "Bob Wilson", email: "bob@pawclinic.dev"},
|
|
16
|
+
carol: {name: "Carol Davis", email: "carol@pawclinic.dev"},
|
|
17
|
+
me: {name: DEMO_USER_NAME, email: DEMO_USER_EMAIL}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Each entry: [days_ago, time, name, author, status, tables, branch, landed_days_after]
|
|
21
|
+
# landed_days_after: if > 0, the landing date badge shows (must be >7 to trigger)
|
|
22
|
+
MIGRATION_TEMPLATES = [
|
|
23
|
+
# ── ~68 days ago (pre-last month) ──
|
|
24
|
+
[68, "09:30:12", "CreateVeterinarians", :alice, "up", ["vets"], nil, 0],
|
|
25
|
+
[66, "14:15:00", "CreatePets", :bob, "up", ["pets"], nil, 13],
|
|
26
|
+
[65, "11:00:30", "CreateOwners", :alice, "up", ["owners"], nil, 13],
|
|
27
|
+
[62, "16:30:00", "AddBreedAndColorToPets", :me, "up", ["pets"], nil, 0],
|
|
28
|
+
[59, "08:45:00", "CreateAppointments", :carol, "up", ["appointments"], nil, 0],
|
|
29
|
+
[57, "15:12:00", "AddOwnerIdToPets", :bob, "up", ["pets", "owners"], nil, 12],
|
|
30
|
+
[55, "10:00:00", "CreateVaccinations", :alice, "up", ["vaccinations"], nil, 0],
|
|
31
|
+
[53, "17:20:00", "AddPhoneToOwners", :carol, "up", ["owners"], nil, 0],
|
|
32
|
+
[51, "09:15:00", "CreateTreatments", :me, "up", ["treatments"], nil, 0],
|
|
33
|
+
[49, "14:00:00", "AddWeightToPets", :bob, "up", ["pets"], nil, 0],
|
|
34
|
+
# ── ~45 days ago (last month) ──
|
|
35
|
+
[45, "09:30:00", "CreateBillingRecords", :me, "up", ["bills"], nil, 0],
|
|
36
|
+
[43, "16:00:00", "AddVetIdToAppointments", :carol, "up", ["appointments", "vets"], nil, 0],
|
|
37
|
+
[41, "12:00:00", "CreateMedicalRecords", :bob, "up", ["records"], nil, 15],
|
|
38
|
+
[39, "08:30:00", "AddIndexOnPetsBreed", :alice, "up", ["pets"], nil, 0],
|
|
39
|
+
[37, "14:00:00", "CreateSpecies", :me, "up", ["species"], nil, 0],
|
|
40
|
+
[35, "11:00:00", "AddSpeciesIdToPets", :me, "up", ["pets", "species", "breeds"], nil, 0],
|
|
41
|
+
[33, "09:30:00", "AddNotesToAppointments", :carol, "up", ["appointments"], nil, 0],
|
|
42
|
+
[31, "15:45:00", "CreateLabResults", :alice, "up", ["labs"], nil, 0],
|
|
43
|
+
[29, "10:20:00", "AddDosageToTreatments", :bob, "up", ["treatments"], nil, 0],
|
|
44
|
+
[27, "13:00:00", "CreateReminders", :me, "up", ["reminders", "pets"], nil, 0],
|
|
45
|
+
# ── ~25 days ago (current month) ──
|
|
46
|
+
[24, "10:00:00", "CreatePrescriptions", :bob, "up", ["prescriptions"], nil, 10],
|
|
47
|
+
[22, "14:00:00", "AddWeightHistoryToPets", :alice, "up", ["pets"], nil, 0],
|
|
48
|
+
[20, "16:30:00", "AddStatusToAppointments", :carol, "up", ["appointments"], nil, 0],
|
|
49
|
+
[18, "09:00:00", "CreateInventory", :me, "up", ["inventory"], nil, 0],
|
|
50
|
+
[16, "11:30:00", "AddAllergiesToPets", :alice, "up", ["pets", "allergies"], nil, 0],
|
|
51
|
+
[14, "08:30:00", "RenameAmountOnBills", :me, "up", ["bills"], nil, 0],
|
|
52
|
+
[10, "15:00:00", "AddMicrochipToPets", :bob, "up", ["pets"], nil, 0],
|
|
53
|
+
[5, "16:00:00", "AddInsuranceToOwners", :carol, "up", ["owners", "plans", "bills", "logs"], "VET-150", 0],
|
|
54
|
+
[3, "11:00:00", "CreateInsuranceClaims", :me, "up", ["claims"], "VET-152", 0],
|
|
55
|
+
[2, "14:30:00", "AddPolicyNumberToBills", :me, "down", ["bills"], "VET-152", 0]
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# Build migrations with dates relative to today.
|
|
59
|
+
def self.migrations
|
|
60
|
+
today = Date.today
|
|
61
|
+
MIGRATION_TEMPLATES.map do |days_ago, time, name, author_key, status, tables, branch, landed_after|
|
|
62
|
+
date = today - days_ago
|
|
63
|
+
h, m, s = time.split(":").map(&:to_i)
|
|
64
|
+
version = date.strftime("%Y%m%d") + format("%02d%02d%02d", h, m, s)
|
|
65
|
+
|
|
66
|
+
landed_date = (landed_after > 7) ? date + landed_after : nil
|
|
67
|
+
|
|
68
|
+
author = AUTHORS[author_key]
|
|
69
|
+
{
|
|
70
|
+
status: status,
|
|
71
|
+
version: version,
|
|
72
|
+
name: name,
|
|
73
|
+
author_name: author[:name],
|
|
74
|
+
author_email: author[:email],
|
|
75
|
+
branch: branch,
|
|
76
|
+
landed_date: landed_date,
|
|
77
|
+
tables: tables
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
ROUTES = [
|
|
83
|
+
# Pets
|
|
84
|
+
{verb: "GET", path: "/pets", reqs: "pets#index", name: "pets"},
|
|
85
|
+
{verb: "GET", path: "/pets/new", reqs: "pets#new", name: "new_pet"},
|
|
86
|
+
{verb: "POST", path: "/pets", reqs: "pets#create", name: ""},
|
|
87
|
+
{verb: "GET", path: "/pets/:id", reqs: "pets#show", name: "pet"},
|
|
88
|
+
{verb: "GET", path: "/pets/:id/edit", reqs: "pets#edit", name: "edit_pet"},
|
|
89
|
+
{verb: "PATCH|PUT", path: "/pets/:id", reqs: "pets#update", name: ""},
|
|
90
|
+
{verb: "DELETE", path: "/pets/:id", reqs: "pets#destroy", name: ""},
|
|
91
|
+
# Owners
|
|
92
|
+
{verb: "GET", path: "/owners", reqs: "owners#index", name: "owners"},
|
|
93
|
+
{verb: "POST", path: "/owners", reqs: "owners#create", name: ""},
|
|
94
|
+
{verb: "GET", path: "/owners/:id", reqs: "owners#show", name: "owner"},
|
|
95
|
+
# Appointments (nested under pets)
|
|
96
|
+
{verb: "GET", path: "/pets/:pet_id/appointments", reqs: "appointments#index", name: "pet_appointments"},
|
|
97
|
+
{verb: "POST", path: "/pets/:pet_id/appointments", reqs: "appointments#create", name: ""},
|
|
98
|
+
{verb: "GET", path: "/pets/:pet_id/appointments/:id", reqs: "appointments#show", name: "pet_appointment"},
|
|
99
|
+
# API namespace
|
|
100
|
+
{verb: "GET", path: "/api/v1/pets", reqs: "api/v1/pets#index", name: "api_v1_pets"},
|
|
101
|
+
{verb: "GET", path: "/api/v1/pets/:id", reqs: "api/v1/pets#show", name: "api_v1_pet"}
|
|
102
|
+
].freeze
|
|
103
|
+
|
|
104
|
+
# Simulate `db:migrate` running — list of [name, timing_seconds]
|
|
105
|
+
MIGRATE_STEPS = [
|
|
106
|
+
["CreateInsuranceClaims", [
|
|
107
|
+
["create_table(:insurance_claims)", 0.0043],
|
|
108
|
+
["add_index(:insurance_claims, :owner_id)", 0.0012]
|
|
109
|
+
]],
|
|
110
|
+
["AddPolicyNumberToBills", [
|
|
111
|
+
["add_column(:bills, :policy_number, :string)", 0.0008]
|
|
112
|
+
]],
|
|
113
|
+
["AddInsuranceToOwners", [
|
|
114
|
+
["add_column(:owners, :insurance_provider, :string)", 0.0006],
|
|
115
|
+
["add_column(:owners, :insurance_id, :string)", 0.0005],
|
|
116
|
+
["add_index(:owners, :insurance_provider)", 0.2310]
|
|
117
|
+
]]
|
|
118
|
+
].freeze
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatters/base"
|
|
4
|
+
require_relative "fixtures"
|
|
5
|
+
|
|
6
|
+
module Railbow
|
|
7
|
+
module Demo
|
|
8
|
+
class MigrateDemo
|
|
9
|
+
def self.run
|
|
10
|
+
new.run
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
f = Railbow::Formatters::Base.new
|
|
15
|
+
|
|
16
|
+
Fixtures::MIGRATE_STEPS.each do |name, steps|
|
|
17
|
+
puts
|
|
18
|
+
puts f.cyan("#{f.emoji(:migrating)} #{name}: migrating...")
|
|
19
|
+
|
|
20
|
+
total_time = 0.0
|
|
21
|
+
steps.each do |operation, seconds|
|
|
22
|
+
total_time += seconds
|
|
23
|
+
timing = f.format_timing(seconds)
|
|
24
|
+
puts " #{f.green(f.emoji(:check))} #{operation} \u2192 #{timing}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
total_timing = f.format_timing(total_time)
|
|
28
|
+
puts f.green("#{f.emoji(:migrated)} #{name}: migrated") + " (#{total_timing} total)"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
puts
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatters/base"
|
|
4
|
+
require_relative "../table"
|
|
5
|
+
require_relative "../config"
|
|
6
|
+
require_relative "fixtures"
|
|
7
|
+
|
|
8
|
+
module Railbow
|
|
9
|
+
module Demo
|
|
10
|
+
class RoutesDemo
|
|
11
|
+
VERB_COLORS = {
|
|
12
|
+
"GET" => Railbow::Formatters::Base::GREEN,
|
|
13
|
+
"POST" => Railbow::Formatters::Base::YELLOW,
|
|
14
|
+
"PATCH" => Railbow::Formatters::Base::CYAN,
|
|
15
|
+
"PUT" => Railbow::Formatters::Base::CYAN,
|
|
16
|
+
"DELETE" => Railbow::Formatters::Base::RED
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
RESET = Railbow::Formatters::Base::RESET
|
|
20
|
+
BOLD = Railbow::Formatters::Base::BOLD
|
|
21
|
+
DIM = Railbow::Formatters::Base::DIM
|
|
22
|
+
CYAN = Railbow::Formatters::Base::CYAN
|
|
23
|
+
|
|
24
|
+
def self.run
|
|
25
|
+
new.run
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
routes = Fixtures::ROUTES
|
|
30
|
+
groups = routes.group_by { |r| r[:reqs].include?("#") ? r[:reqs].split("#").first : "(other)" }
|
|
31
|
+
|
|
32
|
+
lines = []
|
|
33
|
+
groups.each do |label, group_routes|
|
|
34
|
+
lines << ""
|
|
35
|
+
lines << "#{BOLD}#{CYAN}\u2500\u2500 #{label} \u2500\u2500#{RESET}"
|
|
36
|
+
|
|
37
|
+
columns = [
|
|
38
|
+
Railbow::Table::Column.new(label: "Verb", sticky: true),
|
|
39
|
+
Railbow::Table::Column.new(label: "URI Pattern", sticky: true),
|
|
40
|
+
Railbow::Table::Column.new(label: "Controller#Action"),
|
|
41
|
+
Railbow::Table::Column.new(label: "Prefix")
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
rows = group_routes.map { |r|
|
|
45
|
+
[
|
|
46
|
+
colorize_verb(r[:verb]),
|
|
47
|
+
colorize_path(r[:path]),
|
|
48
|
+
colorize_reqs(r[:reqs]),
|
|
49
|
+
r[:name].empty? ? "" : "#{DIM}#{r[:name]}#{RESET}"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
renderer = Railbow::Table::Renderer.new(
|
|
54
|
+
columns: columns,
|
|
55
|
+
theme: Railbow::Table::Themes::PLAIN,
|
|
56
|
+
compact: {oneline: false, dense: false, noheader: false, maxw: nil, hidden_columns: []},
|
|
57
|
+
aliases: Railbow::Config.table_aliases
|
|
58
|
+
)
|
|
59
|
+
lines << renderer.render(rows)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
puts lines.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def colorize_verb(verb)
|
|
68
|
+
return verb if verb.empty?
|
|
69
|
+
|
|
70
|
+
verb.split("|").map { |v|
|
|
71
|
+
color = VERB_COLORS[v]
|
|
72
|
+
color ? "#{color}#{v}#{RESET}" : v
|
|
73
|
+
}.join("#{DIM}|#{RESET}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def colorize_path(path)
|
|
77
|
+
return path if path.empty?
|
|
78
|
+
|
|
79
|
+
path.gsub(/:[a-z_]+|\*[a-z_]+/) { |match| "#{CYAN}#{match}#{RESET}" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def colorize_reqs(reqs)
|
|
83
|
+
return reqs if reqs.empty?
|
|
84
|
+
|
|
85
|
+
if reqs.include?("#")
|
|
86
|
+
controller, action = reqs.split("#", 2)
|
|
87
|
+
"#{BOLD}#{controller}#{RESET}#{DIM}##{RESET}#{action}"
|
|
88
|
+
else
|
|
89
|
+
"#{DIM}#{reqs}#{RESET}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../railbow"
|
|
4
|
+
require_relative "../logo"
|
|
5
|
+
|
|
6
|
+
module Railbow
|
|
7
|
+
module Demo
|
|
8
|
+
class Runner
|
|
9
|
+
SUBCOMMANDS = %w[status migrate routes all].freeze
|
|
10
|
+
|
|
11
|
+
def self.run(subcommand = "status")
|
|
12
|
+
subcommand = subcommand.to_s.downcase
|
|
13
|
+
unless SUBCOMMANDS.include?(subcommand)
|
|
14
|
+
warn "Unknown demo: #{subcommand}"
|
|
15
|
+
warn "Available: #{SUBCOMMANDS.join(", ")}"
|
|
16
|
+
exit 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Railbow.print_logo
|
|
20
|
+
puts
|
|
21
|
+
|
|
22
|
+
if subcommand == "all"
|
|
23
|
+
run_all
|
|
24
|
+
else
|
|
25
|
+
run_one(subcommand)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.run_one(subcommand)
|
|
30
|
+
case subcommand
|
|
31
|
+
when "status"
|
|
32
|
+
require_relative "status_demo"
|
|
33
|
+
StatusDemo.run
|
|
34
|
+
when "migrate"
|
|
35
|
+
require_relative "migrate_demo"
|
|
36
|
+
MigrateDemo.run
|
|
37
|
+
when "routes"
|
|
38
|
+
require_relative "routes_demo"
|
|
39
|
+
RoutesDemo.run
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.run_all
|
|
44
|
+
%w[status migrate routes].each do |sub|
|
|
45
|
+
puts "\n\e[1m\e[36m── railbow demo #{sub} ──\e[0m\n"
|
|
46
|
+
run_one(sub)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "../formatters/base"
|
|
5
|
+
require_relative "../table"
|
|
6
|
+
require_relative "../config"
|
|
7
|
+
require_relative "fixtures"
|
|
8
|
+
|
|
9
|
+
module Railbow
|
|
10
|
+
module Demo
|
|
11
|
+
class StatusDemo
|
|
12
|
+
def self.run
|
|
13
|
+
new.run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
formatter = Railbow::Formatters::Base.new
|
|
18
|
+
migrations = Fixtures.migrations
|
|
19
|
+
|
|
20
|
+
puts "\n#{formatter.emoji(:status)} Database: #{formatter.cyan("db/development.sqlite3")}"
|
|
21
|
+
puts
|
|
22
|
+
|
|
23
|
+
# Use default config: author:me, diff, calendar, tables, wticks, full date
|
|
24
|
+
date_format = "full"
|
|
25
|
+
tables_enabled = true
|
|
26
|
+
diff_enabled = true
|
|
27
|
+
|
|
28
|
+
git_email = Fixtures::DEMO_USER_EMAIL
|
|
29
|
+
git_name = Fixtures::DEMO_USER_NAME
|
|
30
|
+
|
|
31
|
+
# Latest migration date — for "fresh" landed badge detection
|
|
32
|
+
latest_version = migrations.last[:version]
|
|
33
|
+
latest_mig_date = parse_version_date(latest_version)
|
|
34
|
+
|
|
35
|
+
# Check if any landed tags exist
|
|
36
|
+
has_landed_tags = migrations.any? { |m|
|
|
37
|
+
next false unless m[:landed_date]
|
|
38
|
+
mig_date = parse_version_date(m[:version])
|
|
39
|
+
mig_date && (m[:landed_date] - mig_date) > 7
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
needs_name_truncation = tables_enabled || diff_enabled || has_landed_tags
|
|
43
|
+
name_col_width = needs_name_truncation ? 60 : nil
|
|
44
|
+
|
|
45
|
+
table_columns = [
|
|
46
|
+
Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true),
|
|
47
|
+
Railbow::Table::Column.new(label: "Migration ID", sticky: true),
|
|
48
|
+
Railbow::Table::Column.new(label: "Created At"),
|
|
49
|
+
Railbow::Table::Column.new(label: "Migration Name",
|
|
50
|
+
max_width: name_col_width,
|
|
51
|
+
truncate: needs_name_truncation)
|
|
52
|
+
]
|
|
53
|
+
# No Author column in default config (author:me only highlights)
|
|
54
|
+
|
|
55
|
+
tables_truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
|
|
56
|
+
table_columns << Railbow::Table::Column.new(label: "Tables", truncate: true, truncate_fn: tables_truncate_fn)
|
|
57
|
+
|
|
58
|
+
# Build rows
|
|
59
|
+
highlight_rows = Set.new
|
|
60
|
+
rows = migrations.each_with_index.map do |m, idx|
|
|
61
|
+
colored_status = case m[:status]
|
|
62
|
+
when "up" then formatter.green_bold("up")
|
|
63
|
+
when "down" then formatter.yellow_bold("down")
|
|
64
|
+
else m[:status]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
display_name = m[:name]
|
|
68
|
+
|
|
69
|
+
# Diff tag (branch badge)
|
|
70
|
+
diff_tag = m[:branch] ? formatter.diff_tag_branch(m[:branch]) : nil
|
|
71
|
+
|
|
72
|
+
# Landed badge
|
|
73
|
+
landed_tag = nil
|
|
74
|
+
if m[:landed_date]
|
|
75
|
+
mig_date = parse_version_date(m[:version])
|
|
76
|
+
if mig_date && (m[:landed_date] - mig_date) > 7
|
|
77
|
+
fresh = latest_mig_date && m[:landed_date] >= latest_mig_date
|
|
78
|
+
landed_tag = formatter.landed_tag(m[:landed_date], fresh: fresh)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Append tags to display_name
|
|
83
|
+
tags = [landed_tag, diff_tag].compact.join(" ")
|
|
84
|
+
if !tags.empty? && name_col_width
|
|
85
|
+
tags_width = formatter.display_width(formatter.strip_ansi(tags))
|
|
86
|
+
available = name_col_width - tags_width - 2
|
|
87
|
+
display_name = formatter.truncate_str(display_name, available)
|
|
88
|
+
name_width = formatter.display_width(formatter.strip_ansi(display_name))
|
|
89
|
+
padding = name_col_width - name_width - tags_width
|
|
90
|
+
display_name = "#{display_name}#{" " * [padding, 2].max}#{tags}"
|
|
91
|
+
elsif !tags.empty?
|
|
92
|
+
display_name = "#{display_name} #{tags}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
created_at = formatter.format_date(m[:version], date_format)
|
|
96
|
+
|
|
97
|
+
# Highlight current user's rows (author:me mode)
|
|
98
|
+
if m[:author_email] == git_email ||
|
|
99
|
+
(git_name && m[:author_name] && m[:author_name].downcase == git_name.downcase)
|
|
100
|
+
highlight_rows << idx
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Table tags
|
|
104
|
+
table_tags = formatter.table_tags(m[:tables])
|
|
105
|
+
|
|
106
|
+
[colored_status, m[:version], created_at, display_name, table_tags]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Calendar separators
|
|
110
|
+
separators = {}
|
|
111
|
+
versions = migrations.map { |m| m[:version] }
|
|
112
|
+
month_keys = versions.map { |v| v[0..5] }
|
|
113
|
+
calendar_label_fmt = "%b %Y W%V"
|
|
114
|
+
|
|
115
|
+
if month_keys.uniq.size > 1
|
|
116
|
+
month_keys.each_with_index do |mk, i|
|
|
117
|
+
next if i == 0
|
|
118
|
+
if mk != month_keys[i - 1]
|
|
119
|
+
v = versions[i]
|
|
120
|
+
date = Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
|
|
121
|
+
separators[i] = date.strftime(calendar_label_fmt)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Week ticks
|
|
127
|
+
tick_rows = Set.new
|
|
128
|
+
prev_week = nil
|
|
129
|
+
versions.each_with_index do |v, i|
|
|
130
|
+
y = v[0..3].to_i
|
|
131
|
+
m = v[4..5].to_i
|
|
132
|
+
d = v[6..7].to_i
|
|
133
|
+
next if y == 0 || m == 0 || d == 0
|
|
134
|
+
|
|
135
|
+
week = Date.new(y, m, d).cweek
|
|
136
|
+
|
|
137
|
+
if i > 0 && prev_week && week != prev_week
|
|
138
|
+
tick_rows << i
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
prev_week = week
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
aliases = Railbow::Config.table_aliases
|
|
145
|
+
renderer = Railbow::Table::Renderer.new(
|
|
146
|
+
columns: table_columns,
|
|
147
|
+
theme: Railbow::Table::Themes::WALLS,
|
|
148
|
+
compact: {oneline: false, dense: false, noheader: false, maxw: nil, hidden_columns: []},
|
|
149
|
+
aliases: aliases
|
|
150
|
+
)
|
|
151
|
+
puts renderer.render(rows, separators: separators, highlight_rows: highlight_rows, tick_rows: tick_rows, tick_col: 2)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def parse_version_date(version)
|
|
157
|
+
v = version.to_s
|
|
158
|
+
Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
|
|
159
|
+
rescue Date::Error
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require_relative "../text_utils"
|
|
5
|
+
|
|
6
|
+
module Railbow
|
|
7
|
+
module Formatters
|
|
8
|
+
class Base
|
|
9
|
+
include TextUtils
|
|
10
|
+
|
|
11
|
+
RESET = "\e[0m"
|
|
12
|
+
BOLD = "\e[1m"
|
|
13
|
+
GREEN = "\e[32m"
|
|
14
|
+
YELLOW = "\e[33m"
|
|
15
|
+
RED = "\e[31m"
|
|
16
|
+
CYAN = "\e[36m"
|
|
17
|
+
DIM = "\e[2m"
|
|
18
|
+
WHITE = "\e[97m"
|
|
19
|
+
|
|
20
|
+
TABLE_PALETTE = [
|
|
21
|
+
196, # red
|
|
22
|
+
208, # orange
|
|
23
|
+
220, # yellow
|
|
24
|
+
76, # green
|
|
25
|
+
48, # mint
|
|
26
|
+
39, # cyan
|
|
27
|
+
33, # blue
|
|
28
|
+
63, # indigo
|
|
29
|
+
129, # purple
|
|
30
|
+
170, # pink
|
|
31
|
+
214, # amber
|
|
32
|
+
109 # teal
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
BRIGHT_WHITE = "\e[1;97m"
|
|
36
|
+
|
|
37
|
+
def dim(str) = "#{DIM}#{str}#{RESET}"
|
|
38
|
+
def green(str) = "#{GREEN}#{str}#{RESET}"
|
|
39
|
+
def yellow(str) = "#{YELLOW}#{str}#{RESET}"
|
|
40
|
+
def red(str) = "#{RED}#{str}#{RESET}"
|
|
41
|
+
def cyan(str) = "#{CYAN}#{str}#{RESET}"
|
|
42
|
+
def bold(str) = "#{BOLD}#{str}#{RESET}"
|
|
43
|
+
def green_bold(str) = "#{GREEN}#{BOLD}#{str}#{RESET}"
|
|
44
|
+
def yellow_bold(str) = "#{YELLOW}#{BOLD}#{str}#{RESET}"
|
|
45
|
+
|
|
46
|
+
def table_color(table_name)
|
|
47
|
+
TABLE_PALETTE[Zlib.crc32(table_name.to_s) % TABLE_PALETTE.size]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def diff_tag_branch(name) = "\e[38;5;39m\u2387 #{name}#{RESET}"
|
|
51
|
+
def diff_tag_merging(name) = "\e[38;5;213m\u2B07 #{name}#{RESET}"
|
|
52
|
+
|
|
53
|
+
def landed_tag(date, fresh: false)
|
|
54
|
+
color = fresh ? "\e[38;5;220m" : DIM
|
|
55
|
+
"#{color}↪ #{date.strftime("%b %d")}#{RESET}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def table_tag(table_name)
|
|
59
|
+
color_code = table_color(table_name)
|
|
60
|
+
"\e[38;5;#{color_code}m● #{table_name}#{RESET}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def table_tags(table_names)
|
|
64
|
+
return "" if table_names.nil? || table_names.empty?
|
|
65
|
+
|
|
66
|
+
table_names.map { |t| table_tag(t) }.join(" ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Re-fits a pre-formatted table_tags string within max_width,
|
|
70
|
+
# showing full table names and "+N" for overflow.
|
|
71
|
+
# Accepts the full formatted string (with ANSI) and splits it into segments.
|
|
72
|
+
def table_tags_fitted(formatted_str, max_width)
|
|
73
|
+
return formatted_str if formatted_str.nil? || formatted_str.empty?
|
|
74
|
+
|
|
75
|
+
# Split into individual tag segments: each is "\e[38;5;NNNm● name\e[0m"
|
|
76
|
+
segments = formatted_str.scan(/\e\[38;5;\d+m● [^\e]+\e\[0m/)
|
|
77
|
+
return formatted_str if segments.empty?
|
|
78
|
+
|
|
79
|
+
total = segments.size
|
|
80
|
+
plain_segments = segments.map { |s| strip_ansi(s) }
|
|
81
|
+
|
|
82
|
+
# Try fitting all tags
|
|
83
|
+
return formatted_str if display_width(plain_segments.join(" ")) <= max_width
|
|
84
|
+
|
|
85
|
+
# Try fitting progressively fewer tags with +N suffix
|
|
86
|
+
(total - 1).downto(1) do |count|
|
|
87
|
+
remaining = total - count
|
|
88
|
+
suffix = " +#{remaining}"
|
|
89
|
+
candidate = plain_segments[0...count].join(" ") + suffix
|
|
90
|
+
if display_width(candidate) <= max_width
|
|
91
|
+
return segments[0...count].join(" ") + suffix
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Try fitting a truncated first table name + overflow.
|
|
96
|
+
# Need at least 7 chars: "● t… +N" (dot + color prefix use no visible width beyond the dot)
|
|
97
|
+
if max_width >= 7
|
|
98
|
+
suffix = (total > 1) ? " +#{total - 1}" : ""
|
|
99
|
+
# Available width for "● name…" part
|
|
100
|
+
avail = max_width - display_width(suffix)
|
|
101
|
+
# "● " prefix = 2 chars visible, plus at least 1 char of name + ellipsis (1 char)
|
|
102
|
+
if avail >= 4 # "● " (2) + at least 1 char + "…" (1)
|
|
103
|
+
first_plain = plain_segments[0] # e.g. "● some_table"
|
|
104
|
+
name_part = first_plain.sub(/^● /, "")
|
|
105
|
+
# Truncate name to fit: avail - 2 ("● ") - 1 ("…") = chars for name
|
|
106
|
+
name_max = avail - 2 - 1
|
|
107
|
+
truncated_name = name_part[0, name_max]
|
|
108
|
+
color_match = segments[0].match(/\e\[38;5;\d+m/)
|
|
109
|
+
color = color_match ? color_match[0] : ""
|
|
110
|
+
return "#{color}● #{truncated_name}…#{RESET}#{suffix}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Absolute fallback: just +N
|
|
115
|
+
"+#{total}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def format_timing(seconds)
|
|
119
|
+
milliseconds = (seconds * 1000).round(1)
|
|
120
|
+
|
|
121
|
+
timing_str = if milliseconds < 1
|
|
122
|
+
"#{(milliseconds * 1000).round(0)}μs"
|
|
123
|
+
else
|
|
124
|
+
"#{milliseconds}ms"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
cyan(timing_str)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def emoji(type)
|
|
131
|
+
case type
|
|
132
|
+
when :migrating then "🚀"
|
|
133
|
+
when :migrated then "✅"
|
|
134
|
+
when :reverting then "⏪"
|
|
135
|
+
when :reverted then "✅"
|
|
136
|
+
when :check then "✓"
|
|
137
|
+
when :status then "📊"
|
|
138
|
+
else ""
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def format_date(timestamp, mode = "full")
|
|
143
|
+
ts = timestamp.to_s
|
|
144
|
+
return ts if ts.length != 14
|
|
145
|
+
|
|
146
|
+
time = begin
|
|
147
|
+
Time.new(
|
|
148
|
+
ts[0..3].to_i, ts[4..5].to_i, ts[6..7].to_i,
|
|
149
|
+
ts[8..9].to_i, ts[10..11].to_i, ts[12..13].to_i
|
|
150
|
+
)
|
|
151
|
+
rescue ArgumentError
|
|
152
|
+
return ts
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
case mode
|
|
156
|
+
when "full"
|
|
157
|
+
time.strftime("%Y-%m-%d %H:%M:%S")
|
|
158
|
+
when "rel"
|
|
159
|
+
format_relative_time_from(time)
|
|
160
|
+
when "short"
|
|
161
|
+
time.strftime("%b %-d")
|
|
162
|
+
when /\Acustom\((.+)\)\z/
|
|
163
|
+
time.strftime($1)
|
|
164
|
+
else
|
|
165
|
+
time.strftime("%Y-%m-%d %H:%M:%S")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def format_relative_time_from(time)
|
|
172
|
+
diff = Time.now - time
|
|
173
|
+
return "just now" if diff < 0
|
|
174
|
+
|
|
175
|
+
minutes = diff.to_i / 60
|
|
176
|
+
hours = minutes / 60
|
|
177
|
+
days = hours / 24
|
|
178
|
+
weeks = days / 7
|
|
179
|
+
months = days / 30
|
|
180
|
+
years = days / 365
|
|
181
|
+
|
|
182
|
+
if minutes < 1 then "just now"
|
|
183
|
+
elsif hours < 1 then "~#{minutes}min ago"
|
|
184
|
+
elsif days < 1 then "~#{hours}hr ago"
|
|
185
|
+
elsif weeks < 1 then "~#{days}d ago"
|
|
186
|
+
elsif months < 1 then "~#{weeks}w ago"
|
|
187
|
+
elsif years < 1 then "~#{months}mo ago"
|
|
188
|
+
else "~#{years}y ago"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
public
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Railbow
|
|
6
|
+
# Runs git commands defensively: when the git binary is not installed,
|
|
7
|
+
# returns empty output and a failed status instead of raising Errno::ENOENT.
|
|
8
|
+
module GitUtils
|
|
9
|
+
MISSING_GIT_STATUS = Object.new
|
|
10
|
+
|
|
11
|
+
def MISSING_GIT_STATUS.success?
|
|
12
|
+
false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def capture2(*args)
|
|
18
|
+
Open3.capture2("git", *args)
|
|
19
|
+
rescue Errno::ENOENT
|
|
20
|
+
["", MISSING_GIT_STATUS]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def capture3(*args)
|
|
24
|
+
Open3.capture3("git", *args)
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
["", "", MISSING_GIT_STATUS]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|