lex-github 0.3.5 → 0.3.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3d7a7f7bb14416475d247ced95d050133ca186ba90b27def22a2eb650f9f281
4
- data.tar.gz: 18ebc886fae52273743d338ea6d8c392427c11ce247221abd34bcf4ecc431109
3
+ metadata.gz: 345057d3d4b8e4716221915d9f398ec586cbc5485674e07b3fd9cf79788fb281
4
+ data.tar.gz: d3a320f5202c7c77115b1ab28d7a65f2c975fe063864634ffadcb526b8370a65
5
5
  SHA512:
6
- metadata.gz: 392c217ec7e3cbfe66f594ee7d61d226ddaafa70d641034e7ef4da8cd3f6e043e6413b056ebe2c550306e47c3674c0d21fe9bfa2d726ae6dbb09d11d6e2e8912
7
- data.tar.gz: '09c432cd5d0f1b50c9f5dbc375d2301b56b6a21a0fc15880471c9b11c3c717858bda02ff4c88212eb652e41127f438d00ec0b08c5de2b2c951913bde8a6e88e5'
6
+ metadata.gz: c84b0641ad42300d82b2b073133bacacf9ab7aa89ccd7fb859ab16daf771f69d86102d2261fe36d785bbb09b9d48f1170dd463be08886e76388d670eb1184556
7
+ data.tar.gz: 3c0a0a2ab0861ec6eda83e1f1067e791583b556fe0d9166e072a8bba1f97cd458c874715ee88e74868a89aae64d3bcd9b0a75e8e3d4a8ba9f2396cd3f3559306
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.6] - 2026-04-14
6
+
7
+ ### Added
8
+ - `Absorbers::Issues`: normalizes GitHub issue webhook events to fleet work items; filters bot-generated events, already-claimed issues (fleet labels), and ignored actions; stores raw payload in Redis; publishes to assessor queue
9
+ - `Absorbers::IssuesActor`: subscription actor with `pattern 'github.issues.*'` that delegates to `Absorbers::Issues`
10
+ - `Absorbers::WebhookSetup`: mixin for idempotent webhook registration and fleet label creation (`fleet:received`, `fleet:implementing`, `fleet:pr-open`, `fleet:escalated`) on target repos
11
+ - `Absorbers::Helpers`: shared utilities — `bot_generated?`, `has_fleet_label?`, `ignored?`, `work_item_fingerprint`, `generate_work_item_id`, `transport_connected?`
12
+
5
13
  ## [0.3.5] - 2026-04-13
6
14
 
7
15
  ### Added
@@ -0,0 +1,49 @@
1
+ # lib/legion/extensions/github/absorbers/actor.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'issues'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Github
9
+ module Absorbers
10
+ # Subscription actor that listens on the absorber queue and delegates
11
+ # to the Issues absorber module.
12
+ #
13
+ # Queue: lex.github.absorbers.issues.absorb
14
+ # Exchange: lex.github
15
+ # Routing key: lex.github.absorbers.issues.absorb
16
+ #
17
+ # Per Wire Protocol section 17, absorber queues follow the pattern:
18
+ # lex.{lex_name}.absorbers.{absorber_name}.absorb
19
+ class IssuesActor < Legion::Extensions::Actors::Subscription
20
+ pattern 'github.issues.*'
21
+
22
+ def absorb(payload:, **)
23
+ Legion::Extensions::Github::Absorbers::Issues.absorb(payload: payload)
24
+ end
25
+
26
+ def runner_class
27
+ Legion::Extensions::Github::Absorbers::Issues
28
+ end
29
+
30
+ def runner_function
31
+ 'absorb'
32
+ end
33
+
34
+ def use_runner?
35
+ false
36
+ end
37
+
38
+ def check_subtask?
39
+ false
40
+ end
41
+
42
+ def generate_task?
43
+ false
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,68 @@
1
+ # lib/legion/extensions/github/absorbers/helpers.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'digest'
5
+ require 'securerandom'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Github
10
+ module Absorbers
11
+ module Helpers
12
+ FLEET_LABELS = %w[
13
+ fleet:received fleet:implementing fleet:pr-open fleet:escalated
14
+ ].freeze
15
+
16
+ IGNORED_ACTIONS = %w[
17
+ closed transferred deleted pinned unpinned milestoned demilestoned
18
+ ].freeze
19
+
20
+ BOT_PATTERNS = /\[bot\]\z/i
21
+
22
+ def bot_generated?(payload)
23
+ sender = payload['sender'] || payload[:sender]
24
+ return false unless sender
25
+
26
+ login = sender['login'] || sender[:login] || ''
27
+ type = sender['type'] || sender[:type] || ''
28
+
29
+ type.downcase == 'bot' || login.match?(BOT_PATTERNS)
30
+ end
31
+
32
+ def has_fleet_label?(payload) # rubocop:disable Naming/PredicatePrefix
33
+ issue = payload['issue'] || payload[:issue]
34
+ return false unless issue
35
+
36
+ labels = issue['labels'] || issue[:labels] || []
37
+ labels.any? do |label|
38
+ name = label['name'] || label[:name]
39
+ FLEET_LABELS.include?(name)
40
+ end
41
+ end
42
+
43
+ def ignored?(payload)
44
+ action = payload['action'] || payload[:action]
45
+ IGNORED_ACTIONS.include?(action.to_s)
46
+ end
47
+
48
+ def work_item_fingerprint(source:, ref:, title:)
49
+ input = "#{source}:#{ref}:#{title}"
50
+ Digest::SHA256.hexdigest(input)
51
+ end
52
+
53
+ def generate_work_item_id
54
+ SecureRandom.uuid
55
+ end
56
+
57
+ def transport_connected?
58
+ return false unless defined?(Legion::Settings)
59
+
60
+ !!Legion::Settings.dig(:transport, :connected)
61
+ rescue StandardError => _e
62
+ false
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,200 @@
1
+ # lib/legion/extensions/github/absorbers/issues.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'helpers'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Github
9
+ module Absorbers
10
+ # Absorbs GitHub issue events and normalizes them to fleet work items.
11
+ # Subscribes to lex.github.absorbers.issues queue.
12
+ #
13
+ # Filters: bot events, already-claimed issues (fleet labels), ignored
14
+ # actions (closed, transferred, etc.).
15
+ #
16
+ # Publishes normalized work items to the assessor queue via task chain.
17
+ module Issues
18
+ extend self
19
+ extend Helpers
20
+
21
+ CACHE_TTL = 86_400 # 24 hours
22
+
23
+ def description_max_bytes
24
+ Legion::Settings.dig(:fleet, :work_item, :description_max_bytes) || 32_768
25
+ rescue StandardError => _e
26
+ 32_768
27
+ end
28
+
29
+ # Main entry point. Called by the subscription actor when a GitHub
30
+ # webhook event for issues arrives.
31
+ #
32
+ # @param payload [Hash] Raw GitHub webhook payload (string keys from JSON)
33
+ # @return [Hash] { absorbed: true/false, ... }
34
+ def absorb(payload:, **)
35
+ return { absorbed: false, reason: :bot_generated } if bot_generated?(payload)
36
+ return { absorbed: false, reason: :already_claimed } if has_fleet_label?(payload)
37
+ return { absorbed: false, reason: :ignored } if ignored?(payload)
38
+
39
+ work_item = normalize(payload)
40
+
41
+ # NOTE: Absorber does NOT call set_nx — the assessor is the single dedup authority.
42
+ # Source-specific dedup only: label checks, bot filter, action filter.
43
+
44
+ # Store large raw payload in Redis, not inline in AMQP message
45
+ cache_key = "fleet:payload:#{work_item[:work_item_id]}"
46
+ cache_set(cache_key, json_dump(payload), ttl: CACHE_TTL)
47
+ work_item[:raw_payload_ref] = cache_key
48
+
49
+ # Publish to assessor via transport
50
+ publish_result = publish_to_assessor(work_item)
51
+
52
+ # Propagate publish failures — do not swallow
53
+ return publish_result if publish_result.is_a?(Hash) && publish_result[:absorbed] == false
54
+
55
+ { absorbed: true, work_item_id: work_item[:work_item_id] }
56
+ end
57
+
58
+ # Normalize a raw GitHub webhook payload to the standard fleet work
59
+ # item format (design spec section 3).
60
+ #
61
+ # @param payload [Hash] Raw GitHub webhook payload (string keys)
62
+ # @return [Hash] Normalized work item (symbol keys)
63
+ def normalize(payload)
64
+ issue = payload['issue'] || {}
65
+ repo = payload['repository'] || {}
66
+ action = payload['action'] || 'opened'
67
+ owner = repo.dig('owner', 'login') || ''
68
+ repo_name = repo['name'] || ''
69
+ number = issue['number']
70
+ body = issue['body'] || ''
71
+ max_bytes = description_max_bytes
72
+
73
+ {
74
+ work_item_id: generate_work_item_id,
75
+ source: 'github',
76
+ source_ref: "#{owner}/#{repo_name}##{number}",
77
+ source_event: "issues.#{action}",
78
+
79
+ title: issue['title'] || '',
80
+ description: body.bytesize > max_bytes ? body.byteslice(0, max_bytes).scrub('') : body,
81
+ raw_payload_ref: nil, # set after cache write in absorb
82
+
83
+ repo: {
84
+ owner: owner,
85
+ name: repo_name,
86
+ default_branch: repo['default_branch'] || 'main',
87
+ language: repo['language'] || 'unknown'
88
+ },
89
+
90
+ instructions: [],
91
+ context: [],
92
+
93
+ config: default_config,
94
+
95
+ pipeline: {
96
+ stage: 'intake',
97
+ trace: [],
98
+ attempt: 0,
99
+ feedback_history: [],
100
+ plan: nil,
101
+ changes: nil,
102
+ review_result: nil,
103
+ pr_number: nil,
104
+ branch_name: nil,
105
+ context_ref: nil
106
+ }
107
+ }
108
+ end
109
+
110
+ private
111
+
112
+ def default_config
113
+ {
114
+ priority: :medium,
115
+ complexity: nil,
116
+ estimated_difficulty: nil,
117
+ planning: default_config_planning,
118
+ implementation: default_config_implementation,
119
+ validation: default_config_validation,
120
+ feedback: default_config_feedback,
121
+ workspace: { isolation: :worktree, cleanup_on_complete: true },
122
+ context: { load_repo_docs: true, load_file_tree: true, max_context_files: 50 },
123
+ tracing: { stage_comments: true, token_tracking: true },
124
+ safety: { poison_message_threshold: 2, cancel_allowed: true },
125
+ selection: { strategy: :test_winner },
126
+ escalation: { on_max_iterations: :human, consent_domain: 'fleet.shipping' }
127
+ }
128
+ end
129
+
130
+ def default_config_planning
131
+ { enabled: true, solvers: 1, validators: 1, max_iterations: 2 }
132
+ end
133
+
134
+ def default_config_implementation
135
+ { solvers: 1, validators: 3, max_iterations: 5, models: nil }
136
+ end
137
+
138
+ def default_config_validation
139
+ {
140
+ enabled: true,
141
+ run_tests: true,
142
+ run_lint: true,
143
+ security_scan: true,
144
+ adversarial_review: true,
145
+ reviewer_models: nil
146
+ }
147
+ end
148
+
149
+ def default_config_feedback
150
+ { drain_enabled: true, max_drain_rounds: 3, summarize_after: 2 }
151
+ end
152
+
153
+ # Publish the normalized work item to the assessor's queue.
154
+ # Uses Legion::Transport::Messages::Task.
155
+ #
156
+ # generate_task_id returns a Hash { success:, task_id:, ... } — extract task_id.
157
+ # function: must be a String ('assess'), never a Symbol.
158
+ # Do NOT pass exchange: as String (broken until WS-00F lands).
159
+ #
160
+ # Propagates failures — returns { absorbed: false, reason: :publish_failed, ... }
161
+ def publish_to_assessor(work_item)
162
+ # Transport unavailable = lite mode / test environment. Not a publish failure; skip silently.
163
+ return unless transport_connected? && defined?(Legion::Runner)
164
+
165
+ result = Legion::Runner::Status.generate_task_id(
166
+ runner_class: 'Legion::Extensions::Assessor::Runners::Assessor',
167
+ function: 'assess'
168
+ )
169
+ task_id = result&.dig(:task_id)
170
+ raise 'Fleet: cannot create task record (is legion-data connected?)' if task_id.nil?
171
+
172
+ Legion::Transport::Messages::Task.new(
173
+ work_item: work_item,
174
+ function: 'assess',
175
+ task_id: task_id,
176
+ master_id: task_id,
177
+ routing_key: 'lex.assessor.runners.assessor.assess'
178
+ ).publish
179
+ rescue StandardError => e
180
+ log.warn("Absorber publish failed: #{e.message}")
181
+ { absorbed: false, reason: :publish_failed, message: e.message }
182
+ end
183
+
184
+ # Direct delegators to Legion::Cache and Legion::JSON.
185
+ # These thin wrappers satisfy the HelperMigration cops at call sites
186
+ # while preserving full control over key format and arguments.
187
+ # rubocop:disable Legion/HelperMigration/DirectCache, Legion/HelperMigration/DirectJson
188
+ def cache_set(key, value, ttl: nil)
189
+ Legion::Cache.set(key, value, ttl: ttl)
190
+ end
191
+
192
+ def json_dump(object)
193
+ Legion::JSON.dump(object)
194
+ end
195
+ # rubocop:enable Legion/HelperMigration/DirectCache, Legion/HelperMigration/DirectJson
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,101 @@
1
+ # lib/legion/extensions/github/absorbers/webhook_setup.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Legion
5
+ module Extensions
6
+ module Github
7
+ module Absorbers
8
+ # Mixin for auto-registering GitHub webhooks and fleet labels on a repo.
9
+ # Used by `legionio fleet add github` to wire up the absorber source.
10
+ #
11
+ # Include this module in a class that also includes the GitHub runners
12
+ # (RepositoryWebhooks, Labels).
13
+ module WebhookSetup
14
+ FLEET_WEBHOOK_EVENTS = %w[issues pull_request].freeze
15
+
16
+ FLEET_LABELS = [
17
+ { name: 'fleet:received', color: '6f42c1', description: 'Fleet pipeline has received this issue' },
18
+ { name: 'fleet:implementing', color: '0e8a16', description: 'Fleet is implementing a fix' },
19
+ { name: 'fleet:pr-open', color: '1d76db', description: 'Fleet has opened a PR for this issue' },
20
+ { name: 'fleet:escalated', color: 'e4e669', description: 'Fleet escalated this issue to a human' }
21
+ ].freeze
22
+
23
+ # Set up fleet webhook and labels on a GitHub repo.
24
+ #
25
+ # @param owner [String] Repository owner/org
26
+ # @param repo [String] Repository name
27
+ # @param webhook_url [String] Callback URL for webhook delivery
28
+ # @return [Hash] { success:, webhook_id:, labels_created: }
29
+ def setup_fleet_webhook(owner:, repo:, webhook_url:, **)
30
+ # Check if webhook already exists
31
+ existing = list_webhooks(owner: owner, repo: repo)
32
+ existing_hook = (existing[:result] || []).find do |hook|
33
+ url = hook.is_a?(Hash) ? (hook.dig('config', 'url') || hook.dig(:config, :url)) : nil
34
+ url == webhook_url
35
+ end
36
+
37
+ if existing_hook
38
+ hook_id = existing_hook['id'] || existing_hook[:id]
39
+ labels = ensure_fleet_labels(owner: owner, repo: repo)
40
+ return { success: true, existing: true, webhook_id: hook_id, labels_created: labels }
41
+ end
42
+
43
+ # Create webhook
44
+ config = {
45
+ url: webhook_url,
46
+ content_type: 'json',
47
+ insecure_ssl: '0'
48
+ }
49
+
50
+ result = create_webhook(
51
+ owner: owner,
52
+ repo: repo,
53
+ config: config,
54
+ events: FLEET_WEBHOOK_EVENTS,
55
+ active: true
56
+ )
57
+
58
+ webhook_data = result[:result] || {}
59
+ webhook_id = webhook_data['id'] || webhook_data[:id]
60
+
61
+ return { success: false, error: 'webhook creation returned no id' } if webhook_id.nil?
62
+
63
+ # Create fleet labels
64
+ labels = ensure_fleet_labels(owner: owner, repo: repo)
65
+
66
+ {
67
+ success: true,
68
+ existing: false,
69
+ webhook_id: webhook_id,
70
+ webhook_url: webhook_url,
71
+ labels_created: labels
72
+ }
73
+ rescue StandardError => e
74
+ { success: false, error: e.message }
75
+ end
76
+
77
+ def fleet_label_definitions
78
+ FLEET_LABELS
79
+ end
80
+
81
+ private
82
+
83
+ def ensure_fleet_labels(owner:, repo:)
84
+ created = []
85
+ FLEET_LABELS.each do |label_def|
86
+ create_label(
87
+ owner: owner,
88
+ repo: repo,
89
+ name: label_def[:name],
90
+ color: label_def[:color],
91
+ description: label_def[:description]
92
+ )
93
+ created << label_def[:name]
94
+ end
95
+ created
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Github
6
- VERSION = '0.3.5'
6
+ VERSION = '0.3.6'
7
7
  end
8
8
  end
9
9
  end
@@ -37,6 +37,12 @@ require 'legion/extensions/github/runners/releases'
37
37
  require 'legion/extensions/github/runners/deployments'
38
38
  require 'legion/extensions/github/runners/auth'
39
39
  require 'legion/extensions/github/runners/repository_webhooks'
40
+
41
+ # Absorber modules (fleet pipeline intake)
42
+ require 'legion/extensions/github/absorbers/helpers'
43
+ require 'legion/extensions/github/absorbers/issues'
44
+ require 'legion/extensions/github/absorbers/webhook_setup'
45
+
40
46
  require 'legion/extensions/github/client'
41
47
  require 'legion/extensions/github/cli/runner'
42
48
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-github
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -170,6 +170,10 @@ files:
170
170
  - README.md
171
171
  - lex-github.gemspec
172
172
  - lib/legion/extensions/github.rb
173
+ - lib/legion/extensions/github/absorbers/actor.rb
174
+ - lib/legion/extensions/github/absorbers/helpers.rb
175
+ - lib/legion/extensions/github/absorbers/issues.rb
176
+ - lib/legion/extensions/github/absorbers/webhook_setup.rb
173
177
  - lib/legion/extensions/github/app/actor/token_refresh.rb
174
178
  - lib/legion/extensions/github/app/actor/webhook_poller.rb
175
179
  - lib/legion/extensions/github/app/hooks/setup.rb