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 +7 -0
- data/CHANGELOG.md +11 -0
- data/README.md +26 -0
- data/lib/legion/extensions/autofix/actors/log_consumer.rb +19 -0
- data/lib/legion/extensions/autofix/client.rb +27 -0
- data/lib/legion/extensions/autofix/helpers/batch_buffer.rb +67 -0
- data/lib/legion/extensions/autofix/helpers/client.rb +15 -0
- data/lib/legion/extensions/autofix/helpers/prompts.rb +126 -0
- data/lib/legion/extensions/autofix/helpers/temp_checkout.rb +75 -0
- data/lib/legion/extensions/autofix/runners/diagnose.rb +103 -0
- data/lib/legion/extensions/autofix/runners/fix.rb +102 -0
- data/lib/legion/extensions/autofix/runners/pipeline.rb +138 -0
- data/lib/legion/extensions/autofix/runners/ship.rb +64 -0
- data/lib/legion/extensions/autofix/runners/triage.rb +32 -0
- data/lib/legion/extensions/autofix/transport/queues/ingest.rb +16 -0
- data/lib/legion/extensions/autofix/transport.rb +15 -0
- data/lib/legion/extensions/autofix/version.rb +9 -0
- data/lib/legion/extensions/autofix.rb +31 -0
- metadata +87 -0
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
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,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,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: []
|