portage-cli 0.6.4 → 0.7.3

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.
@@ -0,0 +1,249 @@
1
+ require "time"
2
+ require "uri"
3
+ require "portage/ucp"
4
+ require "portage/ucp/client"
5
+ require "portage/ucp/journal"
6
+ require_relative "user_agent"
7
+ require_relative "homepage_fetch"
8
+ require_relative "handoff_spend_mode"
9
+ require_relative "reconcile_notifier"
10
+ require_relative "permissive_authenticator"
11
+ require_relative "handoff_reconciler/wire_adapters"
12
+
13
+ module Portage
14
+ module Cli
15
+ # docs/plans/handoff-reconcile.md Phase 1 — resolves a single pending
16
+ # `settled_by: "shopper"` TransactionLog record: did the shopper finish
17
+ # the checkout `portage buy` handed them, in the browser, on their own?
18
+ #
19
+ # Never infers success from a vanished checkout, a 404, or a plain
20
+ # expiry with no answer — those settle `failed` with `resolution:
21
+ # "unknown"`/`"expired"`, never `complete`. Only a `completed` status the
22
+ # store itself reports settles `complete`. Idempotent: a record that's
23
+ # already terminal (`complete`/`failed`) is a no-op, so `portage buy
24
+ # --wait` and a cron `portage orders reconcile` racing each other never
25
+ # double-settle.
26
+ class HandoffReconciler
27
+ # Raised when the store can't be reached again at all (no native UCP
28
+ # manifest, no matching/configured platform adapter) — the record
29
+ # stays pending; a later run (or a shorter-lived credential/env
30
+ # problem being fixed) may still resolve it.
31
+ class ReconnectError < StandardError; end
32
+
33
+ # `checkout_status` is the store's own raw status while a record stays
34
+ # pending (`incomplete`, `ready_for_complete`, …) — nil once settled,
35
+ # or when the checkout was never found at all. Phase 3's
36
+ # `HandoffWaiter` reads it to know when to emit a `handoff_status`
37
+ # NDJSON event under `portage buy --wait --json`.
38
+ Result = Struct.new(:idempotency_key, :settled, :status, :resolution, :order_id, :amount, :currency,
39
+ :checkout_status, :note, keyword_init: true) do
40
+ def to_h
41
+ { idempotency_key: idempotency_key, settled: settled, status: status, resolution: resolution,
42
+ order_id: order_id, amount: amount, currency: currency, checkout_status: checkout_status,
43
+ note: note }.compact
44
+ end
45
+ end
46
+
47
+ # @param spend_mode [String] one of HandoffSpendMode::MODES — Phase 2.
48
+ def initialize(transaction_log: Portage::Ucp::Support::TransactionLog.new,
49
+ order_ledger: Portage::Ucp::Support::OrderLedger.new,
50
+ journal: Portage::Ucp::Journal::PurchaseJournal.new,
51
+ notifier: ReconcileNotifier.new, spend_mode: HandoffSpendMode.resolve, clock: -> { Time.now })
52
+ @transaction_log = transaction_log
53
+ @order_ledger = order_ledger
54
+ @journal = journal
55
+ @notifier = notifier
56
+ @spend_mode = spend_mode
57
+ @clock = clock
58
+ end
59
+
60
+ # @param record [Hash] a TransactionLog record, as returned by
61
+ # `#find`/`#all` — expected to have `settled_by: "shopper"`, but
62
+ # this checks rather than assumes (see .each_pending_shopper_record).
63
+ # @return [Result]
64
+ def call(record)
65
+ if terminal?(record)
66
+ return Result.new(idempotency_key: record["idempotency_key"], settled: false, status: record["status"],
67
+ note: "already settled")
68
+ end
69
+ unless record["settled_by"] == "shopper"
70
+ return Result.new(idempotency_key: record["idempotency_key"], settled: false,
71
+ note: "not a shopper handoff")
72
+ end
73
+
74
+ reconcile(record)
75
+ end
76
+
77
+ # Every `pending`, `settled_by: "shopper"` record in the log — what
78
+ # `portage orders reconcile` (no `--checkout`) iterates.
79
+ def self.each_pending_shopper_record(transaction_log)
80
+ return enum_for(:each_pending_shopper_record, transaction_log) unless block_given?
81
+
82
+ transaction_log.each_record do |record|
83
+ yield record if record["settled_by"] == "shopper" && record["status"] == "pending"
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def terminal?(record) = Portage::Ucp::Support::TransactionLog::TERMINAL_STATUSES.include?(record["status"])
90
+
91
+ def reconcile(record)
92
+ session = reconnect(record["store_url"])
93
+ checkout = session.get_checkout(checkout_id: record["checkout_id"])
94
+ settle_from_checkout(record, checkout)
95
+ rescue StandardError => e
96
+ # A reconnect failure (no automated path back into the store) and a
97
+ # transport error mid-call are both treated exactly like "not
98
+ # found" — see the class comment and the plan's outcome table.
99
+ # Distinguishing a 404 from a timeout from "can't reconnect at all"
100
+ # buys nothing here: either way, this run learned nothing new about
101
+ # whether the shopper paid, and the record's own `expires_at` is
102
+ # still the only thing allowed to turn that into a settle.
103
+ settle_not_found(record, note: e.message)
104
+ end
105
+
106
+ def settle_from_checkout(record, checkout)
107
+ case checkout && checkout["status"]
108
+ when "completed" then settle_complete(record, checkout)
109
+ when "canceled" then settle_failed(record, resolution: nil)
110
+ when nil then settle_not_found(record)
111
+ else
112
+ if past_expiry?(record)
113
+ settle_failed(record, resolution: "expired")
114
+ else
115
+ still_pending(record, checkout_status: checkout["status"])
116
+ end
117
+ end
118
+ end
119
+
120
+ def settle_not_found(record, note: nil)
121
+ return still_pending(record, note: note) unless past_expiry?(record)
122
+
123
+ settle_failed(record, resolution: "unknown", note: note)
124
+ end
125
+
126
+ def past_expiry?(record)
127
+ expires_at = record["expires_at"]
128
+ return false unless expires_at
129
+
130
+ Time.parse(expires_at) < @clock.call
131
+ rescue ArgumentError
132
+ false
133
+ end
134
+
135
+ def still_pending(record, note: nil, checkout_status: nil)
136
+ Result.new(idempotency_key: record["idempotency_key"], settled: false, status: "pending",
137
+ checkout_status: checkout_status, note: note)
138
+ end
139
+
140
+ def settle_failed(record, resolution:, note: nil)
141
+ updated = @transaction_log.complete(idempotency_key: record["idempotency_key"], status: "failed",
142
+ resolution: resolution)
143
+ notify(record, updated, result: "failed")
144
+ Result.new(idempotency_key: record["idempotency_key"], settled: true, status: "failed",
145
+ resolution: resolution, note: note)
146
+ end
147
+
148
+ # The store's own reported amount, at settle time — not the
149
+ # handoff-time snapshot on `record` (the shopper may have changed
150
+ # quantity/shipping in the browser; see the plan's "Non-negotiable
151
+ # constraints").
152
+ def settle_complete(record, checkout)
153
+ amount = Portage::Ucp::Support::Totals.amount(checkout["totals"])
154
+ currency = checkout["currency"]
155
+ counts = @spend_mode != "warn"
156
+
157
+ updated = @transaction_log.complete(idempotency_key: record["idempotency_key"], status: "complete",
158
+ amount: amount, currency: currency, counts_toward_caps: counts)
159
+
160
+ order_id = record_order(record, checkout)
161
+ record_journal(record, checkout)
162
+ notify(record, updated, result: "complete", order_id: order_id)
163
+
164
+ Result.new(idempotency_key: record["idempotency_key"], settled: true, status: "complete", order_id: order_id,
165
+ amount: amount, currency: currency)
166
+ end
167
+
168
+ # Never blocks the settle above on this — the transaction record is
169
+ # already `complete` by the time this runs. A failure here just means
170
+ # the order snapshot/journal entry didn't get written this round; the
171
+ # money is still correctly recorded either way.
172
+ def record_order(record, checkout)
173
+ order = checkout["order"]
174
+ return nil unless order && order["id"]
175
+
176
+ session = reconnect(record["store_url"])
177
+ order_wire = session.get_order(order_id: order["id"])
178
+ return order["id"] unless order_wire
179
+
180
+ @order_ledger.record(idempotency_key: record["idempotency_key"], order: WireOrder.new(order_wire))
181
+ order["id"]
182
+ rescue StandardError
183
+ order["id"]
184
+ end
185
+
186
+ def record_journal(record, checkout)
187
+ return unless @journal && checkout["order"]
188
+
189
+ @journal.record_checkout(shop: record["shop"], source: "handoff_reconcile",
190
+ checkout: WireCheckout.new(checkout), idempotency_key: record["idempotency_key"])
191
+ rescue StandardError
192
+ nil
193
+ end
194
+
195
+ # Best-effort, same posture as Buy#hand_off's own notifier call — a
196
+ # failed webhook POST never raises out of #call/#reconcile.
197
+ def notify(record, updated, result:, order_id: nil)
198
+ payload = { event: "checkout_reconciled", result: result, reason: record["handoff_reason"],
199
+ checkout_id: record["checkout_id"], shop: record["shop"], store_url: record["store_url"],
200
+ amount: updated["amount"], currency: updated["currency"], order_id: order_id,
201
+ resolution: updated["resolution"] }
202
+ if result == "complete" && record["handoff_reason"] == "policy_blocked" &&
203
+ @spend_mode != "warn"
204
+ payload[:cap_overridden_by_shopper] =
205
+ true
206
+ end
207
+ @notifier.call(payload.compact)
208
+ rescue StandardError
209
+ nil
210
+ end
211
+
212
+ # --- reconnecting to the store (see the plan's "Reconciling needs the
213
+ # store again") ---
214
+
215
+ def reconnect(store_url)
216
+ raise ReconnectError, "no store_url on this record" if store_url.to_s.empty?
217
+
218
+ uri = URI.parse(store_url)
219
+ native_session(uri) || adapter_session(uri) ||
220
+ raise(ReconnectError, "no automated path back into #{store_url}")
221
+ end
222
+
223
+ def native_session(uri)
224
+ Portage::Ucp::Client.discover(uri.to_s, headers: UserAgent.headers)
225
+ rescue Portage::Ucp::Client::DiscoveryError
226
+ nil
227
+ end
228
+
229
+ # Mirrors Buy's own own-store loopback fallback (#adapter_flow) — a
230
+ # later process re-discovers the platform from the homepage the same
231
+ # way, and must not assume the original process's session or
232
+ # credentials still exist, only that this platform's env vars are
233
+ # still set (see the plan's non-negotiable constraint).
234
+ def adapter_session(uri)
235
+ body, headers = HomepageFetch.call(uri)
236
+ platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
237
+ return nil unless platform
238
+
239
+ env = Portage::Ucp::Resolver.env_for(platform)
240
+ return nil unless Portage::Ucp::Resolver.missing_env(platform, env).empty?
241
+
242
+ adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
243
+ Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
244
+ rescue StandardError
245
+ nil
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,28 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/handoff-reconcile.md Phase 2 — how a shopper-paid,
7
+ # reconciled handoff counts toward the buyer's own spend cap/velocity
8
+ # limit (Portage::Ucp::PolicyGuard). Same Setting precedence as
9
+ # CheckoutHandoff/Notifier: flag > PORTAGE_HANDOFF_SPEND_MODE > config.json's
10
+ # "handoff_spend_mode". Unset, or set to anything not in MODES, is
11
+ # `block`, the safer default (a shopper's own completed purchase is real
12
+ # spend; hiding it from the cap would just let the next *agent* purchase
13
+ # blow through it unnoticed).
14
+ module HandoffSpendMode
15
+ ENV_VAR = "PORTAGE_HANDOFF_SPEND_MODE".freeze
16
+ CONFIG_KEY = "handoff_spend_mode".freeze
17
+ MODES = %w[block warn precheck].freeze
18
+ DEFAULT = "block".freeze
19
+
20
+ module_function
21
+
22
+ def resolve(override: nil, config: Config.load)
23
+ mode = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
24
+ MODES.include?(mode) ? mode : DEFAULT
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/handoff-reconcile.md Phase 3, decision 5 — the ceiling on
7
+ # `portage buy --wait`'s poll loop (see HandoffWaiter). Same Setting
8
+ # precedence as HandoffSpendMode/CheckoutHandoff: flag (--wait-timeout)
9
+ # > PORTAGE_HANDOFF_WAIT_TIMEOUT > config.json's "handoff_wait_timeout".
10
+ # Unset is 30 minutes; `off` (or `0`) removes the ceiling entirely, so
11
+ # the wait runs until the checkout's own `expires_at` instead.
12
+ module HandoffWaitTimeout
13
+ ENV_VAR = "PORTAGE_HANDOFF_WAIT_TIMEOUT".freeze
14
+ CONFIG_KEY = "handoff_wait_timeout".freeze
15
+ DEFAULT = 1800 # 30 minutes.
16
+ OFF_VALUES = %w[off 0].freeze
17
+ DURATION_UNITS = { "s" => 1, "m" => 60, "h" => 3600 }.freeze
18
+
19
+ module_function
20
+
21
+ # @return [Integer, nil] seconds to wait, or nil for "no ceiling" —
22
+ # the caller then waits only on the checkout's own `expires_at`.
23
+ def resolve(override: nil, config: Config.load)
24
+ raw = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
25
+ raw.nil? ? DEFAULT : parse(raw)
26
+ end
27
+
28
+ def parse(raw)
29
+ text = raw.to_s.strip.downcase
30
+ return nil if OFF_VALUES.include?(text)
31
+
32
+ seconds = parse_duration(text)
33
+ seconds&.positive? ? seconds : DEFAULT
34
+ end
35
+
36
+ # Accepts a bare integer (seconds) or a suffixed duration (`30m`,
37
+ # `1h`, `45s`) — `--wait-timeout` is a human-typed flag, not a raw
38
+ # second count like `--rolling-window-seconds`.
39
+ def parse_duration(text)
40
+ return text.to_i if text.match?(/\A\d+\z/)
41
+
42
+ match = text.match(/\A(\d+)([smh])\z/)
43
+ return nil unless match
44
+
45
+ match[1].to_i * DURATION_UNITS.fetch(match[2])
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,103 @@
1
+ require "time"
2
+ require_relative "handoff_reconciler"
3
+ require_relative "handoff_wait_timeout"
4
+
5
+ module Portage
6
+ module Cli
7
+ # docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`'s poll
8
+ # loop. Polls a single pending shopper record through HandoffReconciler
9
+ # with backoff (2s -> 30s, plus jitter) until it settles or its deadline
10
+ # passes. Every decision about what a poll *means* stays
11
+ # HandoffReconciler's; this only decides when to poll again and when to
12
+ # stop.
13
+ #
14
+ # Ctrl-C: Ruby's default SIGINT handling raises Interrupt on the main
15
+ # thread, most likely while this is asleep between polls. Caught here so
16
+ # it never crashes the process with a backtrace — the record is already
17
+ # pending in the transaction log either way (see the plan's
18
+ # non-negotiable "the handoff itself never waits on reconcile"), so
19
+ # there's nothing to undo. It's left exactly as it is for a later
20
+ # `portage orders reconcile`; this never settles on interrupt.
21
+ class HandoffWaiter
22
+ INITIAL_BACKOFF = 2
23
+ MAX_BACKOFF = 30
24
+
25
+ def initialize(reconciler:, transaction_log:, wait_timeout_override: nil, clock: -> { Time.now },
26
+ sleeper: ->(seconds) { Kernel.sleep(seconds) })
27
+ @reconciler = reconciler
28
+ @transaction_log = transaction_log
29
+ @wait_timeout_override = wait_timeout_override
30
+ @clock = clock
31
+ @sleeper = sleeper
32
+ end
33
+
34
+ # @param record [Hash] the pending TransactionLog record to poll —
35
+ # typically fresh from the same log this was built with.
36
+ # @yield [Symbol, HandoffReconciler::Result] `:status` on every
37
+ # store-reported checkout status change (never on the first poll's
38
+ # initial status), `:settled` exactly once, when the loop ends
39
+ # because the record actually settled (never on a timeout or
40
+ # interrupt).
41
+ # @return [HandoffReconciler::Result] the last poll's result — settled
42
+ # if it settled, otherwise still pending (deadline reached or
43
+ # interrupted).
44
+ def call(record, &on_event)
45
+ deadline = compute_deadline(record)
46
+ backoff = INITIAL_BACKOFF
47
+ last_checkout_status = nil
48
+ loop do
49
+ current = @transaction_log.find(record["idempotency_key"]) || record
50
+ result = @reconciler.call(current)
51
+ last_checkout_status = emit_status(result, last_checkout_status, on_event)
52
+ return emit_settled(result, on_event) if result.settled
53
+ return result if @clock.call >= deadline
54
+
55
+ @sleeper.call(backoff_with_jitter(backoff))
56
+ backoff = [backoff * 2, MAX_BACKOFF].min
57
+ end
58
+ rescue Interrupt
59
+ HandoffReconciler::Result.new(idempotency_key: record["idempotency_key"], settled: false, status: "pending",
60
+ note: "wait interrupted")
61
+ end
62
+
63
+ private
64
+
65
+ def emit_status(result, last_checkout_status, on_event)
66
+ return last_checkout_status unless result.checkout_status && result.checkout_status != last_checkout_status
67
+
68
+ on_event&.call(:status, result)
69
+ result.checkout_status
70
+ end
71
+
72
+ def emit_settled(result, on_event)
73
+ on_event&.call(:settled, result)
74
+ result
75
+ end
76
+
77
+ # The earlier of `handoff_wait_timeout` (default 30m, `off` removes
78
+ # it) and the checkout's own `expires_at`. If somehow neither is set
79
+ # (no expires_at on the record, and the timeout resolved to nil), the
80
+ # default timeout still applies as a floor — this loop must not spin
81
+ # forever with no deadline at all.
82
+ def compute_deadline(record)
83
+ started = @clock.call
84
+ timeout = HandoffWaitTimeout.resolve(override: @wait_timeout_override)
85
+ candidates = [(started + timeout if timeout), expires_at(record)].compact
86
+ candidates.min || (started + HandoffWaitTimeout::DEFAULT)
87
+ end
88
+
89
+ def expires_at(record)
90
+ value = record["expires_at"]
91
+ return nil unless value
92
+
93
+ Time.parse(value)
94
+ rescue ArgumentError, TypeError
95
+ nil
96
+ end
97
+
98
+ def backoff_with_jitter(backoff)
99
+ backoff + Random.rand(backoff * 0.25)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -9,6 +9,13 @@ module Portage
9
9
  # history list` can answer "what did I already look for" and "what did I
10
10
  # already buy" without re-running anything, and `portage history clear`
11
11
  # can wipe either or both.
12
+ #
13
+ # A purchase entry is one checkout `portage buy` created, whatever came
14
+ # of it: `outcome` is the report's own (`purchased`, `dry_run`,
15
+ # `policy_blocked`, ...), so "what did I already buy" is the entries
16
+ # whose outcome is `purchased`, and every other entry still carries the
17
+ # `checkout_url` to finish it by hand. Entries written before `outcome`
18
+ # existed have only `checkout_status` and `message`.
12
19
  class History
13
20
  PATH = File.join(Dir.home, ".portage", "history.json").freeze
14
21
  MAX_ENTRIES = 200
@@ -18,14 +25,24 @@ module Portage
18
25
  @now = now.to_i
19
26
  end
20
27
 
21
- def record_purchase(url:, query:, checkout:, message:, checkout_status: nil, products: [])
22
- append("purchases", { "url" => url, "query" => query, "checkout" => checkout,
23
- "checkout_status" => checkout_status, "message" => message,
24
- "products" => products, "at" => @now })
28
+ # @param items [Array<Hash>] what the checkout holds (`id`, `title`,
29
+ # `quantity`), not the search results it was picked from.
30
+ # @param total [Integer, nil] minor units of `currency`.
31
+ # rubocop:disable Metrics/ParameterLists -- all keywords; one entry's fields
32
+ def record_purchase(url:, query:, outcome:, message:, source: nil, checkout_id: nil, checkout_status: nil,
33
+ checkout_url: nil, total: nil, currency: nil, items: [])
34
+ # rubocop:enable Metrics/ParameterLists
35
+ append("purchases", { "url" => url, "query" => query, "outcome" => outcome, "source" => source,
36
+ "checkout_id" => checkout_id, "checkout_status" => checkout_status,
37
+ "checkout_url" => checkout_url, "total" => total, "currency" => currency,
38
+ "items" => items, "message" => message, "at" => @now })
25
39
  end
26
40
 
27
- def record_search(query:, offer_count:, message:)
28
- append("searches", { "query" => query, "offer_count" => offer_count, "message" => message, "at" => @now })
41
+ # @param url [String, nil] the store, for a `portage buy` that never
42
+ # reached a checkout there; nil for a cross-store `portage find`.
43
+ def record_search(query:, offer_count:, message:, url: nil)
44
+ append("searches", { "query" => query, "url" => url, "offer_count" => offer_count, "message" => message,
45
+ "at" => @now }.compact)
29
46
  end
30
47
 
31
48
  def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
@@ -0,0 +1,41 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "portage/ucp/support/connection"
4
+ require_relative "user_agent"
5
+
6
+ module Portage
7
+ module Cli
8
+ # Shared "fetch this store's homepage, for platform sniffing" logic —
9
+ # originally private to Buy (#fetch_homepage), extracted so
10
+ # HandoffReconciler's own adapter-fallback reconnect (see
11
+ # docs/plans/handoff-reconcile.md, "Reconciling needs the store again")
12
+ # doesn't duplicate it.
13
+ module HomepageFetch
14
+ REDIRECT_LIMIT = 5
15
+
16
+ # @return [Array(String, Hash), Array(nil, Hash)] the body and response
17
+ # headers, or [nil, {}] on any failure/redirect exhaustion — same
18
+ # "no automated path, not a crash" posture as the rest of the buy
19
+ # flow.
20
+ def self.call(uri, limit: REDIRECT_LIMIT)
21
+ return [nil, {}] if limit.zero?
22
+
23
+ response = Portage::Ucp::Support::Connection.start(uri, route: :store, open_timeout: 5,
24
+ read_timeout: 5) do |http|
25
+ http.get(uri.request_uri, UserAgent.headers)
26
+ end
27
+
28
+ case response
29
+ when Net::HTTPRedirection
30
+ call(URI.join(uri, response["location"]), limit: limit - 1)
31
+ when Net::HTTPSuccess
32
+ [response.body, response.to_hash]
33
+ else
34
+ [nil, {}]
35
+ end
36
+ rescue StandardError
37
+ [nil, {}]
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,37 @@
1
+ module Portage
2
+ module Cli
3
+ # docs/plans/handoff-reconcile.md Phase 3's "macos" notify channel — a
4
+ # native notification banner for a settled handoff. Opt-in (see
5
+ # ReconcileNotify), and a no-op off Darwin.
6
+ #
7
+ # `system("osascript", "-e", script)` in array form, never an
8
+ # interpolated shell string — same posture as CheckoutHandoff's
9
+ # `open`/`xdg-open`. The injection surface that remains is the
10
+ # AppleScript source itself: `title`/`message` come from a merchant
11
+ # name and an amount, both untrusted, so they're never spliced into the
12
+ # script raw. #applescript_string escapes backslashes and double quotes
13
+ # before interpolating, so a merchant name like `foo" & do shell script
14
+ # "rm -rf ~" & "` can't break out of its quoted string and run as
15
+ # AppleScript.
16
+ class MacosNotifier
17
+ def call(title:, message:)
18
+ return false unless RbConfig::CONFIG["host_os"] =~ /darwin/i
19
+
20
+ script = "display notification #{applescript_string(message)} with title #{applescript_string(title)}"
21
+ !!system("osascript", "-e", script)
22
+ rescue StandardError
23
+ false
24
+ end
25
+
26
+ private
27
+
28
+ def applescript_string(text)
29
+ # Block form, not a replacement string: gsub treats "\\" in a
30
+ # replacement *string* as a backreference escape, which would
31
+ # collapse a literal backslash right back down to one.
32
+ escaped = text.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\"' }
33
+ "\"#{escaped}\""
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,5 +1,11 @@
1
1
  require "net/http"
2
+ require "json"
3
+ require "uri"
4
+ require "portage/ucp"
5
+ require "portage/ucp/support/connection"
2
6
  require_relative "config"
7
+ require_relative "setting"
8
+ require_relative "user_agent"
3
9
 
4
10
  module Portage
5
11
  module Cli
@@ -12,15 +18,17 @@ module Portage
12
18
  # speaks HTTP.
13
19
  #
14
20
  # Default off. Precedence for the webhook URL (open decision #1,
15
- # resolved, same shape as CheckoutHandoff's auto-open toggle): a
21
+ # resolved, same Setting as CheckoutHandoff's auto-open toggle): a
16
22
  # per-invocation `webhook_url:` override (portage buy --notify-webhook)
17
23
  # beats PORTAGE_NOTIFY_WEBHOOK_URL, which beats ~/.portage/config.json's
18
24
  # "notify_webhook_url" (Config).
19
25
  class Notifier
20
- include Portage::Ucp::Support::HttpClient
21
-
22
26
  ENV_VAR = "PORTAGE_NOTIFY_WEBHOOK_URL".freeze
23
27
  CONFIG_KEY = "notify_webhook_url".freeze
28
+ TIMEOUT = 5
29
+ # Enough of an error body to name the problem, without pasting a whole
30
+ # HTML error page into the report.
31
+ BODY_EXCERPT = 200
24
32
 
25
33
  def initialize(webhook_url: nil, config: Config.load)
26
34
  @override = webhook_url
@@ -28,12 +36,7 @@ module Portage
28
36
  end
29
37
 
30
38
  def webhook_url
31
- return @override unless @override.nil?
32
-
33
- env = ENV.fetch(ENV_VAR, nil)
34
- return env unless env.nil? || env.empty?
35
-
36
- @config.get(CONFIG_KEY)
39
+ Setting.resolve(override: @override, env: ENV_VAR, config: @config, config_key: CONFIG_KEY)
37
40
  end
38
41
 
39
42
  def enabled? = !webhook_url.to_s.empty?
@@ -42,34 +45,30 @@ module Portage
42
45
  # raises out of `Buy#call` — the checkout itself is a real, correct
43
46
  # outcome independent of whether this delivery succeeded.
44
47
  #
48
+ # Success is any 2xx, whatever the body: Slack's incoming webhooks
49
+ # answer `ok` as plain text, which a JSON parse would have misreported
50
+ # as a failed delivery. Short timeouts, since a hand-off is waiting on
51
+ # this and Net::HTTP's defaults would stall it for up to two minutes.
52
+ #
45
53
  # @return [String, nil] the delivery failure message, or nil when
46
54
  # disabled or on a successful POST.
47
55
  def call(payload)
48
56
  return nil unless enabled?
49
57
 
50
- json_request(Net::HTTP::Post, webhook_url, body: payload)
51
- nil
58
+ response = post(URI(webhook_url), JSON.generate(payload))
59
+ return nil if response.is_a?(Net::HTTPSuccess)
60
+
61
+ "webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
52
62
  rescue StandardError => e
53
- e.message
63
+ "webhook POST failed: #{e.message}"
54
64
  end
55
65
 
56
66
  private
57
67
 
58
- def api_error_class
59
- NotifyApiError
60
- end
61
-
62
- # Raised (internally, always rescued by #call) when the webhook POST
63
- # itself fails (non-2xx) — kept distinct from a plain network error
64
- # only in that it carries the response body/status, same split
65
- # Confirmer::WebhookApiError draws against a raw StandardError.
66
- class NotifyApiError < Portage::Ucp::Error
67
- include Portage::Ucp::Support::ApiError
68
-
69
- private
70
-
71
- def api_label
72
- "Notifier"
68
+ def post(uri, body)
69
+ Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: TIMEOUT,
70
+ read_timeout: TIMEOUT) do |http|
71
+ http.post(uri.request_uri, body, UserAgent.headers.merge("Content-Type" => "application/json"))
73
72
  end
74
73
  end
75
74
  end