lex-swarm-github 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 61d22af3c76a1f33ab47be20d57136e7d0ef11f37731f061876def2d5521dfdd
4
+ data.tar.gz: ac3a2e94eadd471471797c65a322c161855b0c6fe8941cf866693a0aaab3899c
5
+ SHA512:
6
+ metadata.gz: ff1696a0d7d26f1abeafb6297c44eecd047919c6eeaef2bf0a5c74228d90bc1f8fdf85ce69a7ab42f0bec2b130d2bf607a76d272ad0eaa9dcc0393546d9dd389
7
+ data.tar.gz: ac02d9ff34695164586433ea124bd22ce665f3a689fb74d55982bd89cd44c90397373891535240fd077664aeb962856eae298eeec5d13c8208d35a0e14a8ce71
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/swarm_github/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-swarm-github'
7
+ spec.version = Legion::Extensions::SwarmGithub::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Swarm GitHub'
12
+ spec.description = 'GitHub-specific swarm pipeline (finder/fixer/validator) for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-swarm-github'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-swarm-github'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-swarm-github'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-swarm-github'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-swarm-github/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-swarm-github.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module SwarmGithub
8
+ module Actor
9
+ class StaleIssues < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::SwarmGithub::Runners::GithubSwarm
12
+ end
13
+
14
+ def runner_function
15
+ 'mark_stale_issues'
16
+ end
17
+
18
+ def time
19
+ 3600
20
+ end
21
+
22
+ def run_now?
23
+ false
24
+ end
25
+
26
+ def use_runner?
27
+ false
28
+ end
29
+
30
+ def check_subtask?
31
+ false
32
+ end
33
+
34
+ def generate_task?
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/swarm_github/helpers/pipeline'
4
+ require 'legion/extensions/swarm_github/helpers/issue_tracker'
5
+ require 'legion/extensions/swarm_github/runners/github_swarm'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module SwarmGithub
10
+ class Client
11
+ include Runners::GithubSwarm
12
+
13
+ def initialize(**)
14
+ @issue_tracker = Helpers::IssueTracker.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :issue_tracker
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module SwarmGithub
8
+ module Helpers
9
+ class IssueTracker
10
+ attr_reader :issues
11
+
12
+ def initialize
13
+ @issues = {}
14
+ end
15
+
16
+ def track(repo:, issue_number:, title:, labels: [])
17
+ key = "#{repo}##{issue_number}"
18
+ @issues[key] = {
19
+ repo: repo,
20
+ issue_number: issue_number,
21
+ title: title,
22
+ labels: labels,
23
+ state: :received,
24
+ fix_attempts: 0,
25
+ validations: [],
26
+ pr_number: nil,
27
+ created_at: Time.now.utc,
28
+ updated_at: Time.now.utc
29
+ }
30
+ key
31
+ end
32
+
33
+ def transition(key, to_state)
34
+ issue = @issues[key]
35
+ return nil unless issue
36
+ return :invalid_state unless Pipeline.valid_state?(to_state)
37
+
38
+ issue[:state] = to_state
39
+ issue[:updated_at] = Time.now.utc
40
+ issue[:labels] = [Pipeline.label_for_state(to_state)]
41
+ to_state
42
+ end
43
+
44
+ def record_fix_attempt(key)
45
+ issue = @issues[key]
46
+ return nil unless issue
47
+
48
+ issue[:fix_attempts] += 1
49
+ issue[:fix_attempts]
50
+ end
51
+
52
+ def record_validation(key, validator:, approved:, reason: nil)
53
+ issue = @issues[key]
54
+ return nil unless issue
55
+
56
+ issue[:validations] << { validator: validator, approved: approved, reason: reason, at: Time.now.utc }
57
+ check_validation_consensus(key)
58
+ end
59
+
60
+ def attach_pr(key, pr_number:)
61
+ issue = @issues[key]
62
+ return nil unless issue
63
+
64
+ issue[:pr_number] = pr_number
65
+ issue[:state] = :pr_open
66
+ issue[:updated_at] = Time.now.utc
67
+ end
68
+
69
+ def get(key)
70
+ @issues[key]
71
+ end
72
+
73
+ def by_state(state)
74
+ @issues.values.select { |i| i[:state] == state }
75
+ end
76
+
77
+ def count
78
+ @issues.size
79
+ end
80
+
81
+ private
82
+
83
+ def check_validation_consensus(key)
84
+ issue = @issues[key]
85
+ approvals = issue[:validations].count { |v| v[:approved] }
86
+ rejections = issue[:validations].count { |v| !v[:approved] }
87
+
88
+ if approvals >= Pipeline::ADVERSARIAL_REVIEW_K
89
+ :approved
90
+ elsif rejections >= Pipeline::ADVERSARIAL_REVIEW_K
91
+ :rejected
92
+ else
93
+ :pending
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SwarmGithub
6
+ module Helpers
7
+ module Pipeline
8
+ # GitHub swarm state machine (spec: swarm-implementation-spec.md)
9
+ STATES = %i[received found fixing validating approved pr_open rejected stale].freeze
10
+ LABELS = STATES.map { |s| :"swarm:#{s}" }.freeze
11
+
12
+ # Agent roles in the GitHub pipeline
13
+ PIPELINE_ROLES = %i[finder fixer validator pr_swarm].freeze
14
+
15
+ # Validation
16
+ ADVERSARIAL_REVIEW_K = 3 # number of independent validators
17
+ MAX_FIX_ATTEMPTS = 3
18
+ STALE_TIMEOUT = 86_400 # 24 hours
19
+
20
+ module_function
21
+
22
+ def valid_state?(state)
23
+ STATES.include?(state)
24
+ end
25
+
26
+ def label_for_state(state)
27
+ "swarm:#{state}"
28
+ end
29
+
30
+ def next_state(current)
31
+ {
32
+ received: :found,
33
+ found: :fixing,
34
+ fixing: :validating,
35
+ validating: :approved,
36
+ approved: :pr_open
37
+ }[current]
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SwarmGithub
6
+ module Runners
7
+ module GithubSwarm
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def ingest_issue(repo:, issue_number:, title:, labels: [], **)
12
+ key = issue_tracker.track(repo: repo, issue_number: issue_number, title: title, labels: labels)
13
+ Legion::Logging.info "[github-swarm] ingested: key=#{key} title=#{title}"
14
+ { tracked: true, key: key, state: :received }
15
+ end
16
+
17
+ def claim_issue(key:, **)
18
+ result = issue_tracker.transition(key, :found)
19
+ if result == :found
20
+ Legion::Logging.info "[github-swarm] claimed: key=#{key}"
21
+ { claimed: true, state: :found }
22
+ else
23
+ Legion::Logging.debug "[github-swarm] claim failed: key=#{key} result=#{result}"
24
+ { error: result || :not_found }
25
+ end
26
+ end
27
+
28
+ def start_fix(key:, **)
29
+ issue_tracker.transition(key, :fixing)
30
+ attempt = issue_tracker.record_fix_attempt(key)
31
+ if attempt && attempt > Helpers::Pipeline::MAX_FIX_ATTEMPTS
32
+ Legion::Logging.warn "[github-swarm] max attempts exceeded: key=#{key} attempts=#{attempt}"
33
+ { error: :max_attempts_exceeded, attempts: attempt }
34
+ else
35
+ Legion::Logging.info "[github-swarm] fix started: key=#{key} attempt=#{attempt}"
36
+ { fixing: true, attempt: attempt }
37
+ end
38
+ end
39
+
40
+ def submit_validation(key:, validator:, approved:, reason: nil, **)
41
+ consensus = issue_tracker.record_validation(key, validator: validator,
42
+ approved: approved, reason: reason)
43
+ if consensus
44
+ Legion::Logging.info "[github-swarm] validation: key=#{key} validator=#{validator} approved=#{approved} consensus=#{consensus}"
45
+ { recorded: true, consensus: consensus }
46
+ else
47
+ Legion::Logging.debug "[github-swarm] validation failed: key=#{key} not found"
48
+ { error: :not_found }
49
+ end
50
+ end
51
+
52
+ def attach_pr(key:, pr_number:, **)
53
+ result = issue_tracker.attach_pr(key, pr_number: pr_number)
54
+ if result
55
+ Legion::Logging.info "[github-swarm] PR attached: key=#{key} pr=##{pr_number}"
56
+ { attached: true, pr_number: pr_number }
57
+ else
58
+ Legion::Logging.debug "[github-swarm] PR attach failed: key=#{key} not found"
59
+ { error: :not_found }
60
+ end
61
+ end
62
+
63
+ def get_issue(key:, **)
64
+ issue = issue_tracker.get(key)
65
+ Legion::Logging.debug "[github-swarm] get: key=#{key} found=#{!issue.nil?}"
66
+ issue ? { found: true, issue: issue } : { found: false }
67
+ end
68
+
69
+ def issues_by_state(state:, **)
70
+ issues = issue_tracker.by_state(state)
71
+ Legion::Logging.debug "[github-swarm] by_state: state=#{state} count=#{issues.size}"
72
+ { issues: issues, count: issues.size }
73
+ end
74
+
75
+ def pipeline_status(**)
76
+ status = Helpers::Pipeline::STATES.to_h do |state|
77
+ [state, issue_tracker.by_state(state).size]
78
+ end
79
+ summary = status.select { |_, v| v.positive? }.map { |k, v| "#{k}=#{v}" }.join(' ')
80
+ Legion::Logging.debug "[github-swarm] pipeline: total=#{issue_tracker.count} #{summary}"
81
+ { states: status, total: issue_tracker.count }
82
+ end
83
+
84
+ def mark_stale_issues(**)
85
+ terminal = %i[approved pr_open rejected stale]
86
+ now = Time.now.utc
87
+ timeout = Helpers::Pipeline::STALE_TIMEOUT
88
+ stale_keys = []
89
+ issue_tracker.issues.each do |key, issue|
90
+ next if terminal.include?(issue[:state])
91
+ next unless now - issue[:updated_at] > timeout
92
+
93
+ issue_tracker.transition(key, :stale)
94
+ stale_keys << key
95
+ end
96
+ Legion::Logging.debug "[swarm-github] stale check: checked=#{issue_tracker.count} stale=#{stale_keys.size}"
97
+ { checked: issue_tracker.count, marked_stale: stale_keys.size, stale_keys: stale_keys }
98
+ end
99
+
100
+ private
101
+
102
+ def issue_tracker
103
+ @issue_tracker ||= Helpers::IssueTracker.new
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SwarmGithub
6
+ VERSION = '0.1.1'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/swarm_github/version'
4
+ require 'legion/extensions/swarm_github/helpers/pipeline'
5
+ require 'legion/extensions/swarm_github/helpers/issue_tracker'
6
+ require 'legion/extensions/swarm_github/runners/github_swarm'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module SwarmGithub
11
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Actors
6
+ class Every # rubocop:disable Lint/EmptyClass
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
13
+
14
+ require_relative '../../../../../lib/legion/extensions/swarm_github/actors/stale_issues'
15
+
16
+ RSpec.describe Legion::Extensions::SwarmGithub::Actor::StaleIssues do
17
+ subject(:actor) { described_class.new }
18
+
19
+ describe '#runner_class' do
20
+ it { expect(actor.runner_class).to eq Legion::Extensions::SwarmGithub::Runners::GithubSwarm }
21
+ end
22
+
23
+ describe '#runner_function' do
24
+ it { expect(actor.runner_function).to eq 'mark_stale_issues' }
25
+ end
26
+
27
+ describe '#time' do
28
+ it { expect(actor.time).to eq 3600 }
29
+ end
30
+
31
+ describe '#run_now?' do
32
+ it { expect(actor.run_now?).to be false }
33
+ end
34
+
35
+ describe '#use_runner?' do
36
+ it { expect(actor.use_runner?).to be false }
37
+ end
38
+
39
+ describe '#check_subtask?' do
40
+ it { expect(actor.check_subtask?).to be false }
41
+ end
42
+
43
+ describe '#generate_task?' do
44
+ it { expect(actor.generate_task?).to be false }
45
+ end
46
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/swarm_github/client'
4
+
5
+ RSpec.describe Legion::Extensions::SwarmGithub::Client do
6
+ it 'responds to github swarm runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:ingest_issue)
9
+ expect(client).to respond_to(:claim_issue)
10
+ expect(client).to respond_to(:start_fix)
11
+ expect(client).to respond_to(:submit_validation)
12
+ expect(client).to respond_to(:attach_pr)
13
+ expect(client).to respond_to(:get_issue)
14
+ expect(client).to respond_to(:issues_by_state)
15
+ expect(client).to respond_to(:pipeline_status)
16
+ end
17
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SwarmGithub::Helpers::IssueTracker do
4
+ let(:tracker) { described_class.new }
5
+ let(:pipeline) { Legion::Extensions::SwarmGithub::Helpers::Pipeline }
6
+
7
+ let(:repo) { 'org/repo' }
8
+ let(:issue_number) { 42 }
9
+ let(:key) { "#{repo}##{issue_number}" }
10
+
11
+ before { tracker.track(repo: repo, issue_number: issue_number, title: 'Fix the bug') }
12
+
13
+ describe '#initialize' do
14
+ it 'starts with an empty issues hash' do
15
+ expect(described_class.new.issues).to eq({})
16
+ end
17
+ end
18
+
19
+ describe '#track' do
20
+ it 'returns the composite key' do
21
+ k = described_class.new.track(repo: 'a/b', issue_number: 1, title: 'test')
22
+ expect(k).to eq('a/b#1')
23
+ end
24
+
25
+ it 'stores the issue under the composite key' do
26
+ expect(tracker.issues[key]).not_to be_nil
27
+ end
28
+
29
+ it 'sets initial state to :received' do
30
+ expect(tracker.issues[key][:state]).to eq(:received)
31
+ end
32
+
33
+ it 'stores the repo' do
34
+ expect(tracker.issues[key][:repo]).to eq(repo)
35
+ end
36
+
37
+ it 'stores the issue_number' do
38
+ expect(tracker.issues[key][:issue_number]).to eq(issue_number)
39
+ end
40
+
41
+ it 'stores the title' do
42
+ expect(tracker.issues[key][:title]).to eq('Fix the bug')
43
+ end
44
+
45
+ it 'starts fix_attempts at 0' do
46
+ expect(tracker.issues[key][:fix_attempts]).to eq(0)
47
+ end
48
+
49
+ it 'starts with an empty validations array' do
50
+ expect(tracker.issues[key][:validations]).to eq([])
51
+ end
52
+
53
+ it 'starts with pr_number nil' do
54
+ expect(tracker.issues[key][:pr_number]).to be_nil
55
+ end
56
+
57
+ it 'stores provided labels' do
58
+ t = described_class.new
59
+ t.track(repo: 'a/b', issue_number: 1, title: 'test', labels: %w[bug critical])
60
+ expect(t.issues['a/b#1'][:labels]).to eq(%w[bug critical])
61
+ end
62
+
63
+ it 'defaults labels to empty array' do
64
+ expect(tracker.issues[key][:labels]).to eq([])
65
+ end
66
+
67
+ it 'records created_at as a UTC Time' do
68
+ before = Time.now.utc
69
+ t = described_class.new
70
+ t.track(repo: 'a/b', issue_number: 1, title: 'test')
71
+ after = Time.now.utc
72
+ expect(t.issues['a/b#1'][:created_at]).to be_between(before, after)
73
+ end
74
+
75
+ it 'records updated_at as a UTC Time on creation' do
76
+ expect(tracker.issues[key][:updated_at]).not_to be_nil
77
+ end
78
+ end
79
+
80
+ describe '#transition' do
81
+ it 'returns the new state when valid' do
82
+ result = tracker.transition(key, :found)
83
+ expect(result).to eq(:found)
84
+ end
85
+
86
+ it 'updates the issue state' do
87
+ tracker.transition(key, :found)
88
+ expect(tracker.issues[key][:state]).to eq(:found)
89
+ end
90
+
91
+ it 'updates the labels array to the single swarm label for the new state' do
92
+ tracker.transition(key, :found)
93
+ expect(tracker.issues[key][:labels]).to eq([pipeline.label_for_state(:found)])
94
+ end
95
+
96
+ it 'updates updated_at on transition' do
97
+ before = Time.now.utc
98
+ tracker.transition(key, :found)
99
+ after = Time.now.utc
100
+ expect(tracker.issues[key][:updated_at]).to be_between(before, after)
101
+ end
102
+
103
+ it 'returns :invalid_state for an unknown state symbol' do
104
+ expect(tracker.transition(key, :nonexistent)).to eq(:invalid_state)
105
+ end
106
+
107
+ it 'returns nil for an unknown issue key' do
108
+ expect(tracker.transition('bad/key#99', :found)).to be_nil
109
+ end
110
+
111
+ it 'can transition through multiple valid states' do
112
+ tracker.transition(key, :found)
113
+ tracker.transition(key, :fixing)
114
+ expect(tracker.issues[key][:state]).to eq(:fixing)
115
+ end
116
+ end
117
+
118
+ describe '#record_fix_attempt' do
119
+ it 'returns 1 on the first attempt' do
120
+ expect(tracker.record_fix_attempt(key)).to eq(1)
121
+ end
122
+
123
+ it 'increments fix_attempts on the issue' do
124
+ tracker.record_fix_attempt(key)
125
+ expect(tracker.issues[key][:fix_attempts]).to eq(1)
126
+ end
127
+
128
+ it 'returns the running total after multiple calls' do
129
+ tracker.record_fix_attempt(key)
130
+ tracker.record_fix_attempt(key)
131
+ expect(tracker.record_fix_attempt(key)).to eq(3)
132
+ end
133
+
134
+ it 'returns nil for an unknown key' do
135
+ expect(tracker.record_fix_attempt('bad/key#0')).to be_nil
136
+ end
137
+ end
138
+
139
+ describe '#record_validation' do
140
+ it 'appends the validation to the validations array' do
141
+ tracker.record_validation(key, validator: 'v1', approved: true)
142
+ expect(tracker.issues[key][:validations].size).to eq(1)
143
+ end
144
+
145
+ it 'stores the validator identifier' do
146
+ tracker.record_validation(key, validator: 'v1', approved: true)
147
+ expect(tracker.issues[key][:validations].first[:validator]).to eq('v1')
148
+ end
149
+
150
+ it 'stores the approved flag' do
151
+ tracker.record_validation(key, validator: 'v1', approved: false)
152
+ expect(tracker.issues[key][:validations].first[:approved]).to be false
153
+ end
154
+
155
+ it 'stores an optional reason' do
156
+ tracker.record_validation(key, validator: 'v1', approved: false, reason: 'needs more tests')
157
+ expect(tracker.issues[key][:validations].first[:reason]).to eq('needs more tests')
158
+ end
159
+
160
+ it 'stores nil for reason when not provided' do
161
+ tracker.record_validation(key, validator: 'v1', approved: true)
162
+ expect(tracker.issues[key][:validations].first[:reason]).to be_nil
163
+ end
164
+
165
+ it 'records a timestamp on each validation' do
166
+ before = Time.now.utc
167
+ tracker.record_validation(key, validator: 'v1', approved: true)
168
+ after = Time.now.utc
169
+ expect(tracker.issues[key][:validations].first[:at]).to be_between(before, after)
170
+ end
171
+
172
+ it 'returns :pending when fewer than ADVERSARIAL_REVIEW_K approvals exist' do
173
+ tracker.record_validation(key, validator: 'v1', approved: true)
174
+ result = tracker.record_validation(key, validator: 'v2', approved: true)
175
+ expect(result).to eq(:pending)
176
+ end
177
+
178
+ it 'returns :approved when ADVERSARIAL_REVIEW_K approvals are reached' do
179
+ tracker.record_validation(key, validator: 'v1', approved: true)
180
+ tracker.record_validation(key, validator: 'v2', approved: true)
181
+ result = tracker.record_validation(key, validator: 'v3', approved: true)
182
+ expect(result).to eq(:approved)
183
+ end
184
+
185
+ it 'returns :rejected when ADVERSARIAL_REVIEW_K rejections are reached' do
186
+ tracker.record_validation(key, validator: 'v1', approved: false)
187
+ tracker.record_validation(key, validator: 'v2', approved: false)
188
+ result = tracker.record_validation(key, validator: 'v3', approved: false)
189
+ expect(result).to eq(:rejected)
190
+ end
191
+
192
+ it 'returns :pending when approvals and rejections are mixed below threshold' do
193
+ tracker.record_validation(key, validator: 'v1', approved: true)
194
+ result = tracker.record_validation(key, validator: 'v2', approved: false)
195
+ expect(result).to eq(:pending)
196
+ end
197
+
198
+ it 'returns nil for an unknown key' do
199
+ expect(tracker.record_validation('bad/key#0', validator: 'v1', approved: true)).to be_nil
200
+ end
201
+ end
202
+
203
+ describe '#attach_pr' do
204
+ it 'sets the pr_number on the issue' do
205
+ tracker.attach_pr(key, pr_number: 100)
206
+ expect(tracker.issues[key][:pr_number]).to eq(100)
207
+ end
208
+
209
+ it 'transitions state to :pr_open' do
210
+ tracker.attach_pr(key, pr_number: 100)
211
+ expect(tracker.issues[key][:state]).to eq(:pr_open)
212
+ end
213
+
214
+ it 'updates updated_at' do
215
+ before = Time.now.utc
216
+ tracker.attach_pr(key, pr_number: 100)
217
+ after = Time.now.utc
218
+ expect(tracker.issues[key][:updated_at]).to be_between(before, after)
219
+ end
220
+
221
+ it 'returns nil for an unknown key' do
222
+ expect(tracker.attach_pr('bad/key#0', pr_number: 1)).to be_nil
223
+ end
224
+ end
225
+
226
+ describe '#get' do
227
+ it 'returns the issue hash for a known key' do
228
+ result = tracker.get(key)
229
+ expect(result[:title]).to eq('Fix the bug')
230
+ end
231
+
232
+ it 'returns nil for an unknown key' do
233
+ expect(tracker.get('unknown/repo#999')).to be_nil
234
+ end
235
+ end
236
+
237
+ describe '#by_state' do
238
+ it 'returns all issues in the given state' do
239
+ tracker.track(repo: 'a/b', issue_number: 1, title: 'other')
240
+ results = tracker.by_state(:received)
241
+ expect(results.size).to eq(2)
242
+ end
243
+
244
+ it 'excludes issues not in the given state' do
245
+ tracker.transition(key, :found)
246
+ results = tracker.by_state(:received)
247
+ expect(results).to be_empty
248
+ end
249
+
250
+ it 'returns an empty array when no issues match' do
251
+ expect(tracker.by_state(:approved)).to eq([])
252
+ end
253
+
254
+ it 'reflects state changes after transitions' do
255
+ tracker.transition(key, :found)
256
+ expect(tracker.by_state(:found).size).to eq(1)
257
+ end
258
+ end
259
+
260
+ describe '#count' do
261
+ it 'returns 0 for an empty tracker' do
262
+ expect(described_class.new.count).to eq(0)
263
+ end
264
+
265
+ it 'returns the number of tracked issues' do
266
+ expect(tracker.count).to eq(1)
267
+ end
268
+
269
+ it 'increments when additional issues are tracked' do
270
+ tracker.track(repo: 'a/b', issue_number: 2, title: 'another')
271
+ expect(tracker.count).to eq(2)
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SwarmGithub::Helpers::Pipeline do
4
+ describe 'constants' do
5
+ it 'defines STATES as the eight pipeline states' do
6
+ expect(described_class::STATES).to eq(%i[received found fixing validating approved pr_open rejected stale])
7
+ end
8
+
9
+ it 'defines LABELS derived from STATES with swarm: prefix' do
10
+ expected = described_class::STATES.map { |s| :"swarm:#{s}" }
11
+ expect(described_class::LABELS).to eq(expected)
12
+ end
13
+
14
+ it 'defines PIPELINE_ROLES as the four agent roles' do
15
+ expect(described_class::PIPELINE_ROLES).to eq(%i[finder fixer validator pr_swarm])
16
+ end
17
+
18
+ it 'defines ADVERSARIAL_REVIEW_K as 3' do
19
+ expect(described_class::ADVERSARIAL_REVIEW_K).to eq(3)
20
+ end
21
+
22
+ it 'defines MAX_FIX_ATTEMPTS as 3' do
23
+ expect(described_class::MAX_FIX_ATTEMPTS).to eq(3)
24
+ end
25
+
26
+ it 'defines STALE_TIMEOUT as 86400' do
27
+ expect(described_class::STALE_TIMEOUT).to eq(86_400)
28
+ end
29
+ end
30
+
31
+ describe '.valid_state?' do
32
+ it 'returns true for all defined STATES' do
33
+ described_class::STATES.each do |state|
34
+ expect(described_class.valid_state?(state)).to be true
35
+ end
36
+ end
37
+
38
+ it 'returns true for :received' do
39
+ expect(described_class.valid_state?(:received)).to be true
40
+ end
41
+
42
+ it 'returns true for :stale' do
43
+ expect(described_class.valid_state?(:stale)).to be true
44
+ end
45
+
46
+ it 'returns true for :pr_open' do
47
+ expect(described_class.valid_state?(:pr_open)).to be true
48
+ end
49
+
50
+ it 'returns false for an unknown state' do
51
+ expect(described_class.valid_state?(:pending)).to be false
52
+ end
53
+
54
+ it 'returns false for nil' do
55
+ expect(described_class.valid_state?(nil)).to be false
56
+ end
57
+
58
+ it 'returns false for a string version of a valid state' do
59
+ expect(described_class.valid_state?('received')).to be false
60
+ end
61
+ end
62
+
63
+ describe '.label_for_state' do
64
+ it 'returns swarm:received for :received' do
65
+ expect(described_class.label_for_state(:received)).to eq('swarm:received')
66
+ end
67
+
68
+ it 'returns swarm:fixing for :fixing' do
69
+ expect(described_class.label_for_state(:fixing)).to eq('swarm:fixing')
70
+ end
71
+
72
+ it 'returns swarm:pr_open for :pr_open' do
73
+ expect(described_class.label_for_state(:pr_open)).to eq('swarm:pr_open')
74
+ end
75
+
76
+ it 'returns a string result for every defined state' do
77
+ described_class::STATES.each do |state|
78
+ expect(described_class.label_for_state(state)).to eq("swarm:#{state}")
79
+ end
80
+ end
81
+ end
82
+
83
+ describe '.next_state' do
84
+ it 'returns :found for :received' do
85
+ expect(described_class.next_state(:received)).to eq(:found)
86
+ end
87
+
88
+ it 'returns :fixing for :found' do
89
+ expect(described_class.next_state(:found)).to eq(:fixing)
90
+ end
91
+
92
+ it 'returns :validating for :fixing' do
93
+ expect(described_class.next_state(:fixing)).to eq(:validating)
94
+ end
95
+
96
+ it 'returns :approved for :validating' do
97
+ expect(described_class.next_state(:validating)).to eq(:approved)
98
+ end
99
+
100
+ it 'returns :pr_open for :approved' do
101
+ expect(described_class.next_state(:approved)).to eq(:pr_open)
102
+ end
103
+
104
+ it 'returns nil for terminal states with no next state' do
105
+ expect(described_class.next_state(:pr_open)).to be_nil
106
+ expect(described_class.next_state(:rejected)).to be_nil
107
+ expect(described_class.next_state(:stale)).to be_nil
108
+ end
109
+
110
+ it 'returns nil for an unknown state' do
111
+ expect(described_class.next_state(:unknown)).to be_nil
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/swarm_github/client'
4
+
5
+ RSpec.describe Legion::Extensions::SwarmGithub::Runners::GithubSwarm do
6
+ let(:client) { Legion::Extensions::SwarmGithub::Client.new }
7
+
8
+ describe '#ingest_issue' do
9
+ it 'tracks a GitHub issue' do
10
+ result = client.ingest_issue(repo: 'org/repo', issue_number: 42, title: 'Bug fix needed')
11
+ expect(result[:tracked]).to be true
12
+ expect(result[:state]).to eq(:received)
13
+ end
14
+ end
15
+
16
+ describe '#claim_issue' do
17
+ it 'transitions issue to found state' do
18
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'test')
19
+ result = client.claim_issue(key: 'org/repo#1')
20
+ expect(result[:claimed]).to be true
21
+ end
22
+ end
23
+
24
+ describe '#start_fix' do
25
+ it 'starts a fix attempt' do
26
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'test')
27
+ client.claim_issue(key: 'org/repo#1')
28
+ result = client.start_fix(key: 'org/repo#1')
29
+ expect(result[:fixing]).to be true
30
+ expect(result[:attempt]).to eq(1)
31
+ end
32
+
33
+ it 'rejects after max attempts' do
34
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'test')
35
+ 4.times { client.start_fix(key: 'org/repo#1') }
36
+ result = client.start_fix(key: 'org/repo#1')
37
+ expect(result[:error]).to eq(:max_attempts_exceeded)
38
+ end
39
+ end
40
+
41
+ describe '#submit_validation' do
42
+ it 'records validation' do
43
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'test')
44
+ result = client.submit_validation(key: 'org/repo#1', validator: 'v1', approved: true)
45
+ expect(result[:recorded]).to be true
46
+ end
47
+
48
+ it 'reaches consensus with k=3 approvals' do
49
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'test')
50
+ client.submit_validation(key: 'org/repo#1', validator: 'v1', approved: true)
51
+ client.submit_validation(key: 'org/repo#1', validator: 'v2', approved: true)
52
+ result = client.submit_validation(key: 'org/repo#1', validator: 'v3', approved: true)
53
+ expect(result[:consensus]).to eq(:approved)
54
+ end
55
+ end
56
+
57
+ describe '#attach_pr' do
58
+ it 'attaches a PR to the issue' do
59
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'test')
60
+ result = client.attach_pr(key: 'org/repo#1', pr_number: 100)
61
+ expect(result[:attached]).to be true
62
+ end
63
+ end
64
+
65
+ describe '#pipeline_status' do
66
+ it 'returns counts by state' do
67
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'test1')
68
+ client.ingest_issue(repo: 'org/repo', issue_number: 2, title: 'test2')
69
+ status = client.pipeline_status
70
+ expect(status[:total]).to eq(2)
71
+ expect(status[:states][:received]).to eq(2)
72
+ end
73
+ end
74
+
75
+ describe '#mark_stale_issues' do
76
+ it 'returns zero marked when tracker is empty' do
77
+ result = client.mark_stale_issues
78
+ expect(result[:marked_stale]).to eq(0)
79
+ expect(result[:stale_keys]).to eq([])
80
+ end
81
+
82
+ it 'does not mark a freshly ingested issue as stale' do
83
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'fresh')
84
+ result = client.mark_stale_issues
85
+ expect(result[:marked_stale]).to eq(0)
86
+ end
87
+
88
+ it 'marks a non-terminal issue as stale when past timeout' do
89
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'old')
90
+ tracker = client.send(:issue_tracker)
91
+ tracker.issues['org/repo#1'][:updated_at] = Time.now.utc - 90_000
92
+ result = client.mark_stale_issues
93
+ expect(result[:marked_stale]).to eq(1)
94
+ expect(result[:stale_keys]).to include('org/repo#1')
95
+ end
96
+
97
+ it 'does not mark a terminal issue as stale' do
98
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 'done')
99
+ tracker = client.send(:issue_tracker)
100
+ tracker.issues['org/repo#1'][:state] = :approved
101
+ tracker.issues['org/repo#1'][:updated_at] = Time.now.utc - 90_000
102
+ result = client.mark_stale_issues
103
+ expect(result[:marked_stale]).to eq(0)
104
+ end
105
+
106
+ it 'reports correct checked count' do
107
+ client.ingest_issue(repo: 'org/repo', issue_number: 1, title: 't1')
108
+ client.ingest_issue(repo: 'org/repo', issue_number: 2, title: 't2')
109
+ result = client.mark_stale_issues
110
+ expect(result[:checked]).to eq(2)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/swarm_github'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-swarm-github
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Esity
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: GitHub-specific swarm pipeline (finder/fixer/validator) for brain-modeled
27
+ agentic AI
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-swarm-github.gemspec
36
+ - lib/legion/extensions/swarm_github.rb
37
+ - lib/legion/extensions/swarm_github/actors/stale_issues.rb
38
+ - lib/legion/extensions/swarm_github/client.rb
39
+ - lib/legion/extensions/swarm_github/helpers/issue_tracker.rb
40
+ - lib/legion/extensions/swarm_github/helpers/pipeline.rb
41
+ - lib/legion/extensions/swarm_github/runners/github_swarm.rb
42
+ - lib/legion/extensions/swarm_github/version.rb
43
+ - spec/legion/extensions/swarm_github/actors/stale_issues_spec.rb
44
+ - spec/legion/extensions/swarm_github/client_spec.rb
45
+ - spec/legion/extensions/swarm_github/helpers/issue_tracker_spec.rb
46
+ - spec/legion/extensions/swarm_github/helpers/pipeline_spec.rb
47
+ - spec/legion/extensions/swarm_github/runners/github_swarm_spec.rb
48
+ - spec/spec_helper.rb
49
+ homepage: https://github.com/LegionIO/lex-swarm-github
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/LegionIO/lex-swarm-github
54
+ source_code_uri: https://github.com/LegionIO/lex-swarm-github
55
+ documentation_uri: https://github.com/LegionIO/lex-swarm-github
56
+ changelog_uri: https://github.com/LegionIO/lex-swarm-github
57
+ bug_tracker_uri: https://github.com/LegionIO/lex-swarm-github/issues
58
+ rubygems_mfa_required: 'true'
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.4'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: LEX Swarm GitHub
76
+ test_files: []