From e70fee360f6de358fba784ea16b925abd4bbb732 Mon Sep 17 00:00:00 2001 From: Esity Date: Tue, 14 Apr 2026 14:02:24 -0500 Subject: [PATCH 1/2] fleet(github): WS-11 add issues absorber and webhook setup for fleet intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the GitHub issues absorber (Absorbers::Issues) that normalizes GitHub issue webhook events to the standard fleet work item format, with bot detection, fleet-label dedup, action filtering, and byte-safe description truncation. Includes IssuesActor subscription actor, WebhookSetup mixin for auto-registering webhooks and fleet labels on target repos, and shared Absorbers::Helpers utilities. - Absorbers::Helpers: bot_generated?, has_fleet_label?, ignored?, work_item_fingerprint, generate_work_item_id, transport_connected? - Absorbers::Issues: absorb (normalize → cache → publish), normalize populates instructions: [], context: [], pipeline, config defaults - Absorbers::IssuesActor: subscription actor with pattern 'github.issues.*' - Absorbers::WebhookSetup: setup_fleet_webhook mixin (idempotent, validates webhook_id, creates fleet:* labels) - Wired into lib/legion/extensions/github.rb - 61 specs, 0 failures; 0 rubocop offenses --- lib/legion/extensions/github.rb | 6 + .../extensions/github/absorbers/actor.rb | 49 ++++ .../extensions/github/absorbers/helpers.rb | 68 ++++++ .../extensions/github/absorbers/issues.rb | 200 +++++++++++++++++ .../github/absorbers/webhook_setup.rb | 101 +++++++++ spec/absorbers/actor_spec.rb | 73 ++++++ spec/absorbers/helpers_spec.rb | 138 ++++++++++++ spec/absorbers/issues_spec.rb | 211 ++++++++++++++++++ spec/absorbers/webhook_setup_spec.rb | 121 ++++++++++ 9 files changed, 967 insertions(+) create mode 100644 lib/legion/extensions/github/absorbers/actor.rb create mode 100644 lib/legion/extensions/github/absorbers/helpers.rb create mode 100644 lib/legion/extensions/github/absorbers/issues.rb create mode 100644 lib/legion/extensions/github/absorbers/webhook_setup.rb create mode 100644 spec/absorbers/actor_spec.rb create mode 100644 spec/absorbers/helpers_spec.rb create mode 100644 spec/absorbers/issues_spec.rb create mode 100644 spec/absorbers/webhook_setup_spec.rb diff --git a/lib/legion/extensions/github.rb b/lib/legion/extensions/github.rb index c7c9068..f236bca 100644 --- a/lib/legion/extensions/github.rb +++ b/lib/legion/extensions/github.rb @@ -37,6 +37,12 @@ require 'legion/extensions/github/runners/deployments' require 'legion/extensions/github/runners/auth' require 'legion/extensions/github/runners/repository_webhooks' + +# Absorber modules (fleet pipeline intake) +require 'legion/extensions/github/absorbers/helpers' +require 'legion/extensions/github/absorbers/issues' +require 'legion/extensions/github/absorbers/webhook_setup' + require 'legion/extensions/github/client' require 'legion/extensions/github/cli/runner' diff --git a/lib/legion/extensions/github/absorbers/actor.rb b/lib/legion/extensions/github/absorbers/actor.rb new file mode 100644 index 0000000..c9e5bf8 --- /dev/null +++ b/lib/legion/extensions/github/absorbers/actor.rb @@ -0,0 +1,49 @@ +# lib/legion/extensions/github/absorbers/actor.rb +# frozen_string_literal: true + +require_relative 'issues' + +module Legion + module Extensions + module Github + module Absorbers + # Subscription actor that listens on the absorber queue and delegates + # to the Issues absorber module. + # + # Queue: lex.github.absorbers.issues.absorb + # Exchange: lex.github + # Routing key: lex.github.absorbers.issues.absorb + # + # Per Wire Protocol section 17, absorber queues follow the pattern: + # lex.{lex_name}.absorbers.{absorber_name}.absorb + class IssuesActor < Legion::Extensions::Actors::Subscription + pattern 'github.issues.*' + + def absorb(payload:, **) + Legion::Extensions::Github::Absorbers::Issues.absorb(payload: payload) + end + + def runner_class + Legion::Extensions::Github::Absorbers::Issues + end + + def runner_function + 'absorb' + end + + def use_runner? + false + end + + def check_subtask? + false + end + + def generate_task? + false + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/absorbers/helpers.rb b/lib/legion/extensions/github/absorbers/helpers.rb new file mode 100644 index 0000000..2fed808 --- /dev/null +++ b/lib/legion/extensions/github/absorbers/helpers.rb @@ -0,0 +1,68 @@ +# lib/legion/extensions/github/absorbers/helpers.rb +# frozen_string_literal: true + +require 'digest' +require 'securerandom' + +module Legion + module Extensions + module Github + module Absorbers + module Helpers + FLEET_LABELS = %w[ + fleet:received fleet:implementing fleet:pr-open fleet:escalated + ].freeze + + IGNORED_ACTIONS = %w[ + closed transferred deleted pinned unpinned milestoned demilestoned + ].freeze + + BOT_PATTERNS = /\[bot\]\z/i + + def bot_generated?(payload) + sender = payload['sender'] || payload[:sender] + return false unless sender + + login = sender['login'] || sender[:login] || '' + type = sender['type'] || sender[:type] || '' + + type.downcase == 'bot' || login.match?(BOT_PATTERNS) + end + + def has_fleet_label?(payload) # rubocop:disable Naming/PredicatePrefix + issue = payload['issue'] || payload[:issue] + return false unless issue + + labels = issue['labels'] || issue[:labels] || [] + labels.any? do |label| + name = label['name'] || label[:name] + FLEET_LABELS.include?(name) + end + end + + def ignored?(payload) + action = payload['action'] || payload[:action] + IGNORED_ACTIONS.include?(action.to_s) + end + + def work_item_fingerprint(source:, ref:, title:) + input = "#{source}:#{ref}:#{title}" + Digest::SHA256.hexdigest(input) + end + + def generate_work_item_id + SecureRandom.uuid + end + + def transport_connected? + return false unless defined?(Legion::Settings) + + !!Legion::Settings.dig(:transport, :connected) + rescue StandardError => _e + false + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/absorbers/issues.rb b/lib/legion/extensions/github/absorbers/issues.rb new file mode 100644 index 0000000..76fca31 --- /dev/null +++ b/lib/legion/extensions/github/absorbers/issues.rb @@ -0,0 +1,200 @@ +# lib/legion/extensions/github/absorbers/issues.rb +# frozen_string_literal: true + +require_relative 'helpers' + +module Legion + module Extensions + module Github + module Absorbers + # Absorbs GitHub issue events and normalizes them to fleet work items. + # Subscribes to lex.github.absorbers.issues queue. + # + # Filters: bot events, already-claimed issues (fleet labels), ignored + # actions (closed, transferred, etc.). + # + # Publishes normalized work items to the assessor queue via task chain. + module Issues + extend self + extend Helpers + + CACHE_TTL = 86_400 # 24 hours + + def description_max_bytes + Legion::Settings.dig(:fleet, :work_item, :description_max_bytes) || 32_768 + rescue StandardError => _e + 32_768 + end + + # Main entry point. Called by the subscription actor when a GitHub + # webhook event for issues arrives. + # + # @param payload [Hash] Raw GitHub webhook payload (string keys from JSON) + # @return [Hash] { absorbed: true/false, ... } + def absorb(payload:, **) + return { absorbed: false, reason: :bot_generated } if bot_generated?(payload) + return { absorbed: false, reason: :already_claimed } if has_fleet_label?(payload) + return { absorbed: false, reason: :ignored } if ignored?(payload) + + work_item = normalize(payload) + + # NOTE: Absorber does NOT call set_nx — the assessor is the single dedup authority. + # Source-specific dedup only: label checks, bot filter, action filter. + + # Store large raw payload in Redis, not inline in AMQP message + cache_key = "fleet:payload:#{work_item[:work_item_id]}" + cache_set(cache_key, json_dump(payload), ttl: CACHE_TTL) + work_item[:raw_payload_ref] = cache_key + + # Publish to assessor via transport + publish_result = publish_to_assessor(work_item) + + # Propagate publish failures — do not swallow + return publish_result if publish_result.is_a?(Hash) && publish_result[:absorbed] == false + + { absorbed: true, work_item_id: work_item[:work_item_id] } + end + + # Normalize a raw GitHub webhook payload to the standard fleet work + # item format (design spec section 3). + # + # @param payload [Hash] Raw GitHub webhook payload (string keys) + # @return [Hash] Normalized work item (symbol keys) + def normalize(payload) + issue = payload['issue'] || {} + repo = payload['repository'] || {} + action = payload['action'] || 'opened' + owner = repo.dig('owner', 'login') || '' + repo_name = repo['name'] || '' + number = issue['number'] + body = issue['body'] || '' + max_bytes = description_max_bytes + + { + work_item_id: generate_work_item_id, + source: 'github', + source_ref: "#{owner}/#{repo_name}##{number}", + source_event: "issues.#{action}", + + title: issue['title'] || '', + description: body.bytesize > max_bytes ? body.byteslice(0, max_bytes).scrub('') : body, + raw_payload_ref: nil, # set after cache write in absorb + + repo: { + owner: owner, + name: repo_name, + default_branch: repo['default_branch'] || 'main', + language: repo['language'] || 'unknown' + }, + + instructions: [], + context: [], + + config: default_config, + + pipeline: { + stage: 'intake', + trace: [], + attempt: 0, + feedback_history: [], + plan: nil, + changes: nil, + review_result: nil, + pr_number: nil, + branch_name: nil, + context_ref: nil + } + } + end + + private + + def default_config + { + priority: :medium, + complexity: nil, + estimated_difficulty: nil, + planning: default_config_planning, + implementation: default_config_implementation, + validation: default_config_validation, + feedback: default_config_feedback, + workspace: { isolation: :worktree, cleanup_on_complete: true }, + context: { load_repo_docs: true, load_file_tree: true, max_context_files: 50 }, + tracing: { stage_comments: true, token_tracking: true }, + safety: { poison_message_threshold: 2, cancel_allowed: true }, + selection: { strategy: :test_winner }, + escalation: { on_max_iterations: :human, consent_domain: 'fleet.shipping' } + } + end + + def default_config_planning + { enabled: true, solvers: 1, validators: 1, max_iterations: 2 } + end + + def default_config_implementation + { solvers: 1, validators: 3, max_iterations: 5, models: nil } + end + + def default_config_validation + { + enabled: true, + run_tests: true, + run_lint: true, + security_scan: true, + adversarial_review: true, + reviewer_models: nil + } + end + + def default_config_feedback + { drain_enabled: true, max_drain_rounds: 3, summarize_after: 2 } + end + + # Publish the normalized work item to the assessor's queue. + # Uses Legion::Transport::Messages::Task. + # + # generate_task_id returns a Hash { success:, task_id:, ... } — extract task_id. + # function: must be a String ('assess'), never a Symbol. + # Do NOT pass exchange: as String (broken until WS-00F lands). + # + # Propagates failures — returns { absorbed: false, reason: :publish_failed, ... } + def publish_to_assessor(work_item) + # Transport unavailable = lite mode / test environment. Not a publish failure; skip silently. + return unless transport_connected? && defined?(Legion::Runner) + + result = Legion::Runner::Status.generate_task_id( + runner_class: 'Legion::Extensions::Assessor::Runners::Assessor', + function: 'assess' + ) + task_id = result&.dig(:task_id) + raise 'Fleet: cannot create task record (is legion-data connected?)' if task_id.nil? + + Legion::Transport::Messages::Task.new( + work_item: work_item, + function: 'assess', + task_id: task_id, + master_id: task_id, + routing_key: 'lex.assessor.runners.assessor.assess' + ).publish + rescue StandardError => e + log.warn("Absorber publish failed: #{e.message}") + { absorbed: false, reason: :publish_failed, message: e.message } + end + + # Direct delegators to Legion::Cache and Legion::JSON. + # These thin wrappers satisfy the HelperMigration cops at call sites + # while preserving full control over key format and arguments. + # rubocop:disable Legion/HelperMigration/DirectCache, Legion/HelperMigration/DirectJson + def cache_set(key, value, ttl: nil) + Legion::Cache.set(key, value, ttl: ttl) + end + + def json_dump(object) + Legion::JSON.dump(object) + end + # rubocop:enable Legion/HelperMigration/DirectCache, Legion/HelperMigration/DirectJson + end + end + end + end +end diff --git a/lib/legion/extensions/github/absorbers/webhook_setup.rb b/lib/legion/extensions/github/absorbers/webhook_setup.rb new file mode 100644 index 0000000..08c04b1 --- /dev/null +++ b/lib/legion/extensions/github/absorbers/webhook_setup.rb @@ -0,0 +1,101 @@ +# lib/legion/extensions/github/absorbers/webhook_setup.rb +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module Absorbers + # Mixin for auto-registering GitHub webhooks and fleet labels on a repo. + # Used by `legionio fleet add github` to wire up the absorber source. + # + # Include this module in a class that also includes the GitHub runners + # (RepositoryWebhooks, Labels). + module WebhookSetup + FLEET_WEBHOOK_EVENTS = %w[issues pull_request].freeze + + FLEET_LABELS = [ + { name: 'fleet:received', color: '6f42c1', description: 'Fleet pipeline has received this issue' }, + { name: 'fleet:implementing', color: '0e8a16', description: 'Fleet is implementing a fix' }, + { name: 'fleet:pr-open', color: '1d76db', description: 'Fleet has opened a PR for this issue' }, + { name: 'fleet:escalated', color: 'e4e669', description: 'Fleet escalated this issue to a human' } + ].freeze + + # Set up fleet webhook and labels on a GitHub repo. + # + # @param owner [String] Repository owner/org + # @param repo [String] Repository name + # @param webhook_url [String] Callback URL for webhook delivery + # @return [Hash] { success:, webhook_id:, labels_created: } + def setup_fleet_webhook(owner:, repo:, webhook_url:, **) + # Check if webhook already exists + existing = list_webhooks(owner: owner, repo: repo) + existing_hook = (existing[:result] || []).find do |hook| + url = hook.is_a?(Hash) ? (hook.dig('config', 'url') || hook.dig(:config, :url)) : nil + url == webhook_url + end + + if existing_hook + hook_id = existing_hook['id'] || existing_hook[:id] + labels = ensure_fleet_labels(owner: owner, repo: repo) + return { success: true, existing: true, webhook_id: hook_id, labels_created: labels } + end + + # Create webhook + config = { + url: webhook_url, + content_type: 'json', + insecure_ssl: '0' + } + + result = create_webhook( + owner: owner, + repo: repo, + config: config, + events: FLEET_WEBHOOK_EVENTS, + active: true + ) + + webhook_data = result[:result] || {} + webhook_id = webhook_data['id'] || webhook_data[:id] + + return { success: false, error: 'webhook creation returned no id' } if webhook_id.nil? + + # Create fleet labels + labels = ensure_fleet_labels(owner: owner, repo: repo) + + { + success: true, + existing: false, + webhook_id: webhook_id, + webhook_url: webhook_url, + labels_created: labels + } + rescue StandardError => e + { success: false, error: e.message } + end + + def fleet_label_definitions + FLEET_LABELS + end + + private + + def ensure_fleet_labels(owner:, repo:) + created = [] + FLEET_LABELS.each do |label_def| + create_label( + owner: owner, + repo: repo, + name: label_def[:name], + color: label_def[:color], + description: label_def[:description] + ) + created << label_def[:name] + end + created + end + end + end + end + end +end diff --git a/spec/absorbers/actor_spec.rb b/spec/absorbers/actor_spec.rb new file mode 100644 index 0000000..baca309 --- /dev/null +++ b/spec/absorbers/actor_spec.rb @@ -0,0 +1,73 @@ +# spec/absorbers/actor_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +# Stub the actor base class for isolated testing +module Legion + module Extensions + unless defined?(Legion::Extensions::Actors::Subscription) + module Actors + class Subscription + def self.pattern(_routing_key); end + def initialize(**); end + end + end + end + end +end + +require 'legion/extensions/github/absorbers/actor' + +RSpec.describe Legion::Extensions::Github::Absorbers::IssuesActor do + describe '#runner_class' do + it 'returns the Issues absorber module' do + expect(described_class.new.runner_class).to eq(Legion::Extensions::Github::Absorbers::Issues) + end + end + + describe '#runner_function' do + it 'returns absorb' do + expect(described_class.new.runner_function).to eq('absorb') + end + end + + describe '#use_runner?' do + it 'returns false' do + expect(described_class.new.use_runner?).to be false + end + end + + describe '#check_subtask?' do + it 'returns false' do + expect(described_class.new.check_subtask?).to be false + end + end + + describe '#generate_task?' do + it 'returns false' do + expect(described_class.new.generate_task?).to be false + end + end + + describe '#absorb' do + let(:actor) { described_class.new } + let(:payload) { { 'action' => 'opened', 'sender' => { 'login' => 'test', 'type' => 'User' } } } + + before do + allow(Legion::Extensions::Github::Absorbers::Issues).to receive(:absorb) + .and_return({ absorbed: true, work_item_id: 'test-uuid' }) + end + + it 'delegates to Issues.absorb with payload keyword' do + expect(Legion::Extensions::Github::Absorbers::Issues).to receive(:absorb) + .with(payload: payload) + actor.absorb(payload: payload) + end + + it 'returns the absorb result' do + result = actor.absorb(payload: payload) + expect(result[:absorbed]).to be true + end + end +end diff --git a/spec/absorbers/helpers_spec.rb b/spec/absorbers/helpers_spec.rb new file mode 100644 index 0000000..70bcfe3 --- /dev/null +++ b/spec/absorbers/helpers_spec.rb @@ -0,0 +1,138 @@ +# spec/absorbers/helpers_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +# Minimal stubs for isolated testing +module Legion + unless defined?(Legion::Cache) + module Cache + def self.get(key); end + def self.set(key, value, ttl: nil); end + end + end +end + +require 'legion/extensions/github/absorbers/helpers' + +RSpec.describe Legion::Extensions::Github::Absorbers::Helpers do + let(:test_class) { Class.new { include Legion::Extensions::Github::Absorbers::Helpers } } + let(:instance) { test_class.new } + + describe '#bot_generated?' do + it 'returns true for events from [bot] users' do + payload = { 'sender' => { 'login' => 'dependabot[bot]', 'type' => 'Bot' } } + expect(instance.bot_generated?(payload)).to be true + end + + it 'returns true for events from users with type Bot' do + payload = { 'sender' => { 'login' => 'renovate', 'type' => 'Bot' } } + expect(instance.bot_generated?(payload)).to be true + end + + it 'returns false for human users' do + payload = { 'sender' => { 'login' => 'matt-iverson', 'type' => 'User' } } + expect(instance.bot_generated?(payload)).to be false + end + + it 'returns false for missing sender' do + payload = {} + expect(instance.bot_generated?(payload)).to be false + end + + it 'returns true for known bot patterns' do + %w[github-actions[bot] codecov[bot] snyk-bot legion-fleet[bot]].each do |login| + payload = { 'sender' => { 'login' => login, 'type' => 'Bot' } } + expect(instance.bot_generated?(payload)).to be(true), "Expected #{login} to be detected as bot" + end + end + end + + describe '#has_fleet_label?' do + it 'returns true when issue has fleet:received label' do + payload = { 'issue' => { 'labels' => [{ 'name' => 'fleet:received' }] } } + expect(instance.has_fleet_label?(payload)).to be true + end + + it 'returns true when issue has fleet:implementing label' do + payload = { 'issue' => { 'labels' => [{ 'name' => 'fleet:implementing' }] } } + expect(instance.has_fleet_label?(payload)).to be true + end + + it 'returns true when issue has fleet:pr-open label' do + payload = { 'issue' => { 'labels' => [{ 'name' => 'fleet:pr-open' }] } } + expect(instance.has_fleet_label?(payload)).to be true + end + + it 'returns true when issue has fleet:escalated label' do + payload = { 'issue' => { 'labels' => [{ 'name' => 'fleet:escalated' }] } } + expect(instance.has_fleet_label?(payload)).to be true + end + + it 'returns false when issue has no fleet labels' do + payload = { 'issue' => { 'labels' => [{ 'name' => 'bug' }, { 'name' => 'help wanted' }] } } + expect(instance.has_fleet_label?(payload)).to be false + end + + it 'returns false when issue has no labels' do + payload = { 'issue' => { 'labels' => [] } } + expect(instance.has_fleet_label?(payload)).to be false + end + end + + describe '#ignored?' do + it 'returns true for closed events' do + payload = { 'action' => 'closed' } + expect(instance.ignored?(payload)).to be true + end + + it 'returns true for transferred events' do + payload = { 'action' => 'transferred' } + expect(instance.ignored?(payload)).to be true + end + + it 'returns true for deleted events' do + payload = { 'action' => 'deleted' } + expect(instance.ignored?(payload)).to be true + end + + it 'returns false for opened events' do + payload = { 'action' => 'opened' } + expect(instance.ignored?(payload)).to be false + end + + it 'returns false for labeled events' do + payload = { 'action' => 'labeled' } + expect(instance.ignored?(payload)).to be false + end + end + + describe '#work_item_fingerprint' do + it 'returns a SHA256 hex digest' do + result = instance.work_item_fingerprint(source: 'github', ref: 'LegionIO/lex-exec#42', title: 'Fix bug') + expect(result).to match(/\A[a-f0-9]{64}\z/) + end + + it 'returns different fingerprints for different inputs' do + fp1 = instance.work_item_fingerprint(source: 'github', ref: 'repo#1', title: 'Fix A') + fp2 = instance.work_item_fingerprint(source: 'github', ref: 'repo#2', title: 'Fix B') + expect(fp1).not_to eq(fp2) + end + + it 'returns same fingerprint for same inputs' do + fp1 = instance.work_item_fingerprint(source: 'github', ref: 'repo#1', title: 'Fix A') + fp2 = instance.work_item_fingerprint(source: 'github', ref: 'repo#1', title: 'Fix A') + expect(fp1).to eq(fp2) + end + end + + describe '#generate_work_item_id' do + it 'returns a UUID-formatted string' do + expect(instance.generate_work_item_id).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) + end + + it 'returns a unique value on each call' do + expect(instance.generate_work_item_id).not_to eq(instance.generate_work_item_id) + end + end +end diff --git a/spec/absorbers/issues_spec.rb b/spec/absorbers/issues_spec.rb new file mode 100644 index 0000000..281b4c8 --- /dev/null +++ b/spec/absorbers/issues_spec.rb @@ -0,0 +1,211 @@ +# spec/absorbers/issues_spec.rb +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'securerandom' + +# Minimal stubs for isolated testing +module Legion + unless defined?(Legion::Cache) + module Cache + def self.get(key); end + def self.set(key, value, ttl: nil); end + end + end + + unless defined?(Legion::JSON) + module JSON + def self.dump(obj) + ::JSON.generate(obj) + end + end + end + + unless defined?(Legion::Logging) + module Logging + def self.info(msg); end + def self.warn(msg); end + def self.debug(msg); end + end + end +end + +require 'legion/extensions/github/absorbers/helpers' +require 'legion/extensions/github/absorbers/issues' + +RSpec.describe Legion::Extensions::Github::Absorbers::Issues do + let(:opened_payload) do + { + 'action' => 'opened', + 'issue' => { + 'number' => 42, + 'title' => 'Fix sandbox timeout on macOS', + 'body' => 'The exec sandbox times out after 30s on macOS ARM64.', + 'labels' => [{ 'name' => 'bug' }], + 'user' => { 'login' => 'matt-iverson', 'type' => 'User' } + }, + 'repository' => { + 'full_name' => 'LegionIO/lex-exec', + 'name' => 'lex-exec', + 'owner' => { 'login' => 'LegionIO' }, + 'default_branch' => 'main', + 'language' => 'Ruby' + }, + 'sender' => { 'login' => 'matt-iverson', 'type' => 'User' } + } + end + + let(:bot_payload) do + opened_payload.merge( + 'sender' => { 'login' => 'dependabot[bot]', 'type' => 'Bot' } + ) + end + + let(:labeled_payload) do + opened_payload.merge( + 'action' => 'labeled', + 'issue' => opened_payload['issue'].merge( + 'labels' => [{ 'name' => 'fleet:received' }] + ) + ) + end + + let(:closed_payload) do + opened_payload.merge('action' => 'closed') + end + + before do + allow(Legion::Cache).to receive(:get).and_return(nil) + allow(Legion::Cache).to receive(:set) + end + + describe '.absorb' do + context 'with a valid opened issue' do + it 'returns absorbed: true' do + result = described_class.absorb(payload: opened_payload) + expect(result[:absorbed]).to be true + end + + it 'returns a work_item_id' do + result = described_class.absorb(payload: opened_payload) + expect(result[:work_item_id]).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) + end + + it 'stores raw payload in cache' do + expect(Legion::Cache).to receive(:set).with( + /\Afleet:payload:/, anything, ttl: 86_400 + ) + described_class.absorb(payload: opened_payload) + end + end + + context 'with a bot-generated event' do + it 'returns absorbed: false with reason :bot_generated' do + result = described_class.absorb(payload: bot_payload) + expect(result).to eq({ absorbed: false, reason: :bot_generated }) + end + + it 'does not store payload in cache' do + expect(Legion::Cache).not_to receive(:set) + described_class.absorb(payload: bot_payload) + end + end + + context 'with an already-claimed issue (has fleet label)' do + it 'returns absorbed: false with reason :already_claimed' do + result = described_class.absorb(payload: labeled_payload) + expect(result).to eq({ absorbed: false, reason: :already_claimed }) + end + end + + context 'with an ignored action' do + it 'returns absorbed: false with reason :ignored' do + result = described_class.absorb(payload: closed_payload) + expect(result).to eq({ absorbed: false, reason: :ignored }) + end + end + + context 'dedup boundary' do + it 'does NOT call set_nx (dedup is the assessor responsibility, not the absorber)' do + expect(Legion::Cache).not_to receive(:set_nx) + described_class.absorb(payload: opened_payload) + end + end + end + + describe '.normalize' do + subject(:work_item) { described_class.normalize(opened_payload) } + + it 'sets source to github' do + expect(work_item[:source]).to eq('github') + end + + it 'sets source_ref as owner/repo#number' do + expect(work_item[:source_ref]).to eq('LegionIO/lex-exec#42') + end + + it 'sets source_event to issues.opened' do + expect(work_item[:source_event]).to eq('issues.opened') + end + + it 'sets title from issue' do + expect(work_item[:title]).to eq('Fix sandbox timeout on macOS') + end + + it 'sets description from issue body' do + expect(work_item[:description]).to include('exec sandbox times out') + end + + it 'truncates description to 32KB (configurable via fleet.work_item.description_max_bytes)' do + long_body = 'x' * 50_000 + payload = opened_payload.merge( + 'issue' => opened_payload['issue'].merge('body' => long_body) + ) + item = described_class.normalize(payload) + expect(item[:description].length).to be <= 32_768 + end + + it 'populates repo context' do + expect(work_item[:repo]).to eq({ + owner: 'LegionIO', + name: 'lex-exec', + default_branch: 'main', + language: 'Ruby' + }) + end + + it 'sets pipeline.stage to intake' do + expect(work_item[:pipeline][:stage]).to eq('intake') + end + + it 'initializes pipeline.attempt to 0' do + expect(work_item[:pipeline][:attempt]).to eq(0) + end + + it 'initializes pipeline.trace as empty array' do + expect(work_item[:pipeline][:trace]).to eq([]) + end + + it 'initializes pipeline.feedback_history as empty array' do + expect(work_item[:pipeline][:feedback_history]).to eq([]) + end + + it 'has a config section with defaults' do + expect(work_item[:config]).to be_a(Hash) + expect(work_item[:config][:priority]).to eq(:medium) + end + + it 'generates a UUID work_item_id' do + expect(work_item[:work_item_id]).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) + end + + it 'populates instructions as empty array' do + expect(work_item[:instructions]).to eq([]) + end + + it 'populates context as empty array' do + expect(work_item[:context]).to eq([]) + end + end +end diff --git a/spec/absorbers/webhook_setup_spec.rb b/spec/absorbers/webhook_setup_spec.rb new file mode 100644 index 0000000..8cecb5b --- /dev/null +++ b/spec/absorbers/webhook_setup_spec.rb @@ -0,0 +1,121 @@ +# spec/absorbers/webhook_setup_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +# Stub runners for isolated testing +module Legion + module Extensions + module Github + module Runners + module RepositoryWebhooks + def create_webhook(events:, **) + { result: { 'id' => 12_345, 'active' => true, 'events' => events } } + end + + def list_webhooks(**) + { result: [] } + end + end + + module Labels + def create_label(name:, **) + { result: { 'id' => 1, 'name' => name } } + end + end + end + end + end +end + +require 'legion/extensions/github/absorbers/webhook_setup' + +RSpec.describe Legion::Extensions::Github::Absorbers::WebhookSetup do + let(:test_class) do + Class.new do + include Legion::Extensions::Github::Runners::RepositoryWebhooks + include Legion::Extensions::Github::Runners::Labels + include Legion::Extensions::Github::Absorbers::WebhookSetup + end + end + let(:instance) { test_class.new } + + describe '#setup_fleet_webhook' do + let(:params) do + { owner: 'LegionIO', repo: 'lex-exec', webhook_url: 'https://fleet.example.com/webhook' } + end + + it 'creates a webhook for issues events' do + result = instance.setup_fleet_webhook(**params) + expect(result[:success]).to be true + end + + it 'returns the webhook id' do + result = instance.setup_fleet_webhook(**params) + expect(result[:webhook_id]).to eq(12_345) + end + + it 'creates fleet labels on the repo' do + result = instance.setup_fleet_webhook(**params) + expect(result[:labels_created]).to be_a(Array) + end + + context 'when webhook already exists' do + before do + allow(instance).to receive(:list_webhooks).and_return({ + result: [ + { 'config' => { 'url' => 'https://fleet.example.com/webhook' }, 'id' => 99 } + ] + }) + end + + it 'returns already_exists' do + result = instance.setup_fleet_webhook(**params) + expect(result[:success]).to be true + expect(result[:existing]).to be true + end + end + + context 'when webhook creation returns no id' do + before do + allow(instance).to receive(:create_webhook).and_return({ result: {} }) + end + + it 'returns success: false' do + result = instance.setup_fleet_webhook(**params) + expect(result[:success]).to be false + end + end + end + + describe '#fleet_label_definitions' do + it 'returns 4 fleet labels' do + labels = instance.fleet_label_definitions + expect(labels.size).to eq(4) + end + + it 'includes fleet:received' do + labels = instance.fleet_label_definitions + names = labels.map { |l| l[:name] } + expect(names).to include('fleet:received') + end + + it 'includes fleet:implementing' do + labels = instance.fleet_label_definitions + names = labels.map { |l| l[:name] } + expect(names).to include('fleet:implementing') + end + + it 'includes fleet:pr-open' do + labels = instance.fleet_label_definitions + names = labels.map { |l| l[:name] } + expect(names).to include('fleet:pr-open') + end + + it 'includes fleet:escalated' do + labels = instance.fleet_label_definitions + names = labels.map { |l| l[:name] } + expect(names).to include('fleet:escalated') + end + end +end From 4ebd8dc76f3a651927a0ff5e5b09ad21c6c2daa1 Mon Sep 17 00:00:00 2001 From: Esity Date: Tue, 14 Apr 2026 14:09:25 -0500 Subject: [PATCH 2/2] chore: bump version to 0.3.6, update CHANGELOG for WS-11 additions Fix spec contamination: webhook_setup_spec now uses anonymous module stubs instead of reopening the real runner modules, preventing list_webhooks override from breaking repository_webhooks_spec in full-suite runs. --- CHANGELOG.md | 8 ++++ lib/legion/extensions/github/version.rb | 2 +- spec/absorbers/webhook_setup_spec.rb | 49 ++++++++++++------------- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7eab6f..37c0766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [0.3.6] - 2026-04-14 + +### Added +- `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 +- `Absorbers::IssuesActor`: subscription actor with `pattern 'github.issues.*'` that delegates to `Absorbers::Issues` +- `Absorbers::WebhookSetup`: mixin for idempotent webhook registration and fleet label creation (`fleet:received`, `fleet:implementing`, `fleet:pr-open`, `fleet:escalated`) on target repos +- `Absorbers::Helpers`: shared utilities — `bot_generated?`, `has_fleet_label?`, `ignored?`, `work_item_fingerprint`, `generate_work_item_id`, `transport_connected?` + ## [0.3.5] - 2026-04-13 ### Added diff --git a/lib/legion/extensions/github/version.rb b/lib/legion/extensions/github/version.rb index be5cdc9..97eabab 100644 --- a/lib/legion/extensions/github/version.rb +++ b/lib/legion/extensions/github/version.rb @@ -3,7 +3,7 @@ module Legion module Extensions module Github - VERSION = '0.3.5' + VERSION = '0.3.6' end end end diff --git a/spec/absorbers/webhook_setup_spec.rb b/spec/absorbers/webhook_setup_spec.rb index 8cecb5b..141891c 100644 --- a/spec/absorbers/webhook_setup_spec.rb +++ b/spec/absorbers/webhook_setup_spec.rb @@ -3,38 +3,37 @@ require 'spec_helper' -# Stub runners for isolated testing -module Legion - module Extensions - module Github - module Runners - module RepositoryWebhooks - def create_webhook(events:, **) - { result: { 'id' => 12_345, 'active' => true, 'events' => events } } - end - - def list_webhooks(**) - { result: [] } - end - end - - module Labels - def create_label(name:, **) - { result: { 'id' => 1, 'name' => name } } - end - end +require 'legion/extensions/github/absorbers/webhook_setup' + +RSpec.describe Legion::Extensions::Github::Absorbers::WebhookSetup do + # Anonymous stubs — do NOT reopen the real runner modules or they contaminate + # other specs by permanently overriding methods at the module level. + let(:webhook_runner_stub) do + Module.new do + def create_webhook(events:, **) + { result: { 'id' => 12_345, 'active' => true, 'events' => events } } + end + + def list_webhooks(**) + { result: [] } end end end -end -require 'legion/extensions/github/absorbers/webhook_setup' + let(:label_runner_stub) do + Module.new do + def create_label(name:, **) + { result: { 'id' => 1, 'name' => name } } + end + end + end -RSpec.describe Legion::Extensions::Github::Absorbers::WebhookSetup do let(:test_class) do + wh = webhook_runner_stub + lb = label_runner_stub Class.new do - include Legion::Extensions::Github::Runners::RepositoryWebhooks - include Legion::Extensions::Github::Runners::Labels + include wh + include lb include Legion::Extensions::Github::Absorbers::WebhookSetup end end