From 4bf822e5d3dfb8e1cc7a61dcc627658a0cf1ab92 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 26 Apr 2026 15:40:25 -0300 Subject: [PATCH] feat: queue GitHub comment bounty intents Resolves #25 --- app/models/github/bounty_intent.rb | 57 ++++++++++++++++++++++++ app/models/github/event.rb | 1 + spec/models/github/bounty_intent_spec.rb | 35 +++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 app/models/github/bounty_intent.rb create mode 100644 spec/models/github/bounty_intent_spec.rb diff --git a/app/models/github/bounty_intent.rb b/app/models/github/bounty_intent.rb new file mode 100644 index 000000000..abfc0c1ae --- /dev/null +++ b/app/models/github/bounty_intent.rb @@ -0,0 +1,57 @@ +require 'bigdecimal' + +class Github::BountyIntent + AMOUNT_PATTERN = /(?:^|\s)(?:\$\+|\+\$|\$)\s*(\d+(?:\.\d{1,2})?)(?=\s|$|[[:punct:]])/ + + attr_reader :amount + + def initialize(amount) + @amount = BigDecimal(amount.to_s) + end + + def self.extract(text) + text.to_s.scan(AMOUNT_PATTERN).flatten.map do |amount| + new(amount) + end.select(&:valid?) + end + + def self.queue_from_comment(comment_data, issue:, actor:) + intents = extract(comment_data.try(:[], 'body')) + return [] if intents.empty? || issue.blank? || actor.try(:person).blank? + + intents.map { |intent| intent.queue_for(actor.person, issue) }.compact + end + + def valid? + amount > 0 + end + + def to_cart_item(issue) + { + item_type: 'Bounty', + amount: amount.to_s('F'), + currency: 'USD', + issue_id: issue.id, + tweet: false + } + end + + def queue_for(person, issue) + cart = person.shopping_cart + attrs = to_cart_item(issue) + return nil if duplicate?(cart, attrs) + + cart.add_item(attrs) + end + + private + + def duplicate?(cart, attrs) + cart.items.any? do |cart_item| + item = cart_item.with_indifferent_access + item[:item_type] == attrs[:item_type] && + item[:issue_id].to_i == attrs[:issue_id].to_i && + BigDecimal(item[:amount].to_s) == amount + end + end +end diff --git a/app/models/github/event.rb b/app/models/github/event.rb index 7419c0355..952247adf 100644 --- a/app/models/github/event.rb +++ b/app/models/github/event.rb @@ -81,6 +81,7 @@ def self.process_event(event) when 'IssueCommentEvent' issue = issue(event['payload']['issue'], tracker: repo) comment(event['payload']['comment'], issue: issue) + Github::BountyIntent.queue_from_comment(event['payload']['comment'], issue: issue, actor: actor) when 'PullRequestEvent' # NOTE: we can NOT pass in the pull_request here because it doesn't have the right remote_id for issue diff --git a/spec/models/github/bounty_intent_spec.rb b/spec/models/github/bounty_intent_spec.rb new file mode 100644 index 000000000..9ccc96e1e --- /dev/null +++ b/spec/models/github/bounty_intent_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe Github::BountyIntent do + describe '.extract' do + it 'extracts GitHub comment bounty amounts' do + amounts = described_class.extract('Please add $+20 to this issue and +$5 more.').map(&:amount) + + expect(amounts).to eq([BigDecimal('20'), BigDecimal('5')]) + end + + it 'ignores comments without bounty amounts' do + expect(described_class.extract('Looks good to me')).to eq([]) + end + end + + describe '.queue_from_comment' do + let(:person) { create(:person) } + let(:actor) { create(:linked_account_github, person: person) } + let(:issue) { create(:issue) } + + it 'queues a bounty in the commenter shopping cart' do + described_class.queue_from_comment({ 'body' => '$+10' }, issue: issue, actor: actor) + + expect(person.shopping_cart.items).to include( + hash_including('item_type' => 'Bounty', 'amount' => '10.0', 'currency' => 'USD', 'issue_id' => issue.id) + ) + end + + it 'does not queue duplicate pending bounty intents' do + 2.times { described_class.queue_from_comment({ 'body' => '$+10' }, issue: issue, actor: actor) } + + expect(person.shopping_cart.items.count).to eq(1) + end + end +end