rubyn-code 0.4.0 → 0.5.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/README.md +186 -2
- data/lib/rubyn_code/agent/conversation.rb +2 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +2 -1
- data/lib/rubyn_code/agent/llm_caller.rb +4 -2
- data/lib/rubyn_code/agent/loop.rb +7 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +39 -0
- data/lib/rubyn_code/agent/tool_processor.rb +4 -2
- data/lib/rubyn_code/cli/app.rb +85 -11
- data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
- data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
- data/lib/rubyn_code/cli/commands/provider.rb +2 -1
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +4 -2
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- data/lib/rubyn_code/cli/repl.rb +11 -1
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/repl_setup.rb +38 -1
- data/lib/rubyn_code/config/defaults.rb +2 -0
- data/lib/rubyn_code/config/settings.rb +5 -2
- data/lib/rubyn_code/context/context_budget.rb +2 -1
- data/lib/rubyn_code/context/manager.rb +3 -3
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
- data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
- data/lib/rubyn_code/ide/protocol.rb +2 -1
- data/lib/rubyn_code/index/codebase_index.rb +2 -1
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- data/lib/rubyn_code/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
- data/lib/rubyn_code/output/diff_renderer.rb +3 -2
- data/lib/rubyn_code/self_test.rb +2 -1
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +10 -0
- data/lib/rubyn_code/skills/document.rb +8 -2
- data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
- data/lib/rubyn_code/skills/loader.rb +1 -1
- data/lib/rubyn_code/skills/matcher.rb +89 -0
- data/lib/rubyn_code/skills/pack_context.rb +163 -0
- data/lib/rubyn_code/skills/pack_installer.rb +194 -0
- data/lib/rubyn_code/skills/pack_manager.rb +230 -0
- data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
- data/lib/rubyn_code/skills/registry_client.rb +241 -0
- data/lib/rubyn_code/tools/executor.rb +4 -2
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
- data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +3 -6
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +12 -0
- data/skills/rubyn_self_test.md +75 -0
- metadata +13 -1
|
@@ -97,7 +97,8 @@ module RubynCode
|
|
|
97
97
|
table
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
# -- LCS algorithm step
|
|
101
|
+
def fill_lcs_row(table, row, old_lines, new_lines, col_count)
|
|
101
102
|
(1..col_count).each do |col|
|
|
102
103
|
table[row][col] = if old_lines[row - 1] == new_lines[col - 1]
|
|
103
104
|
table[row - 1][col - 1] + 1
|
|
@@ -121,7 +122,7 @@ module RubynCode
|
|
|
121
122
|
result
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/
|
|
125
|
+
def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/ParameterLists -- LCS backtrack step requires all state
|
|
125
126
|
if lines_match?(old_lines, new_lines, old_idx, new_idx)
|
|
126
127
|
result.unshift([:equal, old_idx - 1, new_idx - 1])
|
|
127
128
|
[old_idx - 1, new_idx - 1]
|
data/lib/rubyn_code/self_test.rb
CHANGED
|
@@ -53,7 +53,8 @@ module RubynCode
|
|
|
53
53
|
record('File read (version.rb)', content.include?('VERSION ='))
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
# -- sequential file ops
|
|
57
|
+
def check_file_write_edit_cleanup
|
|
57
58
|
tmp = File.join(project_root, '.rubyn-code/self_test_tmp.rb')
|
|
58
59
|
FileUtils.mkdir_p(File.dirname(tmp))
|
|
59
60
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'gemfile_parser'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Skills
|
|
8
|
+
# Suggests skill packs based on gems detected in the project's Gemfile.
|
|
9
|
+
#
|
|
10
|
+
# On session start, parses the Gemfile, queries the registry for matching
|
|
11
|
+
# packs, and shows a one-time suggestion. Tracks shown suggestions in
|
|
12
|
+
# `.rubyn-code/suggested.json` to avoid repeating.
|
|
13
|
+
class AutoSuggest
|
|
14
|
+
SUGGESTED_FILE = 'suggested.json'
|
|
15
|
+
|
|
16
|
+
# @param project_root [String]
|
|
17
|
+
# @param registry_client [RegistryClient]
|
|
18
|
+
def initialize(project_root:, registry_client: nil)
|
|
19
|
+
@project_root = project_root
|
|
20
|
+
@client = registry_client || RegistryClient.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check for suggestable packs and return a display message if any.
|
|
24
|
+
# Returns nil if no suggestions or if all have been shown/dismissed.
|
|
25
|
+
#
|
|
26
|
+
# This method never raises — registry failures are silently swallowed
|
|
27
|
+
# to avoid blocking session start.
|
|
28
|
+
#
|
|
29
|
+
# @return [String, nil] suggestion message or nil
|
|
30
|
+
def check
|
|
31
|
+
gems = parse_gemfile
|
|
32
|
+
return nil if gems.empty?
|
|
33
|
+
|
|
34
|
+
suggestions = fetch_suggestions(gems)
|
|
35
|
+
return nil if suggestions.empty?
|
|
36
|
+
|
|
37
|
+
new_suggestions = filter_shown(suggestions)
|
|
38
|
+
return nil if new_suggestions.empty?
|
|
39
|
+
|
|
40
|
+
record_shown(new_suggestions)
|
|
41
|
+
format_message(new_suggestions)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Mark a pack as installed so it won't be suggested again.
|
|
47
|
+
#
|
|
48
|
+
# @param name [String] pack name
|
|
49
|
+
def mark_installed(name)
|
|
50
|
+
state = load_state
|
|
51
|
+
state['installed'] ||= []
|
|
52
|
+
state['installed'] << name unless state['installed'].include?(name)
|
|
53
|
+
save_state(state)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Mark a suggestion as dismissed.
|
|
57
|
+
#
|
|
58
|
+
# @param name [String] pack name
|
|
59
|
+
def mark_dismissed(name)
|
|
60
|
+
state = load_state
|
|
61
|
+
state['dismissed'] ||= []
|
|
62
|
+
state['dismissed'] << name unless state['dismissed'].include?(name)
|
|
63
|
+
save_state(state)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def parse_gemfile
|
|
69
|
+
gemfile_path = File.join(@project_root, 'Gemfile')
|
|
70
|
+
return [] unless File.exist?(gemfile_path)
|
|
71
|
+
|
|
72
|
+
GemfileParser.gems(File.read(gemfile_path))
|
|
73
|
+
rescue StandardError
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch_suggestions(gems)
|
|
78
|
+
@client.fetch_suggestions(gems)
|
|
79
|
+
rescue RegistryError
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def filter_shown(suggestions)
|
|
84
|
+
state = load_state
|
|
85
|
+
shown = Array(state['shown'])
|
|
86
|
+
installed = Array(state['installed'])
|
|
87
|
+
dismissed = Array(state['dismissed'])
|
|
88
|
+
skip = (shown + installed + dismissed).uniq
|
|
89
|
+
|
|
90
|
+
suggestions.reject { |s| skip.include?(s['name']) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def record_shown(suggestions)
|
|
94
|
+
state = load_state
|
|
95
|
+
state['shown'] ||= []
|
|
96
|
+
suggestions.each do |s|
|
|
97
|
+
state['shown'] << s['name'] unless state['shown'].include?(s['name'])
|
|
98
|
+
end
|
|
99
|
+
save_state(state)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def format_message(suggestions)
|
|
103
|
+
gem_names = suggestions.map { |s| s['name'] }.join(', ')
|
|
104
|
+
details = suggestions.map { |s| "#{s['name']} (#{s['reason']})" }.join(', ')
|
|
105
|
+
install_cmd = "/install-skills #{suggestions.map { |s| s['name'] }.join(' ')}"
|
|
106
|
+
|
|
107
|
+
"Skill packs available: #{details}\n" \
|
|
108
|
+
"Run #{install_cmd} to install."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def load_state
|
|
112
|
+
path = state_path
|
|
113
|
+
return {} unless File.exist?(path)
|
|
114
|
+
|
|
115
|
+
JSON.parse(File.read(path))
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def save_state(state)
|
|
121
|
+
dir = File.dirname(state_path)
|
|
122
|
+
FileUtils.mkdir_p(dir)
|
|
123
|
+
File.write(state_path, JSON.pretty_generate(state))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def state_path
|
|
127
|
+
File.join(@project_root, '.rubyn-code', SUGGESTED_FILE)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -24,6 +24,13 @@ module RubynCode
|
|
|
24
24
|
@index
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Force the index to be rebuilt on next access. Used after installing
|
|
28
|
+
# a skill pack so newly-written files become discoverable in the same
|
|
29
|
+
# session.
|
|
30
|
+
def refresh!
|
|
31
|
+
@index = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
def list
|
|
28
35
|
available.map { |e| e[:name] }
|
|
29
36
|
end
|
|
@@ -103,6 +110,9 @@ module RubynCode
|
|
|
103
110
|
name: name,
|
|
104
111
|
description: doc.description,
|
|
105
112
|
tags: doc.tags,
|
|
113
|
+
triggers: doc.triggers,
|
|
114
|
+
gems: doc.gems,
|
|
115
|
+
rails: doc.rails,
|
|
106
116
|
path: File.expand_path(path)
|
|
107
117
|
}
|
|
108
118
|
rescue StandardError
|
|
@@ -7,12 +7,15 @@ module RubynCode
|
|
|
7
7
|
class Document
|
|
8
8
|
FRONTMATTER_PATTERN = /\A---\s*\n(.+?\n)---\s*\n(.*)\z/m
|
|
9
9
|
|
|
10
|
-
attr_reader :name, :description, :tags, :body
|
|
10
|
+
attr_reader :name, :description, :tags, :triggers, :gems, :rails, :body
|
|
11
11
|
|
|
12
|
-
def initialize(name:, description:, tags:, body:)
|
|
12
|
+
def initialize(name:, description:, tags:, body:, triggers: [], gems: [], rails: nil) # rubocop:disable Metrics/ParameterLists -- frontmatter-driven, keyword-only is clearer than a metadata hash
|
|
13
13
|
@name = name
|
|
14
14
|
@description = description
|
|
15
15
|
@tags = tags
|
|
16
|
+
@triggers = triggers
|
|
17
|
+
@gems = gems
|
|
18
|
+
@rails = rails
|
|
16
19
|
@body = body
|
|
17
20
|
end
|
|
18
21
|
|
|
@@ -28,6 +31,9 @@ module RubynCode
|
|
|
28
31
|
name: frontmatter['name'].to_s,
|
|
29
32
|
description: frontmatter['description'].to_s,
|
|
30
33
|
tags: Array(frontmatter['tags']),
|
|
34
|
+
triggers: Array(frontmatter['triggers']).map(&:to_s),
|
|
35
|
+
gems: Array(frontmatter['gems']).map(&:to_s),
|
|
36
|
+
rails: frontmatter['rails']&.to_s,
|
|
31
37
|
body: match[2].to_s.strip
|
|
32
38
|
)
|
|
33
39
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Skills
|
|
5
|
+
# Parses Gemfiles to extract gem names for skill pack matching.
|
|
6
|
+
# Handles standard gem declarations and grouped gems.
|
|
7
|
+
#
|
|
8
|
+
# Recognized patterns:
|
|
9
|
+
# gem 'stripe'
|
|
10
|
+
# gem 'stripe', '~> 8.0'
|
|
11
|
+
# gem 'pundit', require: false
|
|
12
|
+
# gem 'sidekiq', '>= 6.0', group: :workers
|
|
13
|
+
#
|
|
14
|
+
# Does NOT match comments or source declarations.
|
|
15
|
+
class GemfileParser
|
|
16
|
+
GEM_PATTERN = /
|
|
17
|
+
^\s*gem\s+
|
|
18
|
+
['"]([^'"]+)['"]
|
|
19
|
+
/x
|
|
20
|
+
|
|
21
|
+
# Extract unique gem names from Gemfile content.
|
|
22
|
+
#
|
|
23
|
+
# @param content [String] raw Gemfile content
|
|
24
|
+
# @return [Array<String>] gem names (lowercase)
|
|
25
|
+
def self.gems(content)
|
|
26
|
+
new(content).gems
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(content)
|
|
30
|
+
@content = content
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def gems
|
|
34
|
+
return [] if @content.to_s.strip.empty?
|
|
35
|
+
|
|
36
|
+
@content.scan(GEM_PATTERN).flatten.map { |m| m.strip.downcase }.uniq
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -42,7 +42,7 @@ module RubynCode
|
|
|
42
42
|
# @param codebase_index [RubynCode::Index::CodebaseIndex, nil]
|
|
43
43
|
# @param project_profile [Object, nil] reserved for future profile-based hints
|
|
44
44
|
# @return [Array<String>] suggested skill names (not loaded automatically)
|
|
45
|
-
def suggest_skills(codebase_index: nil, project_profile: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
45
|
+
def suggest_skills(codebase_index: nil, project_profile: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
46
46
|
return [] unless codebase_index
|
|
47
47
|
|
|
48
48
|
suggestions = []
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'gemfile_parser'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Skills
|
|
7
|
+
# Selects which skills to auto-load for a given user message.
|
|
8
|
+
#
|
|
9
|
+
# A skill is selected when:
|
|
10
|
+
# - its `triggers:` frontmatter list has at least one case-insensitive
|
|
11
|
+
# substring hit against the user message, AND
|
|
12
|
+
# - every gem in its `gems:` list is present in the project's Gemfile
|
|
13
|
+
# (skills with no `gems:` are unrestricted; if there is no Gemfile,
|
|
14
|
+
# gem gating is skipped entirely), AND
|
|
15
|
+
# - it has not already been selected by this matcher in the current
|
|
16
|
+
# session (per-instance dedup).
|
|
17
|
+
#
|
|
18
|
+
# Match scope is the latest user turn only. There is no cap on the
|
|
19
|
+
# number of matches per turn.
|
|
20
|
+
class Matcher
|
|
21
|
+
def initialize(catalog:, project_root: nil)
|
|
22
|
+
@catalog = catalog
|
|
23
|
+
@project_root = project_root
|
|
24
|
+
@loaded = Set.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Find skills whose triggers match `user_message`.
|
|
28
|
+
#
|
|
29
|
+
# @param user_message [String]
|
|
30
|
+
# @return [Array<Hash>] catalog entries to load
|
|
31
|
+
def match(user_message)
|
|
32
|
+
text = user_message.to_s.downcase
|
|
33
|
+
return [] if text.empty?
|
|
34
|
+
|
|
35
|
+
available_gems_set = available_gems
|
|
36
|
+
@catalog.available.filter_map do |entry|
|
|
37
|
+
next unless eligible?(entry, text, available_gems_set)
|
|
38
|
+
|
|
39
|
+
@loaded << entry[:name]
|
|
40
|
+
entry
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Names of skills selected so far in this session.
|
|
45
|
+
def loaded
|
|
46
|
+
@loaded.to_a
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def eligible?(entry, text, available_gems_set)
|
|
52
|
+
return false if @loaded.include?(entry[:name])
|
|
53
|
+
|
|
54
|
+
triggers = entry[:triggers]
|
|
55
|
+
return false if triggers.nil? || triggers.empty?
|
|
56
|
+
return false unless triggers_match?(triggers, text)
|
|
57
|
+
|
|
58
|
+
gem_gate_pass?(entry[:gems], available_gems_set)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def triggers_match?(triggers, text)
|
|
62
|
+
triggers.any? { |t| text.include?(t.to_s.downcase) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Pass when:
|
|
66
|
+
# - skill declares no gem dependencies, or
|
|
67
|
+
# - we can't read a Gemfile (no project root / no Gemfile / parse error),
|
|
68
|
+
# so we don't gate based on missing data, or
|
|
69
|
+
# - every required gem is in the Gemfile.
|
|
70
|
+
def gem_gate_pass?(required_gems, available_gems_set)
|
|
71
|
+
return true if required_gems.nil? || required_gems.empty?
|
|
72
|
+
return true if available_gems_set.nil?
|
|
73
|
+
|
|
74
|
+
required_gems.all? { |g| available_gems_set.include?(g.to_s.downcase) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def available_gems
|
|
78
|
+
return nil unless @project_root
|
|
79
|
+
|
|
80
|
+
gemfile = File.join(@project_root, 'Gemfile')
|
|
81
|
+
return nil unless File.exist?(gemfile)
|
|
82
|
+
|
|
83
|
+
GemfileParser.gems(File.read(gemfile)).to_set
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'gemfile_parser'
|
|
4
|
+
require_relative 'registry_client'
|
|
5
|
+
require_relative 'loader'
|
|
6
|
+
require_relative 'catalog'
|
|
7
|
+
|
|
8
|
+
module RubynCode
|
|
9
|
+
module Skills
|
|
10
|
+
# Builds additional skill context for PR reviews based on detected gems.
|
|
11
|
+
#
|
|
12
|
+
# On GitHub App runs (or any run where a Gemfile is present in the repo):
|
|
13
|
+
# 1. Parse the Gemfile for gem names
|
|
14
|
+
# 2. Fetch matching packs from the registry API
|
|
15
|
+
# 3. Format pack skills into a context block for the review agent
|
|
16
|
+
#
|
|
17
|
+
# The GitHub App has access to ALL packs as a premium differentiator.
|
|
18
|
+
# No local `/install-skills` required — everything is fetched transparently.
|
|
19
|
+
#
|
|
20
|
+
# Usage:
|
|
21
|
+
# context = PackContext.for_repo(
|
|
22
|
+
# project_root: '/path/to/repo',
|
|
23
|
+
# registry_url: 'https://rubyn.ai'
|
|
24
|
+
# )
|
|
25
|
+
# review_prompt = context.build_review_context(diff_content)
|
|
26
|
+
class PackContext
|
|
27
|
+
SKILL_NAME_OVERRIDES = {
|
|
28
|
+
'stripe' => 'stripe/webhooks'
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Factory: build a PackContext for a given repo.
|
|
32
|
+
#
|
|
33
|
+
# @param project_root [String] path to the repository
|
|
34
|
+
# @param registry_url [String, nil] override for registry URL
|
|
35
|
+
# @return [PackContext] ready to build context
|
|
36
|
+
def self.for_repo(project_root:, registry_url: nil)
|
|
37
|
+
gemfile_path = File.join(project_root, 'Gemfile')
|
|
38
|
+
content = File.read(gemfile_path, encoding: 'UTF-8') if File.exist?(gemfile_path)
|
|
39
|
+
gems = content ? GemfileParser.gems(content) : []
|
|
40
|
+
client = RegistryClient.new(base_url: registry_url)
|
|
41
|
+
new(gems: gems, registry_client: client)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Build context for an external/GitHub App context where we need to
|
|
45
|
+
# fetch the full pack content (not just local skills).
|
|
46
|
+
#
|
|
47
|
+
# @param pack_names [Array<String>] names of packs to load
|
|
48
|
+
# @return [String] context block to prepend to the review prompt
|
|
49
|
+
def self.for_packs(pack_names:, registry_client:)
|
|
50
|
+
new(gems: pack_names, registry_client: registry_client).build_context_block
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :gems
|
|
54
|
+
|
|
55
|
+
def initialize(gems:, registry_client:)
|
|
56
|
+
@gems = gems
|
|
57
|
+
@registry_client = registry_client
|
|
58
|
+
@cache = {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the list of packs that matched detected gems.
|
|
62
|
+
# Some gems map to pack names (e.g. stripe → stripe/webhooks).
|
|
63
|
+
#
|
|
64
|
+
# Tradeoff: every detected gem that doesn't appear in SKILL_NAME_OVERRIDES
|
|
65
|
+
# is queried against the registry as a potential pack name. For a typical
|
|
66
|
+
# Rails app with 40-80 gems this means up to 80 sequential registry calls,
|
|
67
|
+
# each returning a 404 for unknown packs. This is intentional for now:
|
|
68
|
+
# - Responses are cached in @cache so repeated calls within a session are free
|
|
69
|
+
# - The GitHub App context is latency-tolerant (async review runs)
|
|
70
|
+
# - A future batch endpoint on the registry API can reduce this to one call
|
|
71
|
+
# If latency becomes a problem, add a KNOWN_PACKS allowlist and skip gems
|
|
72
|
+
# that are not in it before fetching.
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<String>] pack names
|
|
75
|
+
def matched_packs
|
|
76
|
+
@matched_packs ||= gems.filter_map { |gem| pack_name_for(gem) }.uniq
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Fetch and cache pack content from registry.
|
|
80
|
+
#
|
|
81
|
+
# RegistryClient#fetch_pack returns a { data:, etag:, not_modified: } wrapper.
|
|
82
|
+
# We cache and return only the :data payload so callers work with pack attributes
|
|
83
|
+
# directly (e.g. :description, :files) rather than the transport envelope.
|
|
84
|
+
#
|
|
85
|
+
# @param pack_name [String]
|
|
86
|
+
# @return [Hash, nil] pack data or nil if not found
|
|
87
|
+
def fetch_pack(pack_name)
|
|
88
|
+
return @cache[pack_name] if @cache.key?(pack_name)
|
|
89
|
+
|
|
90
|
+
result = @registry_client.fetch_pack(pack_name)
|
|
91
|
+
@cache[pack_name] = result[:data]
|
|
92
|
+
rescue RegistryError
|
|
93
|
+
@cache[pack_name] = nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Build a context block listing all detected packs and their skills.
|
|
97
|
+
# This is prepended to the review prompt so the agent can apply them.
|
|
98
|
+
#
|
|
99
|
+
# @return [String] context block
|
|
100
|
+
def build_context_block
|
|
101
|
+
return '' if matched_packs.empty?
|
|
102
|
+
|
|
103
|
+
lines = []
|
|
104
|
+
lines << "\n## Pack-Informed Review Context"
|
|
105
|
+
intro = 'The following skill packs were detected from the Gemfile and ' \
|
|
106
|
+
'are available for this review (via GitHub App access):'
|
|
107
|
+
lines << intro
|
|
108
|
+
lines << ''
|
|
109
|
+
|
|
110
|
+
matched_packs.each do |pack_name|
|
|
111
|
+
pack = fetch_pack(pack_name)
|
|
112
|
+
if pack.nil?
|
|
113
|
+
lines << "- **[#{pack_name}]** (pack not found in registry — skipped)"
|
|
114
|
+
next
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
lines << "### Pack: #{pack_name}"
|
|
118
|
+
lines << pack_description(pack)
|
|
119
|
+
lines << pack_skills(pack)
|
|
120
|
+
lines << ''
|
|
121
|
+
rescue StandardError
|
|
122
|
+
lines << "- **[#{pack_name}]** (failed to load — skipped)"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
lines.join("\n")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def pack_name_for(gem)
|
|
131
|
+
SKILL_NAME_OVERRIDES[gem] || gem
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def pack_description(pack)
|
|
135
|
+
desc = pack[:description] || pack['description'] || ''
|
|
136
|
+
desc.empty? ? '' : "#{desc}\n"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pack_skills(pack)
|
|
140
|
+
files = pack[:files] || pack['files'] || []
|
|
141
|
+
return '' if files.empty?
|
|
142
|
+
|
|
143
|
+
# RegistryClient#fetch_files_with_content returns an Array of
|
|
144
|
+
# { filename: String, content: String } hashes — not a Hash keyed by name.
|
|
145
|
+
skills = files.map do |file|
|
|
146
|
+
filename = file[:filename] || file['filename'] || ''
|
|
147
|
+
skill_name = File.basename(filename, '.*')
|
|
148
|
+
skill_content = (file[:content] || file['content']).to_s
|
|
149
|
+
format_skill_block(skill_name, skill_content)
|
|
150
|
+
end
|
|
151
|
+
skills.join("\n")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def format_skill_block(name, content)
|
|
155
|
+
lines = []
|
|
156
|
+
lines << "<skill name=\"#{name}\">"
|
|
157
|
+
lines << content.strip
|
|
158
|
+
lines << '</skill>'
|
|
159
|
+
lines.join("\n")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|