ask-rails 0.3.0 → 0.11.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 +4 -4
- data/CHANGELOG.md +129 -0
- data/app/controllers/ask/rails/chat_controller.rb +145 -22
- data/app/models/ask/rails/session.rb +23 -0
- data/app/views/layouts/ask/rails/application.html.erb +364 -63
- data/config/routes.rb +3 -1
- data/lib/ask/rails/audit_log.rb +181 -0
- data/lib/ask/rails/configuration.rb +53 -1
- data/lib/ask/rails/environment_permissions.rb +39 -0
- data/lib/ask/rails/persistence.rb +18 -5
- data/lib/ask/rails/railtie.rb +5 -1
- data/lib/ask/rails/tool.rb +29 -0
- data/lib/ask/rails/tools/read_file.rb +1 -4
- data/lib/ask/rails/tools/read_routes.rb +1 -4
- data/lib/ask/rails/tools/route_inspector.rb +69 -0
- data/lib/ask/rails/tools/run_command.rb +39 -1
- data/lib/ask/rails/tools/schema_graph.rb +174 -0
- data/lib/ask/rails/tools/search_codebase.rb +6 -7
- data/lib/ask/rails/version.rb +1 -1
- data/lib/ask/rails.rb +105 -3
- data/lib/generators/ask/rails/install/install_generator.rb +5 -0
- data/lib/generators/ask/rails/install/templates/audit_log_migration.rb +22 -0
- data/lib/generators/ask/rails/install/templates/initializer.rb +1 -1
- metadata +7 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Tools
|
|
6
|
+
class SchemaGraph < Ask::Rails::Tool
|
|
7
|
+
description "Return the full application schema graph — all models, tables, columns with types, " \
|
|
8
|
+
"associations (belongs_to, has_many, has_one, HABTM, through), validations, indexes, " \
|
|
9
|
+
"and polymorphic relationships. One call gives the agent a complete mental model " \
|
|
10
|
+
"of the application's data layer."
|
|
11
|
+
|
|
12
|
+
param :detail, type: :string, desc: "Detail level: 'all' (default), 'models', 'associations', 'tables'", required: false
|
|
13
|
+
|
|
14
|
+
TABLE_EXCLUSIONS = %w[schema_migrations ar_internal_metadata].freeze
|
|
15
|
+
|
|
16
|
+
def execute(detail: "all")
|
|
17
|
+
models = discover_ar_models
|
|
18
|
+
|
|
19
|
+
result = {
|
|
20
|
+
summary: {
|
|
21
|
+
model_count: models.size,
|
|
22
|
+
table_count: models.map { |m| m.table_name }.uniq.size,
|
|
23
|
+
association_count: models.sum { |m| m.reflect_on_all_associations.size }
|
|
24
|
+
},
|
|
25
|
+
models: %w[all models].include?(detail) ? build_model_details(models, detail) : nil,
|
|
26
|
+
associations: %w[all associations].include?(detail) ? build_association_graph(models) : nil,
|
|
27
|
+
tables: %w[all tables].include?(detail) ? build_table_details(detail) : nil
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def discover_ar_models
|
|
36
|
+
if defined?(::Rails::Application) && ::Rails.application
|
|
37
|
+
::Rails.application.eager_load! rescue nil
|
|
38
|
+
end
|
|
39
|
+
ActiveRecord::Base.descendants.reject do |klass|
|
|
40
|
+
klass.abstract_class? ||
|
|
41
|
+
klass.name.nil? ||
|
|
42
|
+
TABLE_EXCLUSIONS.include?(klass.table_name) ||
|
|
43
|
+
klass.name.start_with?("ActiveRecord::", "ActiveStorage::", "ActionText::")
|
|
44
|
+
end.sort_by(&:name)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
[]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_model_details(models, detail)
|
|
50
|
+
models.filter_map do |klass|
|
|
51
|
+
name = klass.name rescue nil
|
|
52
|
+
next nil unless name
|
|
53
|
+
|
|
54
|
+
entry = {
|
|
55
|
+
name: name,
|
|
56
|
+
table_name: klass.table_name,
|
|
57
|
+
primary_key: safe_primary_key(klass)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if klass.respond_to?(:columns)
|
|
61
|
+
entry[:columns] = safe_columns(klass)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if klass.respond_to?(:reflect_on_all_associations)
|
|
65
|
+
entry[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
|
|
66
|
+
refs.map { |a|
|
|
67
|
+
assoc = { name: a.name, class_name: a.class_name }
|
|
68
|
+
assoc[:through] = a.options[:through] if a.options[:through]
|
|
69
|
+
assoc[:source] = a.options[:source] if a.options[:source]
|
|
70
|
+
assoc[:foreign_key] = a.foreign_key
|
|
71
|
+
assoc[:polymorphic] = true if a.polymorphic?
|
|
72
|
+
assoc[:as] = a.options[:as] if a.options[:as]
|
|
73
|
+
assoc[:dependent] = a.options[:dependent] if a.options[:dependent]
|
|
74
|
+
assoc
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if klass.respond_to?(:validators)
|
|
80
|
+
entry[:validators] = klass.validators.map { |v|
|
|
81
|
+
{
|
|
82
|
+
attribute: v.attributes.first&.to_s,
|
|
83
|
+
kind: v.kind,
|
|
84
|
+
options: v.options.reject { |k, _| k == :if }
|
|
85
|
+
}
|
|
86
|
+
}.reject { |v| v[:attribute].nil? }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
entry
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_association_graph(models)
|
|
94
|
+
edges = []
|
|
95
|
+
|
|
96
|
+
models.each do |klass|
|
|
97
|
+
klass.reflect_on_all_associations.each do |a|
|
|
98
|
+
next if a.macro == :has_many && a.options[:through] # Skip through associations (derived)
|
|
99
|
+
|
|
100
|
+
target_model = find_model_for_association(models, a)
|
|
101
|
+
next unless target_model
|
|
102
|
+
|
|
103
|
+
edges << {
|
|
104
|
+
from: klass.name,
|
|
105
|
+
to: target_model.name,
|
|
106
|
+
type: a.macro,
|
|
107
|
+
via: a.name,
|
|
108
|
+
foreign_key: a.foreign_key,
|
|
109
|
+
polymorphic: a.polymorphic? || false
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
edges
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def find_model_for_association(models, association)
|
|
118
|
+
class_name = association.class_name
|
|
119
|
+
# Try exact match first, then match by class name suffix (handles namespace issues)
|
|
120
|
+
models.find { |m| m.name == class_name } ||
|
|
121
|
+
models.find { |m| m.name.end_with?("::#{class_name}") } ||
|
|
122
|
+
models.find { |m| class_name.end_with?("::#{m.name}") }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_table_details(detail)
|
|
126
|
+
models = discover_ar_models
|
|
127
|
+
tables = {}
|
|
128
|
+
|
|
129
|
+
models.each do |klass|
|
|
130
|
+
table = klass.table_name
|
|
131
|
+
next if TABLE_EXCLUSIONS.include?(table)
|
|
132
|
+
|
|
133
|
+
tables[table] = {
|
|
134
|
+
model: klass.name,
|
|
135
|
+
columns: safe_columns(klass),
|
|
136
|
+
indexes: fetch_indexes_for(table)
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
tables
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def safe_primary_key(klass)
|
|
144
|
+
klass.primary_key
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def safe_columns(klass)
|
|
150
|
+
klass.columns.map { |c|
|
|
151
|
+
col = { name: c.name, type: c.type, null: c.null }
|
|
152
|
+
col[:default] = c.default unless c.default.nil?
|
|
153
|
+
col[:primary_key] = true if c.name == klass.primary_key
|
|
154
|
+
col
|
|
155
|
+
}
|
|
156
|
+
rescue StandardError
|
|
157
|
+
[]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def fetch_indexes_for(table_name)
|
|
161
|
+
ActiveRecord::Base.connection.indexes(table_name).map do |idx|
|
|
162
|
+
{
|
|
163
|
+
name: idx.name,
|
|
164
|
+
columns: idx.columns,
|
|
165
|
+
unique: idx.unique
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
rescue StandardError
|
|
169
|
+
[]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -6,15 +6,14 @@ module Ask
|
|
|
6
6
|
class SearchCodebase < Ask::Rails::Tool
|
|
7
7
|
description "Search the Rails codebase with grep."
|
|
8
8
|
param :pattern, type: :string, desc: "Search pattern", required: true
|
|
9
|
-
param :path, type: :string, desc: "Subdirectory to search (optional)"
|
|
9
|
+
param :path, type: :string, desc: "Subdirectory to search (optional)", required: false
|
|
10
10
|
|
|
11
11
|
def execute(pattern:, path: nil)
|
|
12
|
-
search_path = path ? rails_root.join(path) : rails_root
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
12
|
+
search_path = (path ? rails_root.join(path) : rails_root).to_s
|
|
13
|
+
escaped_pattern = pattern.gsub("'", "'\\\\''")
|
|
14
|
+
escaped_path = search_path.gsub("'", "'\\\\''")
|
|
15
|
+
results = `cd #{rails_root} && grep -rn '#{escaped_pattern}' #{escaped_path} 2>&1 | head -50`
|
|
16
|
+
{ results: results.lines.map(&:chomp), count: results.lines.count }
|
|
18
17
|
end
|
|
19
18
|
end
|
|
20
19
|
end
|
data/lib/ask/rails/version.rb
CHANGED
data/lib/ask/rails.rb
CHANGED
|
@@ -16,21 +16,41 @@ module Ask
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def agent_session(**extra)
|
|
19
|
+
# Auto-prune if configured
|
|
20
|
+
cleanup! if configuration.max_session_age || configuration.max_sessions
|
|
21
|
+
|
|
19
22
|
tools = configuration.tools.map { |t| t.is_a?(Class) ? t.new : t }
|
|
20
23
|
prompt = extra.delete(:system_prompt) || configuration.system_prompt || default_system_prompt
|
|
21
24
|
|
|
25
|
+
# Resolve environment-specific permissions and wire into agent hooks
|
|
26
|
+
hooks = build_environment_hooks
|
|
27
|
+
|
|
22
28
|
Ask::Agent::Session.new(
|
|
23
29
|
model: configuration.default_model,
|
|
24
30
|
max_turns: configuration.max_turns,
|
|
25
31
|
system_prompt: prompt,
|
|
26
32
|
tools: tools,
|
|
27
|
-
|
|
33
|
+
state: configuration.persistence_adapter,
|
|
34
|
+
hooks: hooks,
|
|
28
35
|
**extra
|
|
29
36
|
)
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
def discover_tools!
|
|
33
|
-
self.configuration.tools = Ask::Tools::Shell::TOOLS.map(&:new) +
|
|
40
|
+
self.configuration.tools = Ask::Tools::Shell::TOOLS.map(&:new) + core_rails_tools + discovered_user_tools
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Prune old sessions and audit logs based on configuration limits.
|
|
44
|
+
#
|
|
45
|
+
# Removes sessions older than +max_session_age+ seconds, and limits the
|
|
46
|
+
# total number of sessions to +max_sessions+ (deleting the oldest first).
|
|
47
|
+
# Audit log entries older than the oldest kept session are also removed.
|
|
48
|
+
#
|
|
49
|
+
# Call manually via rake task or cron, or configure limits to auto-prune
|
|
50
|
+
# on agent_session creation.
|
|
51
|
+
def cleanup!
|
|
52
|
+
prune_old_sessions
|
|
53
|
+
limit_session_count
|
|
34
54
|
end
|
|
35
55
|
|
|
36
56
|
def root
|
|
@@ -39,7 +59,76 @@ module Ask
|
|
|
39
59
|
|
|
40
60
|
private
|
|
41
61
|
|
|
42
|
-
def
|
|
62
|
+
def build_environment_hooks
|
|
63
|
+
env_mode = configuration.effective_mode
|
|
64
|
+
return {} unless env_mode
|
|
65
|
+
|
|
66
|
+
perms = Ask::Agent::Extensions::Permissions.new(mode: env_mode)
|
|
67
|
+
{ before_tool: [perms.method(:before_tool_call)] }
|
|
68
|
+
rescue ArgumentError => e
|
|
69
|
+
warn "[ask-rails] Invalid environment mode: #{e.message}"
|
|
70
|
+
{}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def prune_old_sessions
|
|
74
|
+
age = configuration.max_session_age
|
|
75
|
+
return unless age&.> 0
|
|
76
|
+
return unless persistence_available?
|
|
77
|
+
|
|
78
|
+
cutoff = age.seconds.ago
|
|
79
|
+
count = 0
|
|
80
|
+
|
|
81
|
+
configuration.persistence_adapter.list.each do |id|
|
|
82
|
+
data = configuration.persistence_adapter.load(id)
|
|
83
|
+
created = data&.dig(:metadata, :created_at)
|
|
84
|
+
if created && Time.parse(created) < cutoff
|
|
85
|
+
configuration.persistence_adapter.delete(id)
|
|
86
|
+
count += 1
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
count
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def limit_session_count
|
|
96
|
+
max = configuration.max_sessions
|
|
97
|
+
return unless max&.> 0
|
|
98
|
+
return unless persistence_available?
|
|
99
|
+
|
|
100
|
+
sessions = configuration.persistence_adapter.list
|
|
101
|
+
excess = sessions.size - max
|
|
102
|
+
return unless excess > 0
|
|
103
|
+
|
|
104
|
+
# Delete oldest sessions first
|
|
105
|
+
with_timestamps = sessions.map { |id|
|
|
106
|
+
data = configuration.persistence_adapter.load(id)
|
|
107
|
+
created = data&.dig(:metadata, :created_at)
|
|
108
|
+
[id, created ? Time.parse(created) : Time.at(0)]
|
|
109
|
+
}.sort_by(&:last)
|
|
110
|
+
|
|
111
|
+
with_timestamps.first(excess).each do |id, _|
|
|
112
|
+
configuration.persistence_adapter.delete(id)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
excess
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def persistence_available?
|
|
121
|
+
defined?(ActiveRecord::Base) &&
|
|
122
|
+
ActiveRecord::Base.connection.data_source_exists?("ask_sessions")
|
|
123
|
+
rescue StandardError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def core_rails_tools
|
|
128
|
+
CORE_RAILS_TOOLS.map(&:new)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def discovered_user_tools
|
|
43
132
|
tools = []
|
|
44
133
|
files = Dir[::Rails.root.join("app", "tools", "*.rb")]
|
|
45
134
|
files.each do |f|
|
|
@@ -67,6 +156,8 @@ end
|
|
|
67
156
|
require_relative "rails/version"
|
|
68
157
|
require_relative "rails/engine"
|
|
69
158
|
require_relative "rails/configuration"
|
|
159
|
+
require_relative "rails/audit_log"
|
|
160
|
+
require_relative "rails/environment_permissions"
|
|
70
161
|
require_relative "rails/auth"
|
|
71
162
|
require_relative "rails/persistence"
|
|
72
163
|
require_relative "rails/service_discovery"
|
|
@@ -78,8 +169,19 @@ require_relative "rails/tools/read_routes"
|
|
|
78
169
|
require_relative "rails/tools/query_database"
|
|
79
170
|
require_relative "rails/tools/read_model"
|
|
80
171
|
require_relative "rails/tools/read_log"
|
|
172
|
+
require_relative "rails/tools/schema_graph"
|
|
173
|
+
require_relative "rails/tools/route_inspector"
|
|
81
174
|
|
|
82
175
|
# Railtie is loaded only when Rails is fully available
|
|
83
176
|
if defined?(::Rails::Railtie)
|
|
84
177
|
require_relative "rails/railtie"
|
|
85
178
|
end
|
|
179
|
+
|
|
180
|
+
# Define after all tool files are loaded so the constants resolve
|
|
181
|
+
Ask::Rails::CORE_RAILS_TOOLS = [
|
|
182
|
+
Ask::Rails::Tools::ReadFile, Ask::Rails::Tools::RunCommand,
|
|
183
|
+
Ask::Rails::Tools::SearchCodebase, Ask::Rails::Tools::ReadRoutes,
|
|
184
|
+
Ask::Rails::Tools::QueryDatabase, Ask::Rails::Tools::ReadModel,
|
|
185
|
+
Ask::Rails::Tools::ReadLog, Ask::Rails::Tools::SchemaGraph,
|
|
186
|
+
Ask::Rails::Tools::RouteInspector
|
|
187
|
+
].freeze
|
|
@@ -19,6 +19,11 @@ module Ask
|
|
|
19
19
|
template "migration.rb", "db/migrate/#{timestamp}_create_ask_sessions.rb"
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def create_audit_log_migration
|
|
23
|
+
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
24
|
+
template "audit_log_migration.rb", "db/migrate/#{timestamp}_create_ask_audit_logs.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
22
27
|
def create_tools_directory
|
|
23
28
|
empty_directory "app/tools"
|
|
24
29
|
create_file "app/tools/.keep", ""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateAskAuditLogs < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :ask_audit_logs do |t|
|
|
4
|
+
t.string :session_id, null: false
|
|
5
|
+
t.string :tool_name, null: false
|
|
6
|
+
t.jsonb :params
|
|
7
|
+
t.jsonb :result_summary
|
|
8
|
+
t.string :status, null: false, default: "success"
|
|
9
|
+
t.text :error_message
|
|
10
|
+
t.integer :duration_ms
|
|
11
|
+
t.jsonb :user_context
|
|
12
|
+
t.string :environment
|
|
13
|
+
t.datetime :recorded_at, null: false
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :ask_audit_logs, :session_id
|
|
19
|
+
add_index :ask_audit_logs, [:recorded_at, :tool_name]
|
|
20
|
+
add_index :ask_audit_logs, :status
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Ask::Rails.configure do |config|
|
|
2
2
|
config.default_model = ENV.fetch("ASK_DEFAULT_MODEL", "gpt-4o")
|
|
3
3
|
config.max_turns = ENV.fetch("ASK_MAX_TURNS", 25).to_i
|
|
4
|
-
|
|
4
|
+
config.persistence_adapter = Ask::Rails::Persistence.new
|
|
5
5
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -147,14 +147,17 @@ files:
|
|
|
147
147
|
- LICENSE
|
|
148
148
|
- README.md
|
|
149
149
|
- app/controllers/ask/rails/chat_controller.rb
|
|
150
|
+
- app/models/ask/rails/session.rb
|
|
150
151
|
- app/views/ask/rails/chat/index.html.erb
|
|
151
152
|
- app/views/layouts/ask/rails/application.html.erb
|
|
152
153
|
- config/routes.rb
|
|
153
154
|
- lib/ask-rails.rb
|
|
154
155
|
- lib/ask/rails.rb
|
|
156
|
+
- lib/ask/rails/audit_log.rb
|
|
155
157
|
- lib/ask/rails/auth.rb
|
|
156
158
|
- lib/ask/rails/configuration.rb
|
|
157
159
|
- lib/ask/rails/engine.rb
|
|
160
|
+
- lib/ask/rails/environment_permissions.rb
|
|
158
161
|
- lib/ask/rails/persistence.rb
|
|
159
162
|
- lib/ask/rails/railtie.rb
|
|
160
163
|
- lib/ask/rails/service_discovery.rb
|
|
@@ -164,13 +167,16 @@ files:
|
|
|
164
167
|
- lib/ask/rails/tools/read_log.rb
|
|
165
168
|
- lib/ask/rails/tools/read_model.rb
|
|
166
169
|
- lib/ask/rails/tools/read_routes.rb
|
|
170
|
+
- lib/ask/rails/tools/route_inspector.rb
|
|
167
171
|
- lib/ask/rails/tools/run_command.rb
|
|
172
|
+
- lib/ask/rails/tools/schema_graph.rb
|
|
168
173
|
- lib/ask/rails/tools/search_codebase.rb
|
|
169
174
|
- lib/ask/rails/version.rb
|
|
170
175
|
- lib/ask/skills/rails.db_debug/SKILL.md
|
|
171
176
|
- lib/ask/skills/rails.deploy_pipeline/SKILL.md
|
|
172
177
|
- lib/ask/skills/rails.route_trouble/SKILL.md
|
|
173
178
|
- lib/generators/ask/rails/install/install_generator.rb
|
|
179
|
+
- lib/generators/ask/rails/install/templates/audit_log_migration.rb
|
|
174
180
|
- lib/generators/ask/rails/install/templates/initializer.rb
|
|
175
181
|
- lib/generators/ask/rails/install/templates/migration.rb
|
|
176
182
|
homepage: https://github.com/ask-rb/ask-rails
|