lex-swarm-github 0.1.2 → 0.2.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/lib/legion/extensions/swarm_github/actors/pr_webhook.rb +29 -0
- data/lib/legion/extensions/swarm_github/client.rb +8 -0
- data/lib/legion/extensions/swarm_github/helpers/diff_chunker.rb +42 -0
- data/lib/legion/extensions/swarm_github/runners/pr_pipeline.rb +39 -0
- data/lib/legion/extensions/swarm_github/runners/review_notifier.rb +44 -0
- data/lib/legion/extensions/swarm_github/runners/review_poster.rb +66 -0
- data/lib/legion/extensions/swarm_github/version.rb +1 -1
- data/lib/legion/extensions/swarm_github.rb +4 -0
- data/spec/legion/extensions/swarm_github/actors/pr_webhook_spec.rb +39 -0
- data/spec/legion/extensions/swarm_github/helpers/diff_chunker_spec.rb +56 -0
- data/spec/legion/extensions/swarm_github/runners/pr_pipeline_spec.rb +112 -0
- data/spec/legion/extensions/swarm_github/runners/review_notifier_spec.rb +95 -0
- data/spec/legion/extensions/swarm_github/runners/review_poster_spec.rb +82 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 164c564b62ef684027798c73f8c8137083a55e72df5f0d9204a627789a172472
|
|
4
|
+
data.tar.gz: 4368bd456b18a7af926460954c76ba8c4da25bbf2233dcdc1541c9fb6949e9ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 022a87a111bda8aadf4517c53a70573f5cc9b95f53dad7eb9c4b7c870d695ce783084ff98e64e64b3d769ec8849e8c03112ea0f6ce55e59a9d2dd6f508a2bdb5
|
|
7
|
+
data.tar.gz: 60ac0a083878d8bfd2bce7513a91998cddb4c26e324a1160a8f9af1f6f62c05f040ebff300efef86fc399647bccad08561d4e1608e2b7a3db4420232b194f69a
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/subscription'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module SwarmGithub
|
|
8
|
+
module Actor
|
|
9
|
+
class PrWebhook < Legion::Extensions::Actors::Subscription
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::SwarmGithub::Runners::PrPipeline
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'run_review_pipeline_from_webhook'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check_subtask?
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def generate_task?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
require 'legion/extensions/swarm_github/helpers/pipeline'
|
|
4
4
|
require 'legion/extensions/swarm_github/helpers/issue_tracker'
|
|
5
5
|
require 'legion/extensions/swarm_github/runners/github_swarm'
|
|
6
|
+
require 'legion/extensions/swarm_github/runners/pull_request_reviewer'
|
|
7
|
+
require 'legion/extensions/swarm_github/runners/review_poster'
|
|
8
|
+
require 'legion/extensions/swarm_github/runners/review_notifier'
|
|
9
|
+
require 'legion/extensions/swarm_github/runners/pr_pipeline'
|
|
6
10
|
|
|
7
11
|
module Legion
|
|
8
12
|
module Extensions
|
|
9
13
|
module SwarmGithub
|
|
10
14
|
class Client
|
|
11
15
|
include Runners::GithubSwarm
|
|
16
|
+
include Runners::PullRequestReviewer
|
|
17
|
+
include Runners::ReviewPoster
|
|
18
|
+
include Runners::ReviewNotifier
|
|
19
|
+
include Runners::PrPipeline
|
|
12
20
|
|
|
13
21
|
def initialize(**)
|
|
14
22
|
@issue_tracker = Helpers::IssueTracker.new
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SwarmGithub
|
|
6
|
+
module Helpers
|
|
7
|
+
module DiffChunker
|
|
8
|
+
DEFAULT_MAX_CHARS = 12_000
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def chunk_files(files, max_chars: DEFAULT_MAX_CHARS)
|
|
13
|
+
files = files.select { |f| f[:patch] && !f[:patch].empty? }
|
|
14
|
+
return [] if files.empty?
|
|
15
|
+
|
|
16
|
+
chunks = []
|
|
17
|
+
current_chunk = []
|
|
18
|
+
current_size = 0
|
|
19
|
+
|
|
20
|
+
files.each do |file|
|
|
21
|
+
patch = file[:patch]
|
|
22
|
+
patch = patch[0, max_chars] if patch.length > max_chars
|
|
23
|
+
entry = { filename: file[:filename], patch: patch }
|
|
24
|
+
|
|
25
|
+
if current_size + patch.length > max_chars && !current_chunk.empty?
|
|
26
|
+
chunks << current_chunk
|
|
27
|
+
current_chunk = []
|
|
28
|
+
current_size = 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
current_chunk << entry
|
|
32
|
+
current_size += patch.length
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
chunks << current_chunk unless current_chunk.empty?
|
|
36
|
+
chunks
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SwarmGithub
|
|
6
|
+
module Runners
|
|
7
|
+
module PrPipeline
|
|
8
|
+
REVIEWABLE_ACTIONS = %w[opened synchronize reopened].freeze
|
|
9
|
+
DEFAULT_SLACK_CHANNEL = '#code-reviews'
|
|
10
|
+
|
|
11
|
+
def run_review_pipeline(owner:, repo:, pull_number:, slack_channel: nil, **)
|
|
12
|
+
channel = slack_channel || DEFAULT_SLACK_CHANNEL
|
|
13
|
+
|
|
14
|
+
review = review_pull_request(owner: owner, repo: repo, pull_number: pull_number)
|
|
15
|
+
return { review: review, post: nil, notify: nil } unless review[:status] == 'reviewed'
|
|
16
|
+
|
|
17
|
+
post = post_review(owner: owner, repo: repo, pull_number: pull_number, review: review)
|
|
18
|
+
notify = notify_review(channel: channel, pull_ref: "#{owner}/#{repo}##{pull_number}",
|
|
19
|
+
review: review, post_result: post)
|
|
20
|
+
|
|
21
|
+
{ review: review, post: post, notify: notify }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run_review_pipeline_from_webhook(payload:, slack_channel: nil, **)
|
|
25
|
+
action = payload['action']
|
|
26
|
+
return { skipped: true, reason: 'action not reviewable' } unless REVIEWABLE_ACTIONS.include?(action)
|
|
27
|
+
|
|
28
|
+
full_name = payload.dig('repository', 'full_name') || ''
|
|
29
|
+
owner, repo = full_name.split('/', 2)
|
|
30
|
+
pull_number = payload.dig('pull_request', 'number')
|
|
31
|
+
|
|
32
|
+
run_review_pipeline(owner: owner, repo: repo, pull_number: pull_number,
|
|
33
|
+
slack_channel: slack_channel)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SwarmGithub
|
|
6
|
+
module Runners
|
|
7
|
+
module ReviewNotifier
|
|
8
|
+
def notify_review(channel:, pull_ref:, review:, post_result:, **)
|
|
9
|
+
return { notified: false, reason: 'review not posted' } unless post_result[:posted]
|
|
10
|
+
|
|
11
|
+
return { notified: false, reason: 'lex-slack not available' } unless defined?(Legion::Extensions::Slack::Client)
|
|
12
|
+
|
|
13
|
+
message = format_slack_message(pull_ref: pull_ref, review: review, post_result: post_result)
|
|
14
|
+
result = Legion::Extensions::Slack::Client.new(**).post_message(
|
|
15
|
+
channel: channel, text: message
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
{ notified: result[:ok] == true, channel: channel, ts: result[:ts] }
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
{ notified: false, reason: "slack error: #{e.message}" }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def format_slack_message(pull_ref:, review:, post_result:)
|
|
26
|
+
comments_count = post_result[:comments_count] || 0
|
|
27
|
+
files = review[:files_reviewed] || 0
|
|
28
|
+
|
|
29
|
+
parts = [":mag: *AI Code Review* for `#{pull_ref}` (#{files} file#{'s' if files != 1})"]
|
|
30
|
+
parts << review[:summary] if review[:summary]
|
|
31
|
+
|
|
32
|
+
parts << if comments_count.positive?
|
|
33
|
+
":memo: #{comments_count} comment#{'s' if comments_count != 1} posted"
|
|
34
|
+
else
|
|
35
|
+
':white_check_mark: No issues found'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
parts.join("\n")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module SwarmGithub
|
|
6
|
+
module Runners
|
|
7
|
+
module ReviewPoster
|
|
8
|
+
def post_review(owner:, repo:, pull_number:, review:, **)
|
|
9
|
+
return { posted: false, reason: 'review was skipped' } unless review[:status] == 'reviewed'
|
|
10
|
+
|
|
11
|
+
return { posted: false, reason: 'lex-github not available' } unless defined?(Legion::Extensions::Github::Client)
|
|
12
|
+
|
|
13
|
+
body = format_review_body(review)
|
|
14
|
+
inline_comments = format_inline_comments(review[:comments] || [])
|
|
15
|
+
|
|
16
|
+
result = Legion::Extensions::Github::Client.new.create_review(
|
|
17
|
+
owner: owner, repo: repo, pull_number: pull_number,
|
|
18
|
+
body: body, comments: inline_comments
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
review_id = result.dig(:result, 'id') || result.dig(:result, :id)
|
|
22
|
+
{ posted: true, review_id: review_id, comments_count: inline_comments.size }
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
{ posted: false, reason: "post failed: #{e.message}" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def format_review_body(review)
|
|
30
|
+
parts = ["**Legion AI Review** (#{review[:files_reviewed]} file#{'s' if review[:files_reviewed] != 1} reviewed)"]
|
|
31
|
+
parts << ''
|
|
32
|
+
parts << review[:summary] if review[:summary]
|
|
33
|
+
|
|
34
|
+
comments = review[:comments] || []
|
|
35
|
+
if comments.any?
|
|
36
|
+
by_severity = comments.group_by { |c| c[:severity] || 'info' }
|
|
37
|
+
counts = by_severity.map { |sev, list| "#{list.size} #{sev}" }.join(', ')
|
|
38
|
+
parts << ''
|
|
39
|
+
parts << "Findings: #{counts}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
parts.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_inline_comments(comments)
|
|
46
|
+
comments.filter_map do |c|
|
|
47
|
+
next unless c[:file] && c[:message]
|
|
48
|
+
|
|
49
|
+
severity_icon = case c[:severity]
|
|
50
|
+
when 'error' then '**Error**'
|
|
51
|
+
when 'warning' then 'Warning'
|
|
52
|
+
else 'Note'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
path: c[:file],
|
|
57
|
+
position: c[:line] || 1,
|
|
58
|
+
body: "#{severity_icon}: #{c[:message]}"
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -5,6 +5,10 @@ require 'legion/extensions/swarm_github/helpers/pipeline'
|
|
|
5
5
|
require 'legion/extensions/swarm_github/helpers/issue_tracker'
|
|
6
6
|
require 'legion/extensions/swarm_github/runners/github_swarm'
|
|
7
7
|
require 'legion/extensions/swarm_github/runners/pull_request_reviewer'
|
|
8
|
+
require 'legion/extensions/swarm_github/helpers/diff_chunker'
|
|
9
|
+
require 'legion/extensions/swarm_github/runners/review_poster'
|
|
10
|
+
require 'legion/extensions/swarm_github/runners/review_notifier'
|
|
11
|
+
require 'legion/extensions/swarm_github/runners/pr_pipeline'
|
|
8
12
|
|
|
9
13
|
module Legion
|
|
10
14
|
module Extensions
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
# Stub base class for actor
|
|
6
|
+
unless defined?(Legion::Extensions::Actors::Subscription)
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Actors
|
|
10
|
+
class Subscription
|
|
11
|
+
def initialize(**); end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
$LOADED_FEATURES << 'legion/extensions/actors/subscription'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require 'legion/extensions/swarm_github/actors/pr_webhook'
|
|
20
|
+
|
|
21
|
+
RSpec.describe Legion::Extensions::SwarmGithub::Actor::PrWebhook do
|
|
22
|
+
let(:actor) { described_class.allocate }
|
|
23
|
+
|
|
24
|
+
it 'defines runner_class as PrPipeline' do
|
|
25
|
+
expect(actor.runner_class).to eq(Legion::Extensions::SwarmGithub::Runners::PrPipeline)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'defines runner_function as run_review_pipeline_from_webhook' do
|
|
29
|
+
expect(actor.runner_function).to eq('run_review_pipeline_from_webhook')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'does not check subtasks' do
|
|
33
|
+
expect(actor.check_subtask?).to be false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'does not generate tasks' do
|
|
37
|
+
expect(actor.generate_task?).to be false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/swarm_github/helpers/diff_chunker'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::SwarmGithub::Helpers::DiffChunker do
|
|
7
|
+
describe '.chunk_files' do
|
|
8
|
+
let(:small_file) { { filename: 'a.rb', patch: '+ line1' } }
|
|
9
|
+
let(:large_patch) { "+ #{'x' * 6000}" }
|
|
10
|
+
let(:large_file) { { filename: 'b.rb', patch: large_patch } }
|
|
11
|
+
|
|
12
|
+
it 'returns a single chunk when files fit within limit' do
|
|
13
|
+
chunks = described_class.chunk_files([small_file], max_chars: 4000)
|
|
14
|
+
expect(chunks.size).to eq(1)
|
|
15
|
+
expect(chunks.first.size).to eq(1)
|
|
16
|
+
expect(chunks.first.first[:filename]).to eq('a.rb')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'splits files into multiple chunks when total exceeds limit' do
|
|
20
|
+
files = [small_file, large_file]
|
|
21
|
+
chunks = described_class.chunk_files(files, max_chars: 4000)
|
|
22
|
+
expect(chunks.size).to eq(2)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'truncates a single file patch that exceeds the limit' do
|
|
26
|
+
chunks = described_class.chunk_files([large_file], max_chars: 1000)
|
|
27
|
+
expect(chunks.size).to eq(1)
|
|
28
|
+
expect(chunks.first.first[:patch].length).to be <= 1000
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'preserves filename in each chunk entry' do
|
|
32
|
+
chunks = described_class.chunk_files([small_file, large_file], max_chars: 4000)
|
|
33
|
+
all_files = chunks.flatten.map { |f| f[:filename] }
|
|
34
|
+
expect(all_files).to include('a.rb', 'b.rb')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'uses default max_chars of 12000' do
|
|
38
|
+
files = Array.new(5) { |i| { filename: "f#{i}.rb", patch: "+ #{'y' * 3000}" } }
|
|
39
|
+
chunks = described_class.chunk_files(files)
|
|
40
|
+
expect(chunks.size).to be > 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'handles empty file list' do
|
|
44
|
+
chunks = described_class.chunk_files([])
|
|
45
|
+
expect(chunks).to eq([])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'skips files with nil patch' do
|
|
49
|
+
nil_patch = { filename: 'binary.png', patch: nil }
|
|
50
|
+
chunks = described_class.chunk_files([nil_patch, small_file])
|
|
51
|
+
all_files = chunks.flatten.map { |f| f[:filename] }
|
|
52
|
+
expect(all_files).not_to include('binary.png')
|
|
53
|
+
expect(all_files).to include('a.rb')
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/swarm_github/helpers/pipeline'
|
|
5
|
+
require 'legion/extensions/swarm_github/runners/pull_request_reviewer'
|
|
6
|
+
require 'legion/extensions/swarm_github/runners/review_poster'
|
|
7
|
+
require 'legion/extensions/swarm_github/runners/review_notifier'
|
|
8
|
+
require 'legion/extensions/swarm_github/runners/pr_pipeline'
|
|
9
|
+
|
|
10
|
+
RSpec.describe Legion::Extensions::SwarmGithub::Runners::PrPipeline do
|
|
11
|
+
let(:pipeline) do
|
|
12
|
+
klass = Class.new do
|
|
13
|
+
include Legion::Extensions::SwarmGithub::Runners::PullRequestReviewer
|
|
14
|
+
include Legion::Extensions::SwarmGithub::Runners::ReviewPoster
|
|
15
|
+
include Legion::Extensions::SwarmGithub::Runners::ReviewNotifier
|
|
16
|
+
include Legion::Extensions::SwarmGithub::Runners::PrPipeline
|
|
17
|
+
end
|
|
18
|
+
klass.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe '#run_review_pipeline' do
|
|
22
|
+
let(:review_result) do
|
|
23
|
+
{ status: 'reviewed', pr: 'org/repo#1', files_reviewed: 2,
|
|
24
|
+
summary: 'OK', comments: [] }
|
|
25
|
+
end
|
|
26
|
+
let(:post_result) { { posted: true, review_id: 1, comments_count: 0 } }
|
|
27
|
+
let(:notify_result) { { notified: true } }
|
|
28
|
+
|
|
29
|
+
before do
|
|
30
|
+
allow(pipeline).to receive(:review_pull_request).and_return(review_result)
|
|
31
|
+
allow(pipeline).to receive(:post_review).and_return(post_result)
|
|
32
|
+
allow(pipeline).to receive(:notify_review).and_return(notify_result)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'calls review, post, notify in sequence' do
|
|
36
|
+
expect(pipeline).to receive(:review_pull_request).ordered
|
|
37
|
+
expect(pipeline).to receive(:post_review).ordered
|
|
38
|
+
expect(pipeline).to receive(:notify_review).ordered
|
|
39
|
+
|
|
40
|
+
pipeline.run_review_pipeline(owner: 'org', repo: 'repo', pull_number: 1)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'returns combined result' do
|
|
44
|
+
result = pipeline.run_review_pipeline(owner: 'org', repo: 'repo', pull_number: 1)
|
|
45
|
+
expect(result[:review][:status]).to eq('reviewed')
|
|
46
|
+
expect(result[:post][:posted]).to be true
|
|
47
|
+
expect(result[:notify][:notified]).to be true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'passes slack_channel to notify' do
|
|
51
|
+
expect(pipeline).to receive(:notify_review).with(hash_including(channel: '#my-reviews'))
|
|
52
|
+
|
|
53
|
+
pipeline.run_review_pipeline(owner: 'org', repo: 'repo', pull_number: 1,
|
|
54
|
+
slack_channel: '#my-reviews')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'uses default channel when none provided' do
|
|
58
|
+
expect(pipeline).to receive(:notify_review).with(hash_including(channel: '#code-reviews'))
|
|
59
|
+
|
|
60
|
+
pipeline.run_review_pipeline(owner: 'org', repo: 'repo', pull_number: 1)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'skips post and notify when review is skipped' do
|
|
64
|
+
allow(pipeline).to receive(:review_pull_request)
|
|
65
|
+
.and_return({ status: 'skipped', reason: 'no files' })
|
|
66
|
+
|
|
67
|
+
expect(pipeline).not_to receive(:post_review)
|
|
68
|
+
expect(pipeline).not_to receive(:notify_review)
|
|
69
|
+
|
|
70
|
+
result = pipeline.run_review_pipeline(owner: 'org', repo: 'repo', pull_number: 1)
|
|
71
|
+
expect(result[:review][:status]).to eq('skipped')
|
|
72
|
+
expect(result[:post]).to be_nil
|
|
73
|
+
expect(result[:notify]).to be_nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe '#run_review_pipeline_from_webhook' do
|
|
78
|
+
let(:review_result) do
|
|
79
|
+
{ status: 'reviewed', pr: 'LegionIO/core#7', files_reviewed: 2,
|
|
80
|
+
summary: 'OK', comments: [] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
before do
|
|
84
|
+
allow(pipeline).to receive(:review_pull_request).and_return(review_result)
|
|
85
|
+
allow(pipeline).to receive(:post_review).and_return({ posted: true, review_id: 1, comments_count: 0 })
|
|
86
|
+
allow(pipeline).to receive(:notify_review).and_return({ notified: true })
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'extracts owner, repo, pull_number from pull_request event' do
|
|
90
|
+
payload = {
|
|
91
|
+
'action' => 'opened',
|
|
92
|
+
'pull_request' => { 'number' => 7 },
|
|
93
|
+
'repository' => { 'full_name' => 'LegionIO/core' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
expect(pipeline).to receive(:review_pull_request)
|
|
97
|
+
.with(hash_including(owner: 'LegionIO', repo: 'core', pull_number: 7))
|
|
98
|
+
.and_return(review_result)
|
|
99
|
+
|
|
100
|
+
pipeline.run_review_pipeline_from_webhook(payload: payload)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'ignores non-reviewable actions' do
|
|
104
|
+
payload = { 'action' => 'closed', 'pull_request' => { 'number' => 1 },
|
|
105
|
+
'repository' => { 'full_name' => 'a/b' } }
|
|
106
|
+
|
|
107
|
+
result = pipeline.run_review_pipeline_from_webhook(payload: payload)
|
|
108
|
+
expect(result[:skipped]).to be true
|
|
109
|
+
expect(result[:reason]).to eq('action not reviewable')
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/swarm_github/runners/review_notifier'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::SwarmGithub::Runners::ReviewNotifier do
|
|
7
|
+
let(:notifier) { Class.new { include Legion::Extensions::SwarmGithub::Runners::ReviewNotifier }.new }
|
|
8
|
+
|
|
9
|
+
describe '#notify_review' do
|
|
10
|
+
let(:review_result) do
|
|
11
|
+
{
|
|
12
|
+
status: 'reviewed', pr: 'org/repo#42', files_reviewed: 3,
|
|
13
|
+
summary: 'Found 1 issue',
|
|
14
|
+
comments: [{ file: 'lib/foo.rb', severity: 'error', message: 'Bug' }]
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
let(:post_result) { { posted: true, review_id: 999, comments_count: 1 } }
|
|
19
|
+
|
|
20
|
+
context 'when lex-slack is available' do
|
|
21
|
+
let(:slack_client) { double('Slack::Client') }
|
|
22
|
+
|
|
23
|
+
before do
|
|
24
|
+
stub_const('Legion::Extensions::Slack::Client', Class.new)
|
|
25
|
+
allow(Legion::Extensions::Slack::Client).to receive(:new).and_return(slack_client)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'posts a summary to the configured channel' do
|
|
29
|
+
expect(slack_client).to receive(:post_message) do |args|
|
|
30
|
+
expect(args[:channel]).to eq('#code-review')
|
|
31
|
+
expect(args[:text]).to include('org/repo#42')
|
|
32
|
+
expect(args[:text]).to include('3 file')
|
|
33
|
+
{ ok: true, ts: '123.456' }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
result = notifier.notify_review(
|
|
37
|
+
channel: '#code-review', pull_ref: 'org/repo#42',
|
|
38
|
+
review: review_result, post_result: post_result
|
|
39
|
+
)
|
|
40
|
+
expect(result[:notified]).to be true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'includes comment count in message' do
|
|
44
|
+
expect(slack_client).to receive(:post_message) do |args|
|
|
45
|
+
expect(args[:text]).to include('1 comment')
|
|
46
|
+
{ ok: true, ts: '1' }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
notifier.notify_review(
|
|
50
|
+
channel: '#reviews', pull_ref: 'org/repo#42',
|
|
51
|
+
review: review_result, post_result: post_result
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'sends clean message when review had no issues' do
|
|
56
|
+
clean_review = review_result.merge(comments: [], summary: 'All clear')
|
|
57
|
+
clean_post = post_result.merge(comments_count: 0)
|
|
58
|
+
|
|
59
|
+
expect(slack_client).to receive(:post_message) do |args|
|
|
60
|
+
expect(args[:text]).to include('No issues found')
|
|
61
|
+
{ ok: true, ts: '1' }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
notifier.notify_review(
|
|
65
|
+
channel: '#reviews', pull_ref: 'org/repo#42',
|
|
66
|
+
review: clean_review, post_result: clean_post
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
context 'when lex-slack is not available' do
|
|
72
|
+
it 'returns not_available' do
|
|
73
|
+
result = notifier.notify_review(
|
|
74
|
+
channel: '#reviews', pull_ref: 'org/repo#42',
|
|
75
|
+
review: review_result, post_result: post_result
|
|
76
|
+
)
|
|
77
|
+
expect(result[:notified]).to be false
|
|
78
|
+
expect(result[:reason]).to eq('lex-slack not available')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
context 'when review was not posted' do
|
|
83
|
+
let(:failed_post) { { posted: false, reason: 'lex-github not available' } }
|
|
84
|
+
|
|
85
|
+
it 'skips notification' do
|
|
86
|
+
result = notifier.notify_review(
|
|
87
|
+
channel: '#reviews', pull_ref: 'org/repo#42',
|
|
88
|
+
review: review_result, post_result: failed_post
|
|
89
|
+
)
|
|
90
|
+
expect(result[:notified]).to be false
|
|
91
|
+
expect(result[:reason]).to eq('review not posted')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/swarm_github/runners/review_poster'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::SwarmGithub::Runners::ReviewPoster do
|
|
7
|
+
let(:poster) { Class.new { include Legion::Extensions::SwarmGithub::Runners::ReviewPoster }.new }
|
|
8
|
+
|
|
9
|
+
describe '#post_review' do
|
|
10
|
+
let(:review_result) do
|
|
11
|
+
{
|
|
12
|
+
status: 'reviewed', pr: 'org/repo#42', files_reviewed: 3,
|
|
13
|
+
summary: 'Found 1 issue',
|
|
14
|
+
comments: [{ file: 'lib/foo.rb', line: 10, severity: 'error', message: 'SQL injection' }]
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
context 'when lex-github is available' do
|
|
19
|
+
let(:github_client) { double('Github::Client') }
|
|
20
|
+
|
|
21
|
+
before do
|
|
22
|
+
stub_const('Legion::Extensions::Github::Client', Class.new)
|
|
23
|
+
allow(Legion::Extensions::Github::Client).to receive(:new).and_return(github_client)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'posts a review to GitHub with formatted body' do
|
|
27
|
+
expect(github_client).to receive(:create_review) do |args|
|
|
28
|
+
expect(args[:owner]).to eq('org')
|
|
29
|
+
expect(args[:repo]).to eq('repo')
|
|
30
|
+
expect(args[:pull_number]).to eq(42)
|
|
31
|
+
expect(args[:body]).to include('Found 1 issue')
|
|
32
|
+
expect(args[:body]).to include('3 file')
|
|
33
|
+
{ result: { 'id' => 999 } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
result = poster.post_review(owner: 'org', repo: 'repo', pull_number: 42, review: review_result)
|
|
37
|
+
expect(result[:posted]).to be true
|
|
38
|
+
expect(result[:review_id]).to eq(999)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'converts comments to GitHub inline format' do
|
|
42
|
+
expect(github_client).to receive(:create_review) do |args|
|
|
43
|
+
expect(args[:comments].size).to eq(1)
|
|
44
|
+
expect(args[:comments].first[:path]).to eq('lib/foo.rb')
|
|
45
|
+
expect(args[:comments].first[:body]).to include('SQL injection')
|
|
46
|
+
{ result: { 'id' => 999 } }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
poster.post_review(owner: 'org', repo: 'repo', pull_number: 42, review: review_result)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'handles review with no comments' do
|
|
53
|
+
no_comments = review_result.merge(comments: [], summary: 'All clear')
|
|
54
|
+
expect(github_client).to receive(:create_review) do |args|
|
|
55
|
+
expect(args[:comments]).to eq([])
|
|
56
|
+
{ result: { 'id' => 1 } }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
result = poster.post_review(owner: 'org', repo: 'repo', pull_number: 42, review: no_comments)
|
|
60
|
+
expect(result[:posted]).to be true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
context 'when lex-github is not available' do
|
|
65
|
+
it 'returns not_available error' do
|
|
66
|
+
result = poster.post_review(owner: 'org', repo: 'repo', pull_number: 42, review: review_result)
|
|
67
|
+
expect(result[:posted]).to be false
|
|
68
|
+
expect(result[:reason]).to eq('lex-github not available')
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context 'when review was skipped' do
|
|
73
|
+
let(:skipped) { { status: 'skipped', reason: 'no files' } }
|
|
74
|
+
|
|
75
|
+
it 'returns skipped without posting' do
|
|
76
|
+
result = poster.post_review(owner: 'org', repo: 'repo', pull_number: 1, review: skipped)
|
|
77
|
+
expect(result[:posted]).to be false
|
|
78
|
+
expect(result[:reason]).to eq('review was skipped')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -20,19 +20,29 @@ files:
|
|
|
20
20
|
- Gemfile
|
|
21
21
|
- lex-swarm-github.gemspec
|
|
22
22
|
- lib/legion/extensions/swarm_github.rb
|
|
23
|
+
- lib/legion/extensions/swarm_github/actors/pr_webhook.rb
|
|
23
24
|
- lib/legion/extensions/swarm_github/actors/stale_issues.rb
|
|
24
25
|
- lib/legion/extensions/swarm_github/client.rb
|
|
26
|
+
- lib/legion/extensions/swarm_github/helpers/diff_chunker.rb
|
|
25
27
|
- lib/legion/extensions/swarm_github/helpers/issue_tracker.rb
|
|
26
28
|
- lib/legion/extensions/swarm_github/helpers/pipeline.rb
|
|
27
29
|
- lib/legion/extensions/swarm_github/runners/github_swarm.rb
|
|
30
|
+
- lib/legion/extensions/swarm_github/runners/pr_pipeline.rb
|
|
28
31
|
- lib/legion/extensions/swarm_github/runners/pull_request_reviewer.rb
|
|
32
|
+
- lib/legion/extensions/swarm_github/runners/review_notifier.rb
|
|
33
|
+
- lib/legion/extensions/swarm_github/runners/review_poster.rb
|
|
29
34
|
- lib/legion/extensions/swarm_github/version.rb
|
|
35
|
+
- spec/legion/extensions/swarm_github/actors/pr_webhook_spec.rb
|
|
30
36
|
- spec/legion/extensions/swarm_github/actors/stale_issues_spec.rb
|
|
31
37
|
- spec/legion/extensions/swarm_github/client_spec.rb
|
|
38
|
+
- spec/legion/extensions/swarm_github/helpers/diff_chunker_spec.rb
|
|
32
39
|
- spec/legion/extensions/swarm_github/helpers/issue_tracker_spec.rb
|
|
33
40
|
- spec/legion/extensions/swarm_github/helpers/pipeline_spec.rb
|
|
34
41
|
- spec/legion/extensions/swarm_github/runners/github_swarm_spec.rb
|
|
42
|
+
- spec/legion/extensions/swarm_github/runners/pr_pipeline_spec.rb
|
|
35
43
|
- spec/legion/extensions/swarm_github/runners/pull_request_reviewer_spec.rb
|
|
44
|
+
- spec/legion/extensions/swarm_github/runners/review_notifier_spec.rb
|
|
45
|
+
- spec/legion/extensions/swarm_github/runners/review_poster_spec.rb
|
|
36
46
|
- spec/spec_helper.rb
|
|
37
47
|
homepage: https://github.com/LegionIO/lex-swarm-github
|
|
38
48
|
licenses:
|