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 +7 -0
- data/Gemfile +11 -0
- data/lex-swarm-github.gemspec +29 -0
- data/lib/legion/extensions/swarm_github/actors/stale_issues.rb +41 -0
- data/lib/legion/extensions/swarm_github/client.rb +23 -0
- data/lib/legion/extensions/swarm_github/helpers/issue_tracker.rb +100 -0
- data/lib/legion/extensions/swarm_github/helpers/pipeline.rb +43 -0
- data/lib/legion/extensions/swarm_github/runners/github_swarm.rb +109 -0
- data/lib/legion/extensions/swarm_github/version.rb +9 -0
- data/lib/legion/extensions/swarm_github.rb +14 -0
- data/spec/legion/extensions/swarm_github/actors/stale_issues_spec.rb +46 -0
- data/spec/legion/extensions/swarm_github/client_spec.rb +17 -0
- data/spec/legion/extensions/swarm_github/helpers/issue_tracker_spec.rb +274 -0
- data/spec/legion/extensions/swarm_github/helpers/pipeline_spec.rb +114 -0
- data/spec/legion/extensions/swarm_github/runners/github_swarm_spec.rb +113 -0
- data/spec/spec_helper.rb +20 -0
- metadata +76 -0
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,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,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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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: []
|