lex-autofix 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: e309323e3b94d1b313d89ef3519279342ac89a11441f682e1e3d2930415b39b5
4
+ data.tar.gz: b63ac5224d391c1170f3a69a96874a135ad6726f882d5996923799c89d993338
5
+ SHA512:
6
+ metadata.gz: 773519f18416a41f2913c1c7bab97ab3fb53da70b02ac241a6185a9128fcfeb5b757fb8af21b72cbb682461d7a6efb096864a2e8a8a03fa4d77067730ce07b94
7
+ data.tar.gz: cb558e1cd91befef1bca8657072ec7cd35e0467f6275d65725df4255cf9e1d85dd61979e3e16f7a8a6948509a004f534b59b2a3524e49c418565a92b97bdfa32
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1] - 2026-03-21
4
+
5
+ ### Added
6
+ - CI workflow (reusable LegionIO CI + release)
7
+
8
+ ## [0.1.0] - 2026-03-21
9
+
10
+ ### Added
11
+ - Initial release: scaffold, batch buffer, triage, diagnose, fix, ship runners
data/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # lex-autofix
2
+
3
+ Autonomous error fix agent for LegionIO. Subscribes to the `legion.logging` RabbitMQ exchange, batches and triages errors via LLM, manages GitHub issues, and opens PRs with automated code fixes.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'lex-autofix'
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ | Setting | Default | Purpose |
16
+ |---------|---------|---------|
17
+ | `autofix.batch.window_seconds` | 300 | Time window before batch flush |
18
+ | `autofix.batch.count_threshold` | 3 | Error count per group to trigger flush |
19
+ | `autofix.llm.max_retries` | 3 | Max LLM fix attempts before issue-only |
20
+ | `autofix.github.token` | nil | GitHub PAT (supports `vault://`) |
21
+ | `autofix.github.org` | LegionIO | GitHub org for operations |
22
+ | `autofix.checkout_dir` | ~/.legionio/autofix/ | Temp directory for checkouts |
23
+
24
+ ## License
25
+
26
+ MIT
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Autofix
6
+ module Actors
7
+ class LogConsumer < Legion::Extensions::Actors::Subscription
8
+ def runner_function = 'handle_log_event'
9
+ def check_subtask? = false
10
+ def generate_task? = false
11
+
12
+ def enabled?
13
+ !!(defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/client'
4
+ require_relative 'runners/triage'
5
+ require_relative 'runners/diagnose'
6
+ require_relative 'runners/fix'
7
+ require_relative 'runners/ship'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Autofix
12
+ class Client
13
+ include Helpers::Client
14
+ include Runners::Triage
15
+ include Runners::Diagnose
16
+ include Runners::Fix
17
+ include Runners::Ship
18
+
19
+ attr_reader :opts
20
+
21
+ def initialize(github_token: nil, github_org: 'LegionIO', checkout_dir: nil, **extra)
22
+ @opts = { github_token: github_token, github_org: github_org, checkout_dir: checkout_dir, **extra }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Autofix
6
+ module Helpers
7
+ # Thread-safe in-memory buffer that groups error events by lex:exception_class key.
8
+ # Flush is triggered when a group reaches count_threshold OR the time window elapses.
9
+ class BatchBuffer
10
+ DEFAULT_WINDOW_SECONDS = 300
11
+ DEFAULT_COUNT_THRESHOLD = 3
12
+
13
+ attr_reader :groups
14
+
15
+ def initialize(window_seconds: DEFAULT_WINDOW_SECONDS, count_threshold: DEFAULT_COUNT_THRESHOLD)
16
+ @window_seconds = window_seconds
17
+ @count_threshold = count_threshold
18
+ @groups = {}
19
+ @first_event_time = nil
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def add(event)
24
+ @mutex.synchronize do
25
+ key = build_key(event)
26
+ @groups[key] ||= []
27
+ @groups[key] << event
28
+ @first_event_time ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ end
30
+ end
31
+
32
+ def flush_ready?
33
+ @mutex.synchronize do
34
+ return false if @groups.empty?
35
+
36
+ return true if @groups.any? { |_, events| events.length >= @count_threshold }
37
+
38
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @first_event_time
39
+ elapsed >= @window_seconds
40
+ end
41
+ end
42
+
43
+ def flush!
44
+ @mutex.synchronize do
45
+ all_events = @groups.values.flatten
46
+ @groups = {}
47
+ @first_event_time = nil
48
+ all_events
49
+ end
50
+ end
51
+
52
+ def size
53
+ @mutex.synchronize { @groups.values.sum(&:length) }
54
+ end
55
+
56
+ private
57
+
58
+ def build_key(event)
59
+ lex = event[:lex] || 'core'
60
+ exception_class = event[:exception_class] || 'unknown'
61
+ "#{lex}:#{exception_class}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Autofix
6
+ module Helpers
7
+ module Client
8
+ def settings
9
+ { options: @opts }
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Autofix
8
+ module Helpers
9
+ module Prompts
10
+ module_function
11
+
12
+ def triage(events)
13
+ serialized = ::JSON.pretty_generate(events)
14
+ <<~PROMPT
15
+ You are an expert Ruby engineer analyzing error telemetry from a running system.
16
+
17
+ Below is a JSON array of error events captured from one or more LegionIO extensions.
18
+ Your job is to group these errors into clusters based on their type, origin, and likely root cause.
19
+
20
+ For each cluster, decide:
21
+ - Whether it is actionable (can be fixed by editing source code)
22
+ - A brief summary of what is going wrong
23
+ - Which repository (gem) is the likely owner of the fix
24
+ - The list of event indices belonging to this cluster
25
+
26
+ Return ONLY valid JSON matching the required schema. Do not include any explanation outside the JSON.
27
+
28
+ Events:
29
+ #{serialized}
30
+ PROMPT
31
+ end
32
+
33
+ def triage_schema
34
+ {
35
+ type: 'object',
36
+ properties: {
37
+ clusters: {
38
+ type: 'array',
39
+ items: {
40
+ type: 'object',
41
+ properties: {
42
+ id: { type: 'string' },
43
+ summary: { type: 'string' },
44
+ actionable: { type: 'boolean' },
45
+ reason: { type: 'string' },
46
+ events: { type: 'array', items: { type: 'integer' } },
47
+ suggested_repo: { type: 'string' }
48
+ },
49
+ required: %w[id summary actionable events]
50
+ }
51
+ }
52
+ },
53
+ required: %w[clusters]
54
+ }
55
+ end
56
+
57
+ def fix(error_details:, files:)
58
+ file_section = files.map do |path, content|
59
+ "### #{path}\n```ruby\n#{content}\n```"
60
+ end.join("\n\n")
61
+
62
+ <<~PROMPT
63
+ You are an expert Ruby engineer. Your task is to produce a minimal code fix for the error described below.
64
+
65
+ ## Error Details
66
+
67
+ Exception class: #{error_details[:exception_class]}
68
+ Message: #{error_details[:message]}
69
+ Backtrace:
70
+ #{Array(error_details[:backtrace]).first(10).map { |l| " #{l}" }.join("\n")}
71
+
72
+ ## Source Files
73
+
74
+ #{file_section}
75
+
76
+ ## Instructions
77
+
78
+ Analyze the error and the source files above. Produce the smallest set of edits that fixes the issue.
79
+ Each edit must specify the exact old string to replace and the new string to substitute.
80
+ Return ONLY valid JSON matching the required schema. Do not include explanation outside the JSON.
81
+ PROMPT
82
+ end
83
+
84
+ def fix_retry(error_details:, files:, test_output:)
85
+ base = fix(error_details: error_details, files: files)
86
+ <<~PROMPT
87
+ #{base.chomp}
88
+
89
+ ## Previous Fix Attempt Failed
90
+
91
+ The prior fix was applied but tests still failed. Here is the test output:
92
+
93
+ ```
94
+ #{test_output}
95
+ ```
96
+
97
+ Review the test failures above and produce a revised set of edits.
98
+ Return ONLY valid JSON matching the required schema. Do not include explanation outside the JSON.
99
+ PROMPT
100
+ end
101
+
102
+ def fix_schema
103
+ {
104
+ type: 'object',
105
+ properties: {
106
+ edits: {
107
+ type: 'array',
108
+ items: {
109
+ type: 'object',
110
+ properties: {
111
+ file: { type: 'string' },
112
+ old: { type: 'string' },
113
+ new: { type: 'string' }
114
+ },
115
+ required: %w[file old new]
116
+ }
117
+ }
118
+ },
119
+ required: %w[edits]
120
+ }
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Autofix
9
+ module Helpers
10
+ # Manages temporary git checkouts for applying and testing code fixes.
11
+ class TempCheckout
12
+ DEFAULT_BASE_DIR = '~/.legionio/autofix/'
13
+ FILE_READ_CAP = 10
14
+
15
+ def initialize(base_dir: DEFAULT_BASE_DIR)
16
+ @base_dir = ::File.expand_path(base_dir)
17
+ ::FileUtils.mkdir_p(@base_dir)
18
+ end
19
+
20
+ def clone(repo_url:, branch:)
21
+ dir = ::File.join(@base_dir, "checkout_#{SecureRandom.hex(8)}")
22
+ success = system('git', 'clone', '--depth', '1', repo_url, dir)
23
+ return { success: false, reason: 'git clone failed' } unless success
24
+
25
+ success = system('git', '-C', dir, 'checkout', '-b', branch)
26
+ return { success: false, reason: 'git checkout -b failed' } unless success
27
+
28
+ { success: true, path: dir }
29
+ end
30
+
31
+ def cleanup(path)
32
+ expanded = ::File.expand_path(path)
33
+ return { success: false, reason: 'path is outside base_dir' } unless under_base_dir?(expanded)
34
+
35
+ ::FileUtils.rm_rf(expanded)
36
+ { success: true }
37
+ end
38
+
39
+ def apply_edits(checkout_path:, edits:)
40
+ edits.each do |edit|
41
+ file_path = ::File.join(checkout_path, edit['file'])
42
+ return { success: false, reason: "file not found: #{edit['file']}" } unless ::File.exist?(file_path)
43
+
44
+ content = ::File.read(file_path)
45
+ return { success: false, reason: "old string not found in #{edit['file']}" } unless content.include?(edit['old'])
46
+
47
+ ::File.write(file_path, content.sub(edit['old'], edit['new']))
48
+ end
49
+
50
+ { success: true }
51
+ end
52
+
53
+ def read_files(checkout_path:, file_paths:)
54
+ capped = file_paths.first(FILE_READ_CAP)
55
+ result = {}
56
+ capped.each do |rel_path|
57
+ abs_path = ::File.join(checkout_path, rel_path)
58
+ next unless ::File.exist?(abs_path)
59
+
60
+ result[rel_path] = ::File.read(abs_path)
61
+ end
62
+ result
63
+ end
64
+
65
+ private
66
+
67
+ def under_base_dir?(expanded_path)
68
+ expanded_path.start_with?(@base_dir + ::File::SEPARATOR) ||
69
+ expanded_path == @base_dir
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Autofix
6
+ module Runners
7
+ module Diagnose
8
+ def check_github(cluster:, events:, token:, org: 'LegionIO')
9
+ repo = cluster[:suggested_repo]
10
+ exception_class = events.first[:exception_class] || 'unknown'
11
+ client = Legion::Extensions::Github::Client.new(token: token)
12
+ search_result = client.search_issues(
13
+ query: "repo:#{org}/#{repo} is:open label:autofix #{exception_class}"
14
+ )
15
+ items = search_result.dig(:result, :items) || []
16
+
17
+ if items.any?
18
+ update_existing_issue(client: client, issue: items.first, events: events, org: org, repo: repo)
19
+ else
20
+ ctx = build_issue_context(cluster: cluster, events: events, exception_class: exception_class)
21
+ open_new_issue(client: client, cluster: cluster, org: org, repo: repo, ctx: ctx)
22
+ end
23
+ rescue StandardError => e
24
+ { success: false, reason: e.message }
25
+ end
26
+
27
+ private
28
+
29
+ def update_existing_issue(client:, issue:, events:, org:, repo:)
30
+ issue_number = issue[:number]
31
+ issue_body = issue[:body].to_s
32
+ caller_locations = events.map { |e| "#{e[:caller_file]}:#{e[:caller_line]}" }
33
+ new_locations = caller_locations.reject { |loc| issue_body.include?(loc) }
34
+
35
+ if new_locations.any?
36
+ comment_body = "**Additional caller locations observed:**\n\n" \
37
+ "#{new_locations.map { |l| "- #{l}" }.join("\n")}"
38
+ client.create_comment(owner: org, repo: repo, issue_number: issue_number, body: comment_body)
39
+ { success: true, action: :commented, issue_number: issue_number }
40
+ else
41
+ { success: true, action: :skipped, issue_number: issue_number }
42
+ end
43
+ end
44
+
45
+ def build_issue_context(cluster:, events:, exception_class:)
46
+ first = events.first
47
+ {
48
+ exception_class: exception_class,
49
+ caller_locations: events.map { |e| "#{e[:caller_file]}:#{e[:caller_line]}" },
50
+ occurrences: events.size,
51
+ first_seen: first[:first_seen] || 'unknown',
52
+ message: first[:message],
53
+ backtrace_lines: (first[:backtrace] || []).first(5).map { |l| " #{l}" }.join("\n"),
54
+ diagnosis: cluster[:diagnosis] || cluster[:summary]
55
+ }
56
+ end
57
+
58
+ def open_new_issue(client:, cluster:, org:, repo:, ctx:)
59
+ body = <<~BODY
60
+ ## autofix report
61
+
62
+ **Exception**: `#{ctx[:exception_class]}`
63
+ **Source**: #{ctx[:caller_locations].first}
64
+ **Occurrences**: #{ctx[:occurrences]}
65
+ **First seen**: #{ctx[:first_seen]}
66
+
67
+ ### Error Details
68
+
69
+ ```
70
+ #{ctx[:message]}
71
+ ```
72
+
73
+ ### Backtrace (top 5)
74
+
75
+ ```
76
+ #{ctx[:backtrace_lines]}
77
+ ```
78
+
79
+ ### Caller Locations
80
+
81
+ #{ctx[:caller_locations].map { |l| "- #{l}" }.join("\n")}
82
+
83
+ ### Diagnosis
84
+
85
+ #{ctx[:diagnosis]}
86
+ BODY
87
+
88
+ result = client.create_issue(
89
+ owner: org,
90
+ repo: repo,
91
+ title: "autofix: #{cluster[:summary]}",
92
+ body: body,
93
+ labels: ['autofix']
94
+ )
95
+
96
+ issue_data = result[:result] || {}
97
+ { success: true, action: :created, issue_number: issue_data[:number], url: issue_data[:html_url] }
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/prompts'
4
+ require_relative '../helpers/temp_checkout'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Autofix
9
+ module Runners
10
+ module Fix
11
+ def attempt_fix(repo_url:, branch:, error_details:, max_retries: 3, checkout_dir: nil)
12
+ tc_opts = checkout_dir ? { base_dir: checkout_dir } : {}
13
+ tc = Helpers::TempCheckout.new(**tc_opts)
14
+
15
+ clone_result = tc.clone(repo_url: repo_url, branch: branch)
16
+ return clone_result unless clone_result[:success]
17
+
18
+ checkout_path = clone_result[:path]
19
+ file_paths = extract_file_paths(error_details)
20
+ files = tc.read_files(checkout_path: checkout_path, file_paths: file_paths)
21
+
22
+ schema = Helpers::Prompts.fix_schema
23
+ test_output = nil
24
+
25
+ max_retries.times do |attempt|
26
+ messages = build_messages(attempt: attempt, error_details: error_details,
27
+ files: files, test_output: test_output)
28
+
29
+ llm_result = Legion::LLM.structured(messages: messages, schema: schema)
30
+ edits = llm_result[:edits] || []
31
+
32
+ apply_result = tc.apply_edits(checkout_path: checkout_path, edits: edits)
33
+ unless apply_result[:success]
34
+ tc.cleanup(checkout_path)
35
+ return { success: false, reason: apply_result[:reason] }
36
+ end
37
+
38
+ test_result = run_tests(checkout_path: checkout_path)
39
+ if test_result[:success]
40
+ lint_result = run_lint(checkout_path: checkout_path)
41
+ return { success: true, checkout_path: checkout_path } if lint_result[:success]
42
+
43
+ test_output = lint_result[:output]
44
+ else
45
+ test_output = test_result[:output]
46
+ end
47
+
48
+ system('git', '-C', checkout_path, 'checkout', '.')
49
+ end
50
+
51
+ tc.cleanup(checkout_path)
52
+ { success: false, reason: "fix failed after max retries (#{max_retries})" }
53
+ rescue StandardError => e
54
+ { success: false, reason: e.message }
55
+ end
56
+
57
+ def run_tests(checkout_path:)
58
+ Dir.chdir(checkout_path) do
59
+ system('bundle', 'install', '--quiet')
60
+ output = `bundle exec rspec 2>&1`
61
+ { success: $CHILD_STATUS&.success?, output: output }
62
+ end
63
+ end
64
+
65
+ def run_lint(checkout_path:)
66
+ Dir.chdir(checkout_path) do
67
+ system('bundle', 'exec', 'rubocop', '-A')
68
+ output = `bundle exec rubocop 2>&1`
69
+ { success: $CHILD_STATUS&.success?, output: output }
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def extract_file_paths(error_details)
76
+ paths = []
77
+ paths << error_details[:caller_file] if error_details[:caller_file]
78
+
79
+ Array(error_details[:backtrace]).each do |line|
80
+ file = line.to_s.split(':').first
81
+ paths << file if file && !file.empty?
82
+ end
83
+
84
+ paths = paths.uniq
85
+ spec_paths = paths.map { |p| p.sub('lib/', 'spec/').sub(/\.rb$/, '_spec.rb') }
86
+ (paths + spec_paths).uniq
87
+ end
88
+
89
+ def build_messages(attempt:, error_details:, files:, test_output:)
90
+ prompt = if attempt.zero?
91
+ Helpers::Prompts.fix(error_details: error_details, files: files)
92
+ else
93
+ Helpers::Prompts.fix_retry(error_details: error_details, files: files,
94
+ test_output: test_output.to_s)
95
+ end
96
+ [{ role: 'user', content: prompt }]
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'triage'
4
+ require_relative 'diagnose'
5
+ require_relative 'fix'
6
+ require_relative 'ship'
7
+ require_relative '../helpers/batch_buffer'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Autofix
12
+ module Runners
13
+ module Pipeline
14
+ include Triage
15
+ include Diagnose
16
+ include Fix
17
+ include Ship
18
+
19
+ def self.buffer
20
+ @buffer ||= begin
21
+ window = Legion::Settings.dig(:autofix, :batch, :window_seconds) rescue nil # rubocop:disable Style/RescueModifier
22
+ count = Legion::Settings.dig(:autofix, :batch, :count_threshold) rescue nil # rubocop:disable Style/RescueModifier
23
+ opts = {}
24
+ opts[:window_seconds] = window if window
25
+ opts[:count_threshold] = count if count
26
+ Helpers::BatchBuffer.new(**opts)
27
+ end
28
+ end
29
+
30
+ def handle_log_event(**event)
31
+ Pipeline.buffer.add(event)
32
+ run_pipeline if Pipeline.buffer.flush_ready?
33
+ { success: true }
34
+ end
35
+
36
+ def run_pipeline
37
+ events = Pipeline.buffer.flush!
38
+ triage_result = batch_triage(events: events)
39
+ return triage_result unless triage_result[:success]
40
+
41
+ triage_result[:non_actionable].each do |cluster|
42
+ log_info("autofix: skipping non-actionable cluster: #{cluster[:summary]}")
43
+ end
44
+
45
+ triage_result[:actionable].each do |cluster|
46
+ cluster_events = events.select { |e| e[:lex] == cluster[:suggested_repo] }
47
+ cluster_events = events if cluster_events.empty?
48
+ process_cluster(cluster: cluster, events: cluster_events)
49
+ end
50
+
51
+ { success: true }
52
+ end
53
+
54
+ def process_cluster(cluster:, events:)
55
+ token = resolve_token
56
+ org = resolve_org
57
+ max_retries = resolve_max_retries
58
+ checkout_dir = resolve_checkout_dir
59
+
60
+ github_result = check_github(cluster: cluster, events: events, token: token, org: org)
61
+ return github_result unless github_result[:success]
62
+
63
+ issue_number = github_result[:issue_number]
64
+ repo = cluster[:suggested_repo]
65
+
66
+ return { success: true, action: :issue_only } if github_result[:action] == :skipped
67
+
68
+ summary = cluster[:summary].to_s
69
+ repo_url = "https://github.com/#{org}/#{repo}.git"
70
+ branch = "autofix/#{issue_number}-#{slug(summary)}"
71
+
72
+ fix_result = attempt_fix(
73
+ repo_url: repo_url,
74
+ branch: branch,
75
+ error_details: events.first,
76
+ max_retries: max_retries,
77
+ checkout_dir: checkout_dir
78
+ )
79
+
80
+ unless fix_result[:success]
81
+ log_warn("autofix: fix failed for cluster #{cluster[:summary]}: #{fix_result[:reason]}")
82
+ return fix_result
83
+ end
84
+
85
+ ship(
86
+ checkout_path: fix_result[:checkout_path],
87
+ owner: org,
88
+ repo: repo,
89
+ branch: branch,
90
+ issue_number: issue_number,
91
+ summary: summary,
92
+ token: token,
93
+ checkout_dir: checkout_dir
94
+ )
95
+ end
96
+
97
+ private
98
+
99
+ def resolve_token
100
+ Legion::Settings.dig(:autofix, :github, :token)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ def resolve_org
106
+ Legion::Settings.dig(:autofix, :github, :org) || 'LegionIO'
107
+ rescue StandardError
108
+ 'LegionIO'
109
+ end
110
+
111
+ def resolve_max_retries
112
+ Legion::Settings.dig(:autofix, :llm, :max_retries) || 3
113
+ rescue StandardError
114
+ 3
115
+ end
116
+
117
+ def resolve_checkout_dir
118
+ Legion::Settings.dig(:autofix, :checkout_dir)
119
+ rescue StandardError
120
+ nil
121
+ end
122
+
123
+ def slug(text)
124
+ text.to_s.downcase.gsub(/[^a-z0-9]+/, '-').slice(0, 40).chomp('-')
125
+ end
126
+
127
+ def log_info(msg)
128
+ Legion::Logging.info(msg) if defined?(Legion::Logging)
129
+ end
130
+
131
+ def log_warn(msg)
132
+ Legion::Logging.warn(msg) if defined?(Legion::Logging)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/temp_checkout'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Autofix
8
+ module Runners
9
+ module Ship
10
+ def commit_and_push(checkout_path:, branch:, message:)
11
+ Dir.chdir(checkout_path) do
12
+ return { success: false, reason: 'git add failed' } unless system('git', 'add', '-A')
13
+ return { success: false, reason: 'git commit failed' } unless system('git', 'commit', '-m', message)
14
+ return { success: false, reason: 'git push failed' } unless system('git', 'push', '-u', 'origin', branch)
15
+ end
16
+
17
+ { success: true }
18
+ end
19
+
20
+ def open_pr(owner:, repo:, branch:, title:, body:, token:) # rubocop:disable Metrics/ParameterLists
21
+ client = Legion::Extensions::Github::Client.new(token: token)
22
+ result = client.create_pull_request(
23
+ owner: owner,
24
+ repo: repo,
25
+ title: title,
26
+ head: branch,
27
+ base: 'main',
28
+ body: body
29
+ )
30
+ pr = result[:result] || result
31
+ { success: true, pr_number: pr[:number], url: pr[:html_url] }
32
+ rescue StandardError => e
33
+ { success: false, reason: e.message }
34
+ end
35
+
36
+ def ship(checkout_path:, owner:, repo:, branch:, issue_number:, summary:, token:, checkout_dir: nil) # rubocop:disable Metrics/ParameterLists
37
+ commit_result = commit_and_push(
38
+ checkout_path: checkout_path,
39
+ branch: branch,
40
+ message: "fix: #{summary}"
41
+ )
42
+ return commit_result unless commit_result[:success]
43
+
44
+ pr_result = open_pr(
45
+ owner: owner,
46
+ repo: repo,
47
+ branch: branch,
48
+ title: "fix: #{summary}",
49
+ body: "Closes ##{issue_number}\n\nAutomated fix by lex-autofix.",
50
+ token: token
51
+ )
52
+
53
+ tc_opts = checkout_dir ? { base_dir: checkout_dir } : {}
54
+ Helpers::TempCheckout.new(**tc_opts).cleanup(checkout_path)
55
+
56
+ pr_result
57
+ rescue StandardError => e
58
+ { success: false, reason: e.message }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/prompts'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Autofix
8
+ module Runners
9
+ module Triage
10
+ def batch_triage(events:)
11
+ return { success: false, reason: 'no events to triage' } if events.empty?
12
+
13
+ prompt = Helpers::Prompts.triage(events)
14
+ schema = Helpers::Prompts.triage_schema
15
+
16
+ result = Legion::LLM.structured(
17
+ messages: [{ role: 'user', content: prompt }],
18
+ schema: schema
19
+ )
20
+
21
+ clusters = result[:clusters] || []
22
+ actionable, non_actionable = clusters.partition { |c| c[:actionable] }
23
+
24
+ { success: true, clusters: clusters, actionable: actionable, non_actionable: non_actionable }
25
+ rescue StandardError => e
26
+ { success: false, reason: e.message }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Autofix
6
+ module Transport
7
+ module Queues
8
+ class Ingest < Legion::Transport::Queue
9
+ def queue_name = 'autofix.ingest'
10
+ def queue_options = { durable: true, auto_delete: false }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Autofix
6
+ module Transport
7
+ extend Legion::Extensions::Transport if defined?(Legion::Extensions::Transport)
8
+
9
+ def self.additional_e_to_q
10
+ [{ from: 'legion.logging', to: 'autofix.ingest', routing_key: 'legion.#' }]
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Autofix
6
+ VERSION = '0.1.1'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'autofix/version'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Autofix
8
+ extend Legion::Extensions::Core if defined?(Legion::Extensions::Core)
9
+
10
+ def self.llm_required?
11
+ true
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ # Require components when framework is available
18
+ if defined?(Legion::Extensions::Core)
19
+ require_relative 'autofix/helpers/batch_buffer'
20
+ require_relative 'autofix/helpers/prompts'
21
+ require_relative 'autofix/helpers/temp_checkout'
22
+ require_relative 'autofix/helpers/client'
23
+ require_relative 'autofix/runners/triage'
24
+ require_relative 'autofix/runners/diagnose'
25
+ require_relative 'autofix/runners/fix'
26
+ require_relative 'autofix/runners/ship'
27
+ require_relative 'autofix/runners/pipeline'
28
+ require_relative 'autofix/client'
29
+ require_relative 'autofix/transport' if defined?(Legion::Extensions::Transport)
30
+ require_relative 'autofix/actors/log_consumer' if defined?(Legion::Extensions::Actors::Subscription)
31
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-autofix
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - LegionIO
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-llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: lex-github
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ description: Subscribes to legion.logging exchange, triages errors via LLM, and opens
41
+ PRs with fixes
42
+ email:
43
+ - admin@legionio.dev
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - README.md
50
+ - lib/legion/extensions/autofix.rb
51
+ - lib/legion/extensions/autofix/actors/log_consumer.rb
52
+ - lib/legion/extensions/autofix/client.rb
53
+ - lib/legion/extensions/autofix/helpers/batch_buffer.rb
54
+ - lib/legion/extensions/autofix/helpers/client.rb
55
+ - lib/legion/extensions/autofix/helpers/prompts.rb
56
+ - lib/legion/extensions/autofix/helpers/temp_checkout.rb
57
+ - lib/legion/extensions/autofix/runners/diagnose.rb
58
+ - lib/legion/extensions/autofix/runners/fix.rb
59
+ - lib/legion/extensions/autofix/runners/pipeline.rb
60
+ - lib/legion/extensions/autofix/runners/ship.rb
61
+ - lib/legion/extensions/autofix/runners/triage.rb
62
+ - lib/legion/extensions/autofix/transport.rb
63
+ - lib/legion/extensions/autofix/transport/queues/ingest.rb
64
+ - lib/legion/extensions/autofix/version.rb
65
+ homepage: https://github.com/LegionIO/lex-autofix
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ rubygems_mfa_required: 'true'
70
+ rdoc_options: []
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '3.4'
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ requirements: []
84
+ rubygems_version: 3.6.9
85
+ specification_version: 4
86
+ summary: Autonomous error fix agent for LegionIO
87
+ test_files: []