lex-swarm-github 0.3.1 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1c767cd01e97e707ce4079c0a3313c723e984143e1e29cfcd580bfd7b391735
4
- data.tar.gz: abf61ad73ed7875421cef31b0ff713031f5fc99bc61ece84943fdba22cf34169
3
+ metadata.gz: afb02c0714b2eeeb371b7d0890c04b6a5637be7b8584f62872cec6337baa980e
4
+ data.tar.gz: 794646c0b9897d25e18fb22cf156c3f7ee2a5a07408aa23876b17cef00ece198
5
5
  SHA512:
6
- metadata.gz: 99ae16ec047585531e0c41eedb26e55d4a4e4b2940562078bbc1c36f4f9eb734c08c1df8aef46aa35032ea68c2199e90d39cef50b7535eb8c179f16f90a689c9
7
- data.tar.gz: f61a356f212fa8f7b7b250fa6d4fd9ef10446233842cf65410ec3239e76bb689793ef3694978b2e6ee67c00ea09f137b9bcc36d7ec0ad438b626ec5da9fa3875
6
+ metadata.gz: 5c20383d9e86e5db3135a6cca8af1b1b226348f9cada58232425e7ae8064c17c2366f6aab167404b2081a567e95e238cfd37fc23cebf338fedb9078c35d5591c
7
+ data.tar.gz: 9d494a1404085b965f8bf6d272b9b875fc4bd3c21c291d9d07fdf45452fe356d6e3966f4e5fe6f42c142f5557b18ea80d68092fe33dbc0cd1d5c9628eea9e68e
@@ -19,10 +19,14 @@ module Legion
19
19
  return { skipped: true, reason: verdict == 'approve' ? :github_disabled : :not_approved }
20
20
  end
21
21
 
22
- generation = payload[:generation] || {}
23
- review = payload.except(:generation)
22
+ generation = payload[:generation] || payload['generation'] || {}
23
+ review = payload.except(:generation, 'generation')
24
+ review_k = payload[:review_k] || payload['review_k']
25
+ raw_models = payload[:review_models] || payload['review_models']
26
+ review_models = normalize_review_models(raw_models)
24
27
 
25
- Runners::ExtensionLifecycle.run_lifecycle(generation: generation, review: review)
28
+ Runners::ExtensionLifecycle.run_lifecycle(generation: generation, review: review,
29
+ review_k: review_k, review_models: review_models)
26
30
  rescue StandardError => e
27
31
  log.warn("LifecycleSubscriber failed: #{e.message}")
28
32
  { success: false, error: e.message }
@@ -30,6 +34,16 @@ module Legion
30
34
 
31
35
  private
32
36
 
37
+ def normalize_review_models(raw)
38
+ return nil unless raw.is_a?(Array)
39
+
40
+ raw.filter_map do |spec|
41
+ next unless spec.is_a?(Hash)
42
+
43
+ spec.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
44
+ end
45
+ end
46
+
33
47
  def github_lifecycle_enabled?
34
48
  return false unless defined?(Legion::Settings)
35
49
 
@@ -7,7 +7,7 @@ module Legion
7
7
  module ExtensionLifecycle
8
8
  extend self
9
9
 
10
- def run_lifecycle(generation:, review:)
10
+ def run_lifecycle(generation:, review:, review_k: nil, review_models: nil)
11
11
  config = github_config
12
12
  return { success: false, error: :github_not_enabled } unless config[:enabled]
13
13
  return { success: false, error: :target_repo_missing } unless config[:target_repo]
@@ -31,11 +31,17 @@ module Legion
31
31
  label_pull_request(owner: owner, repo: repo, pull_number: pr[:pull_number],
32
32
  labels: config[:pr_labels])
33
33
 
34
+ k = review_k || default_review_k
35
+ models = review_models || default_review_models
36
+ review_result = run_adversarial_review(owner: owner, repo: repo,
37
+ pull_number: pr[:pull_number], k: k, models: models)
38
+
34
39
  handle_auto_merge(owner: owner, repo: repo, pull_number: pr[:pull_number],
35
- config: config, review: review)
40
+ config: config, review: review, review_result: review_result)
36
41
 
37
42
  { success: true, pull_number: pr[:pull_number], html_url: pr[:html_url],
38
- branch: branch_name, generation_id: generation[:generation_id] }
43
+ branch: branch_name, generation_id: generation[:generation_id],
44
+ review_consensus: review_result&.dig(:consensus), review_k: k }
39
45
  rescue StandardError => e
40
46
  log.warn("ExtensionLifecycle failed: #{e.message}")
41
47
  { success: false, error: e.message }
@@ -84,8 +90,109 @@ module Legion
84
90
  { success: false, error: e.message }
85
91
  end
86
92
 
87
- def handle_auto_merge(owner:, repo:, pull_number:, config:, review:)
93
+ def default_review_k
94
+ return 1 unless defined?(Legion::Settings)
95
+
96
+ Legion::Settings.dig(:codegen, :self_generate, :github, :review_k) || 1
97
+ rescue StandardError => e
98
+ log.warn(e.message)
99
+ 1
100
+ end
101
+
102
+ def default_review_models
103
+ return [] unless defined?(Legion::Settings)
104
+
105
+ Legion::Settings.dig(:codegen, :self_generate, :github, :review_models) || []
106
+ rescue StandardError => e
107
+ log.warn(e.message)
108
+ []
109
+ end
110
+
111
+ def provider_available?(provider_sym)
112
+ return false unless defined?(Legion::Settings)
113
+
114
+ Legion::Settings.dig(:llm, :providers, provider_sym, :enabled) == true
115
+ rescue StandardError => e
116
+ log.warn(e.message)
117
+ false
118
+ end
119
+
120
+ def build_model_assignments(count, models)
121
+ return Array.new(count) { nil } if models.nil?
122
+
123
+ unless models.is_a?(Array)
124
+ log.warn("review_models must be an Array, got #{models.class}; using defaults")
125
+ return Array.new(count) { nil }
126
+ end
127
+
128
+ return Array.new(count) { nil } if models.empty?
129
+
130
+ available = models.filter_map do |raw_spec|
131
+ unless raw_spec.is_a?(Hash)
132
+ log.warn('review model spec is not a Hash, skipping')
133
+ next
134
+ end
135
+
136
+ spec = raw_spec.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
137
+ provider_value = spec[:provider]
138
+
139
+ if provider_value && !provider_value.respond_to?(:to_sym)
140
+ log.warn("review provider value #{provider_value.inspect} (#{provider_value.class}) cannot be symbolized, skipping")
141
+ next
142
+ end
143
+
144
+ provider_sym = provider_value&.to_sym
145
+ spec[:provider] = provider_sym if provider_sym
146
+
147
+ if provider_sym && !provider_available?(provider_sym)
148
+ log.warn("review provider #{provider_sym} not available, skipping")
149
+ next
150
+ end
151
+
152
+ spec
153
+ end
154
+
155
+ return Array.new(count) { nil } if available.empty?
156
+
157
+ assignments = available.first(count)
158
+ assignments + Array.new([count - assignments.length, 0].max) { nil }
159
+ end
160
+
161
+ def run_adversarial_review(owner:, repo:, pull_number:, k:, models: []) # rubocop:disable Naming/MethodParameterName
162
+ return { success: true, skipped: true, reason: :reviewer_unavailable } unless pr_reviewer_available?
163
+
164
+ assignments = build_model_assignments(k, models)
165
+
166
+ reviews = assignments.map do |spec|
167
+ kwargs = { owner: owner, repo: repo, pull_number: pull_number }
168
+ if spec
169
+ kwargs[:model] = spec[:model] if spec[:model]
170
+ kwargs[:provider] = spec[:provider] if spec[:provider]
171
+ end
172
+ Legion::Extensions::SwarmGithub::Runners::PullRequestReviewer.review_pull_request(**kwargs)
173
+ end
174
+
175
+ approvals = reviews.count do |r|
176
+ r[:status] == 'reviewed' && (r[:comments] || []).none? { |c| %w[error critical].include?(c[:severity]&.to_s) }
177
+ end
178
+ rejections = k - approvals
179
+
180
+ {
181
+ success: true,
182
+ consensus: approvals > rejections ? :approve : :request_changes,
183
+ k: k,
184
+ approvals: approvals,
185
+ rejections: rejections,
186
+ reviews: reviews
187
+ }
188
+ rescue StandardError => e
189
+ log.warn("adversarial PR review failed: #{e.message}")
190
+ { success: true, skipped: true, reason: :review_error }
191
+ end
192
+
193
+ def handle_auto_merge(owner:, repo:, pull_number:, config:, review:, review_result: nil) # rubocop:disable Metrics/ParameterLists
88
194
  return unless config[:auto_merge] && review[:verdict]&.to_sym == :approve
195
+ return if review_result && review_result[:consensus] == :request_changes
89
196
 
90
197
  github_client.merge_pull_request(
91
198
  owner: owner, repo: repo, pull_number: pull_number,
@@ -150,6 +257,10 @@ module Legion
150
257
  defined?(Legion::Extensions::Github::Client)
151
258
  end
152
259
 
260
+ def pr_reviewer_available?
261
+ defined?(Legion::Extensions::SwarmGithub::Runners::PullRequestReviewer)
262
+ end
263
+
153
264
  def github_client
154
265
  @github_client ||= Legion::Extensions::Github::Client.new(**github_connection_opts)
155
266
  end
@@ -5,11 +5,13 @@ module Legion
5
5
  module SwarmGithub
6
6
  module Runners
7
7
  module PullRequestReviewer
8
- def review_pull_request(owner:, repo:, pull_number:)
8
+ extend self
9
+
10
+ def review_pull_request(owner:, repo:, pull_number:, model: nil, provider: nil)
9
11
  files = fetch_pr_files(owner: owner, repo: repo, pull_number: pull_number)
10
12
  return { status: 'skipped', reason: 'no files' } if files.empty?
11
13
 
12
- review = generate_review(files)
14
+ review = generate_review(files, model: model, provider: provider)
13
15
 
14
16
  {
15
17
  status: 'reviewed',
@@ -33,12 +35,15 @@ module Legion
33
35
  []
34
36
  end
35
37
 
36
- def generate_review(files)
38
+ def generate_review(files, model: nil, provider: nil)
37
39
  chunks = Helpers::DiffChunker.chunk_files(files)
38
40
  reviews = chunks.map do |chunk|
39
41
  diff_text = chunk.map { |f| "--- #{f[:filename]} ---\n#{f[:patch]}" }.join("\n\n")
40
42
  prompt = code_review_prompt(diff_text)
41
- response = Legion::LLM.chat(message: prompt, caller: { extension: 'lex-swarm-github' })
43
+ llm_kwargs = { message: prompt, caller: { extension: 'lex-swarm-github' } }
44
+ llm_kwargs[:model] = model if model
45
+ llm_kwargs[:provider] = provider if provider
46
+ response = Legion::LLM.chat(**llm_kwargs)
42
47
  parse_review_response(response)
43
48
  end
44
49
  merge_chunk_reviews(reviews)
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module SwarmGithub
6
- VERSION = '0.3.1'
6
+ VERSION = '0.3.2'
7
7
  end
8
8
  end
9
9
  end
@@ -80,11 +80,21 @@ RSpec.describe Legion::Extensions::SwarmGithub::Actor::LifecycleSubscriber do
80
80
  actor.action(payload)
81
81
  expect(Legion::Extensions::SwarmGithub::Runners::ExtensionLifecycle)
82
82
  .to have_received(:run_lifecycle).with(
83
- generation: generation,
84
- review: hash_including(verdict: 'approve')
83
+ generation: generation,
84
+ review: hash_including(verdict: 'approve'),
85
+ review_k: nil,
86
+ review_models: nil
85
87
  )
86
88
  end
87
89
 
90
+ it 'forwards review_models from payload' do
91
+ models = [{ provider: :bedrock, model: 'claude' }]
92
+ payload = { verdict: 'approve', generation: generation, review_models: models }
93
+ actor.action(payload)
94
+ expect(Legion::Extensions::SwarmGithub::Runners::ExtensionLifecycle)
95
+ .to have_received(:run_lifecycle).with(hash_including(review_models: models))
96
+ end
97
+
88
98
  it 'returns the lifecycle result' do
89
99
  result = actor.action({ verdict: 'approve', generation: generation })
90
100
  expect(result).to eq(lifecycle_result)
@@ -79,6 +79,7 @@ RSpec.describe Legion::Extensions::SwarmGithub::Runners::ExtensionLifecycle do
79
79
  { result: { 'number' => 42, 'html_url' => 'https://github.com/org/repo/pull/42' } }
80
80
  )
81
81
  allow(github_client).to receive(:add_labels).and_return({ success: true })
82
+ allow(runner).to receive(:pr_reviewer_available?).and_return(false)
82
83
  end
83
84
 
84
85
  it 'returns success with PR details' do
@@ -154,6 +155,7 @@ RSpec.describe Legion::Extensions::SwarmGithub::Runners::ExtensionLifecycle do
154
155
  { result: { 'number' => 7, 'html_url' => 'https://github.com/org/repo/pull/7' } }
155
156
  )
156
157
  allow(github_client).to receive(:merge_pull_request).and_return({ success: true })
158
+ allow(runner).to receive(:pr_reviewer_available?).and_return(false)
157
159
  end
158
160
 
159
161
  it 'calls merge_pull_request' do
@@ -175,5 +177,142 @@ RSpec.describe Legion::Extensions::SwarmGithub::Runners::ExtensionLifecycle do
175
177
  expect(result[:error]).to eq('unexpected failure')
176
178
  end
177
179
  end
180
+
181
+ describe 'adversarial PR review' do
182
+ let(:mod) { described_class }
183
+
184
+ before do
185
+ allow(mod).to receive(:github_config).and_return(
186
+ enabled: true, target_repo: 'Test/repo', target_branch: 'main',
187
+ auto_merge: false, pr_labels: [], branch_prefix: 'feature/auto-gen'
188
+ )
189
+ allow(mod).to receive(:create_lifecycle_branch).and_return({ success: true })
190
+ allow(mod).to receive(:commit_generated_files).and_return({ success: true })
191
+ allow(mod).to receive(:open_pull_request).and_return({ success: true, pull_number: 1, html_url: 'url' })
192
+ allow(mod).to receive(:label_pull_request).and_return({ success: true })
193
+ allow(mod).to receive(:run_adversarial_review).and_return({ success: true, consensus: :approve, k: 3 })
194
+ allow(mod).to receive(:handle_auto_merge)
195
+ end
196
+
197
+ it 'passes review_k through to adversarial review' do
198
+ mod.run_lifecycle(generation: generation, review: review, review_k: 3)
199
+ expect(mod).to have_received(:run_adversarial_review).with(hash_including(k: 3))
200
+ end
201
+
202
+ it 'defaults review_k to 1' do
203
+ mod.run_lifecycle(generation: generation, review: review)
204
+ expect(mod).to have_received(:run_adversarial_review).with(hash_including(k: 1))
205
+ end
206
+
207
+ it 'includes review consensus in result' do
208
+ result = mod.run_lifecycle(generation: generation, review: review, review_k: 3)
209
+ expect(result[:review_consensus]).to eq(:approve)
210
+ expect(result[:review_k]).to eq(3)
211
+ end
212
+ end
213
+
214
+ describe 'multi-provider adversarial review' do
215
+ let(:mod) { described_class }
216
+
217
+ before do
218
+ allow(mod).to receive(:github_config).and_return(
219
+ enabled: true, target_repo: 'Test/repo', target_branch: 'main',
220
+ auto_merge: false, pr_labels: [], branch_prefix: 'feature/auto-gen'
221
+ )
222
+ allow(mod).to receive(:create_lifecycle_branch).and_return({ success: true })
223
+ allow(mod).to receive(:commit_generated_files).and_return({ success: true })
224
+ allow(mod).to receive(:open_pull_request).and_return({ success: true, pull_number: 1, html_url: 'url' })
225
+ allow(mod).to receive(:label_pull_request).and_return({ success: true })
226
+ allow(mod).to receive(:handle_auto_merge)
227
+ end
228
+
229
+ it 'forwards review_models to adversarial review' do
230
+ allow(mod).to receive(:run_adversarial_review).and_return({ success: true, consensus: :approve, k: 2 })
231
+ models = [{ provider: :bedrock, model: 'claude' }]
232
+ mod.run_lifecycle(generation: generation, review: review, review_k: 2, review_models: models)
233
+ expect(mod).to have_received(:run_adversarial_review).with(hash_including(models: models))
234
+ end
235
+
236
+ it 'uses default_review_models when review_models is nil' do
237
+ allow(mod).to receive(:run_adversarial_review).and_return({ success: true, consensus: :approve, k: 1 })
238
+ allow(mod).to receive(:default_review_models).and_return([])
239
+ mod.run_lifecycle(generation: generation, review: review)
240
+ expect(mod).to have_received(:default_review_models)
241
+ end
242
+ end
243
+
244
+ describe '#build_model_assignments' do
245
+ let(:mod) { described_class }
246
+
247
+ it 'returns all nils when models is nil' do
248
+ result = mod.send(:build_model_assignments, 3, nil)
249
+ expect(result).to eq([nil, nil, nil])
250
+ end
251
+
252
+ it 'returns all nils when models is empty' do
253
+ result = mod.send(:build_model_assignments, 2, [])
254
+ expect(result).to eq([nil, nil])
255
+ end
256
+
257
+ it 'uses each available model at most once, then fills remaining slots with nil' do
258
+ allow(mod).to receive(:provider_available?).and_return(true)
259
+ models = [{ provider: :bedrock, model: 'a' }, { provider: :openai, model: 'b' }]
260
+ result = mod.send(:build_model_assignments, 3, models)
261
+ expect(result).to eq([{ provider: :bedrock, model: 'a' }, { provider: :openai, model: 'b' }, nil])
262
+ end
263
+
264
+ it 'skips unavailable providers and falls back to nil assignments' do
265
+ allow(mod).to receive(:provider_available?).and_return(false)
266
+ models = [{ provider: :unavailable, model: 'x' }]
267
+ result = mod.send(:build_model_assignments, 2, models)
268
+ expect(result).to eq([nil, nil])
269
+ end
270
+
271
+ it 'returns all nils and warns when models is a String' do
272
+ expect(mod).to receive(:log).at_least(:once).and_return(double(warn: nil))
273
+ result = mod.send(:build_model_assignments, 2, 'not-an-array')
274
+ expect(result).to eq([nil, nil])
275
+ end
276
+
277
+ it 'returns all nils and warns when models is a Hash' do
278
+ expect(mod).to receive(:log).at_least(:once).and_return(double(warn: nil))
279
+ result = mod.send(:build_model_assignments, 2, { provider: :bedrock, model: 'claude' })
280
+ expect(result).to eq([nil, nil])
281
+ end
282
+
283
+ it 'returns all nils and warns when models is an Integer' do
284
+ expect(mod).to receive(:log).at_least(:once).and_return(double(warn: nil))
285
+ result = mod.send(:build_model_assignments, 2, 42)
286
+ expect(result).to eq([nil, nil])
287
+ end
288
+
289
+ it 'skips specs with a non-symbolizable provider value and warns' do
290
+ logger = double(warn: nil)
291
+ allow(mod).to receive(:log).and_return(logger)
292
+ models = [{ provider: [1, 2], model: 'x' }, { provider: :openai, model: 'y' }]
293
+ allow(mod).to receive(:provider_available?).with(:openai).and_return(true)
294
+ result = mod.send(:build_model_assignments, 2, models)
295
+ expect(logger).to have_received(:warn).with(/cannot be symbolized/)
296
+ expect(result).to eq([{ provider: :openai, model: 'y' }, nil])
297
+ end
298
+ end
299
+
300
+ describe '#provider_available?' do
301
+ let(:mod) { described_class }
302
+
303
+ it 'returns false when Legion::Settings is not defined' do
304
+ hide_const('Legion::Settings') if defined?(Legion::Settings)
305
+ expect(mod.send(:provider_available?, :bedrock)).to be false
306
+ end
307
+ end
308
+
309
+ describe '#default_review_models' do
310
+ let(:mod) { described_class }
311
+
312
+ it 'returns empty array when Legion::Settings is not defined' do
313
+ hide_const('Legion::Settings') if defined?(Legion::Settings)
314
+ expect(mod.send(:default_review_models)).to eq([])
315
+ end
316
+ end
178
317
  end
179
318
  end
@@ -98,5 +98,32 @@ RSpec.describe Legion::Extensions::SwarmGithub::Runners::PullRequestReviewer do
98
98
  expect(result[:comments].first[:severity]).to eq('error')
99
99
  end
100
100
  end
101
+
102
+ context 'with model: and provider: kwargs' do
103
+ let(:client) { Class.new { include Legion::Extensions::SwarmGithub::Runners::PullRequestReviewer }.new }
104
+
105
+ before do
106
+ stub_const('Legion::LLM', Module.new)
107
+ allow(Legion::LLM).to receive(:chat).and_return('{"summary":"ok","comments":[]}')
108
+ allow(client).to receive(:fetch_pr_files).and_return(
109
+ [{ filename: 'test.rb', patch: '+x' }]
110
+ )
111
+ end
112
+
113
+ it 'forwards model and provider to Legion::LLM.chat' do
114
+ client.review_pull_request(owner: 'o', repo: 'r', pull_number: 1, model: 'gpt-4o', provider: :openai)
115
+ expect(Legion::LLM).to have_received(:chat).with(hash_including(model: 'gpt-4o', provider: :openai))
116
+ end
117
+
118
+ it 'omits model key when model is nil' do
119
+ client.review_pull_request(owner: 'o', repo: 'r', pull_number: 1)
120
+ expect(Legion::LLM).to have_received(:chat).with(hash_not_including(:model))
121
+ end
122
+
123
+ it 'omits provider key when provider is nil' do
124
+ client.review_pull_request(owner: 'o', repo: 'r', pull_number: 1)
125
+ expect(Legion::LLM).to have_received(:chat).with(hash_not_including(:provider))
126
+ end
127
+ end
101
128
  end
102
129
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-swarm-github
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity