portage-cli 0.6.4 → 0.7.0
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 +4 -4
- data/CHANGELOG.md +109 -0
- data/README.md +117 -6
- data/lib/portage/cli/buy.rb +350 -71
- data/lib/portage/cli/checkout_handoff.rb +6 -18
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +17 -0
- data/lib/portage/cli/find.rb +8 -8
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/notifier.rb +23 -27
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +133 -19
- metadata +16 -5
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
require_relative "decisions"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
# The confidence gate (docs/plans/system-one-decision-layer.md
|
|
8
|
+
# § Responsibilities 3) as `portage buy` uses it: one yes/no question put
|
|
9
|
+
# to a Decision::ModelBackends backend right before an unattended
|
|
10
|
+
# (`--yes`) completion — "is this checkout what the shopper asked for,
|
|
11
|
+
# and safe to complete without a person looking at it?"
|
|
12
|
+
#
|
|
13
|
+
# Default off. Nothing asks a model anything unless a backend is named,
|
|
14
|
+
# via `--decision-backend NAME` or PORTAGE_DECISION_BACKEND (`jev` or
|
|
15
|
+
# `laya`, Decision::ModelBackends::REGISTRY's keys). The threshold comes
|
|
16
|
+
# from `--min-confidence N` or PORTAGE_MIN_CONFIDENCE, else
|
|
17
|
+
# DEFAULT_THRESHOLD.
|
|
18
|
+
#
|
|
19
|
+
# This answers the plan's open question about how the gate relates to
|
|
20
|
+
# `Confirmer`: it runs in front of the completion, after `--yes` and the
|
|
21
|
+
# local PolicyCheck, and never in place of either. A low score holds the
|
|
22
|
+
# purchase and hands the checkout to the shopper. It never
|
|
23
|
+
# auto-approves anything `--yes` wouldn't already have allowed.
|
|
24
|
+
#
|
|
25
|
+
# Fails closed. An unknown backend name, a backend that isn't configured
|
|
26
|
+
# (no JEV_API_KEY, no Laya bridge), or a backend call that fails all hold
|
|
27
|
+
# the purchase, the same as a low score does. So does naming a backend
|
|
28
|
+
# without portage-ucp-decision installed: the backends live in that
|
|
29
|
+
# optional gem. Once a caller has asked for a confidence check, the
|
|
30
|
+
# check never passing is not "no opinion".
|
|
31
|
+
class ConfidenceCheck
|
|
32
|
+
BACKEND_ENV = "PORTAGE_DECISION_BACKEND".freeze
|
|
33
|
+
THRESHOLD_ENV = "PORTAGE_MIN_CONFIDENCE".freeze
|
|
34
|
+
DEFAULT_THRESHOLD = 0.8
|
|
35
|
+
QUESTION = "safe_to_complete".freeze
|
|
36
|
+
# Phrased so "yes" means "proceed": a noul answer's value is the
|
|
37
|
+
# probability of yes, and that is what ConfidenceGate thresholds.
|
|
38
|
+
INSTRUCTIONS = "The state is a checkout an agent built on a shopper's behalf: the shopper's search " \
|
|
39
|
+
"query, the merchant, the requested quantity, the checkout's line items and totals, and " \
|
|
40
|
+
"any warnings about where the checkout differs from the request. Answer yes only if the " \
|
|
41
|
+
"checkout clearly matches what the shopper asked for and is safe to complete without a " \
|
|
42
|
+
"person reviewing it first.".freeze
|
|
43
|
+
|
|
44
|
+
# @param backend [String, nil] a ModelBackends::REGISTRY key; nil
|
|
45
|
+
# defers to PORTAGE_DECISION_BACKEND.
|
|
46
|
+
# @param threshold [Float, nil] 0.0..1.0; nil defers to
|
|
47
|
+
# PORTAGE_MIN_CONFIDENCE, then DEFAULT_THRESHOLD.
|
|
48
|
+
# @param resolver [#call, nil] builds a backend from its name —
|
|
49
|
+
# injectable so specs never reach a real model. nil means
|
|
50
|
+
# ModelBackends.resolve, looked up only once a backend is needed.
|
|
51
|
+
# @raise [ArgumentError] for a threshold outside 0.0..1.0. An explicit
|
|
52
|
+
# `threshold:` is always checked, so a mistyped `--min-confidence`
|
|
53
|
+
# fails loudly. PORTAGE_MIN_CONFIDENCE is read, and so checked,
|
|
54
|
+
# only once a backend is enabled: a stale value in a shell that
|
|
55
|
+
# never uses the gate mustn't stop every `portage buy`.
|
|
56
|
+
def initialize(backend: nil, threshold: nil, resolver: nil)
|
|
57
|
+
@backend_name = Setting.resolve(override: backend, env: BACKEND_ENV)&.to_s&.strip
|
|
58
|
+
raw_threshold = enabled? ? Setting.resolve(override: threshold, env: THRESHOLD_ENV) : threshold
|
|
59
|
+
@threshold = parse_threshold(raw_threshold || DEFAULT_THRESHOLD)
|
|
60
|
+
@resolver = resolver
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
attr_reader :threshold
|
|
64
|
+
|
|
65
|
+
def enabled? = !@backend_name.nil?
|
|
66
|
+
|
|
67
|
+
# @param state [Hash] JSON-serializable. Never pass it a payment token.
|
|
68
|
+
# @return [Hash, nil] nil when disabled. Otherwise `proceed:`,
|
|
69
|
+
# `reason:`, `confidence:`, `threshold:`, `backend:` and `error:`.
|
|
70
|
+
# `reason` is nil when it proceeds, else why it held:
|
|
71
|
+
# `below_threshold`, `backend_error` (unknown, unconfigured or
|
|
72
|
+
# failed backend) or `not_installed` (no portage-ucp-decision).
|
|
73
|
+
# `error` is the detail behind the last two, nil otherwise.
|
|
74
|
+
def call(state)
|
|
75
|
+
return nil unless enabled?
|
|
76
|
+
return held("not_installed", not_installed_message) unless Decisions.available?
|
|
77
|
+
|
|
78
|
+
verdict = Portage::Ucp::Decision::ConfidenceGate.via_backend(
|
|
79
|
+
backend: resolve_backend, state: JSON.generate(state), question: QUESTION,
|
|
80
|
+
instructions: INSTRUCTIONS, threshold: @threshold
|
|
81
|
+
)
|
|
82
|
+
{ proceed: verdict.proceed, reason: verdict.proceed ? nil : "below_threshold",
|
|
83
|
+
confidence: verdict.confidence, threshold: @threshold, backend: @backend_name, error: nil }
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
# Any failure, not only Decision::Error: this runs after a real
|
|
86
|
+
# checkout exists, so an escaped exception would drop the report,
|
|
87
|
+
# the hand-off and the history entry along with the purchase.
|
|
88
|
+
held("backend_error", e.message)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# What `portage doctor` reports: why the named backend would hold every
|
|
92
|
+
# `--yes` purchase, before one is attempted.
|
|
93
|
+
# @return [String, nil] nil when disabled or ready to answer.
|
|
94
|
+
def configuration_problem
|
|
95
|
+
return nil unless enabled?
|
|
96
|
+
return not_installed_message unless Decisions.available?
|
|
97
|
+
|
|
98
|
+
resolve_backend.configuration_problem
|
|
99
|
+
rescue Portage::Ucp::Decision::Error => e
|
|
100
|
+
e.message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def resolve_backend
|
|
106
|
+
(@resolver || Portage::Ucp::Decision::ModelBackends.method(:resolve)).call(@backend_name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def held(reason, error)
|
|
110
|
+
{ proceed: false, reason: reason, confidence: nil, threshold: @threshold, backend: @backend_name,
|
|
111
|
+
error: error }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def not_installed_message
|
|
115
|
+
"portage-ucp-decision is not installed — `gem install portage-ucp-decision` to use the " \
|
|
116
|
+
"#{@backend_name} confidence check."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_threshold(raw)
|
|
120
|
+
value = Float(raw, exception: false)
|
|
121
|
+
return value if value&.between?(0.0, 1.0)
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "confidence threshold must be a number between 0.0 and 1.0, got #{raw.inspect}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# portage-cli's judgment calls (docs/plans/system-one-decision-layer.md).
|
|
6
|
+
# The rules live in portage-ucp core: Support::OfferRanking,
|
|
7
|
+
# Support::Escalation and PolicyGuard. portage-ucp-decision's
|
|
8
|
+
# OfferRanking, EscalationPolicy and PolicyCheck are typed wrappers
|
|
9
|
+
# around those same modules, so `buy` and `find` answer the same way
|
|
10
|
+
# whether or not that gem is installed.
|
|
11
|
+
#
|
|
12
|
+
# Every method returns a plain Hash whose `reason` is a String or nil,
|
|
13
|
+
# so a caller sees the same values in-process as in `--json`.
|
|
14
|
+
#
|
|
15
|
+
# Only the confidence gate needs the optional gem, because the model
|
|
16
|
+
# backends live there (see ConfidenceCheck and .available?).
|
|
17
|
+
module Decisions
|
|
18
|
+
# Loaded the way Resolver.build_adapter's callers treat an optional
|
|
19
|
+
# adapter gem: `require`, then rescue LoadError. It isn't in the
|
|
20
|
+
# gemspec, so `gem install portage-cli` stays light.
|
|
21
|
+
# @return [Boolean] whether portage-ucp-decision could be loaded.
|
|
22
|
+
# Memoized: `require` runs once per process.
|
|
23
|
+
def self.available?
|
|
24
|
+
return @available unless @available.nil?
|
|
25
|
+
|
|
26
|
+
@available = begin
|
|
27
|
+
require "portage/ucp/decision"
|
|
28
|
+
true
|
|
29
|
+
rescue LoadError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param offers [Array<Hash>] with `:checkout` (buyable) and `:amount`.
|
|
35
|
+
def self.rank(offers)
|
|
36
|
+
Portage::Ucp::Support::OfferRanking.rank(offers) { |offer| [offer[:checkout], offer[:amount]] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param warnings [Array<String>] mismatches that should escalate —
|
|
40
|
+
# pass none when mismatches are only to be reported.
|
|
41
|
+
# @return [Hash] `escalate:`, `reason:`.
|
|
42
|
+
def self.escalation(checkout_status:, warnings: [])
|
|
43
|
+
reason = Portage::Ucp::Support::Escalation.reason(checkout_status: checkout_status, warnings: warnings)
|
|
44
|
+
{ escalate: !reason.nil?, reason: reason&.to_s }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The buyer's spend policy, checked by PolicyGuard.
|
|
48
|
+
#
|
|
49
|
+
# PolicyGuard skips both spend caps when `amount` is nil, which suits
|
|
50
|
+
# its own caller (an adapter that can't price a checkout up front).
|
|
51
|
+
# Here it would let a checkout with no `total` line past a configured
|
|
52
|
+
# cap while reporting `allowed: true`, so a missing total is denied as
|
|
53
|
+
# `total_unknown` whenever a cap exists.
|
|
54
|
+
# @param transaction_log [Portage::Ucp::Support::TransactionLog] what
|
|
55
|
+
# the rolling cap and velocity limit count.
|
|
56
|
+
# @return [Hash] `allowed:`, `reason:`.
|
|
57
|
+
def self.policy(amount:, currency:, merchant:, token_ref:,
|
|
58
|
+
transaction_log: Portage::Ucp::Support::TransactionLog.new)
|
|
59
|
+
policy = Portage::Ucp::Policy.load
|
|
60
|
+
if amount.nil? && (policy.per_transaction_cap || policy.rolling_cap)
|
|
61
|
+
return { allowed: false, reason: "total_unknown" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Portage::Ucp::PolicyGuard.check!(amount: amount, currency: currency, merchant: merchant,
|
|
65
|
+
token_ref: token_ref, policy: policy, transaction_log: transaction_log)
|
|
66
|
+
{ allowed: true, reason: nil }
|
|
67
|
+
rescue Portage::Ucp::PolicyViolationError => e
|
|
68
|
+
{ allowed: false, reason: e.reason.to_s }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "portage/ucp"
|
|
2
|
+
require_relative "confidence_check"
|
|
2
3
|
|
|
3
4
|
module Portage
|
|
4
5
|
module Cli
|
|
@@ -24,6 +25,7 @@ module Portage
|
|
|
24
25
|
rate_limiter_finding,
|
|
25
26
|
signing_keys_finding,
|
|
26
27
|
payment_handlers_finding,
|
|
28
|
+
decision_backend_finding,
|
|
27
29
|
*capability_findings
|
|
28
30
|
].compact
|
|
29
31
|
end
|
|
@@ -54,6 +56,21 @@ module Portage
|
|
|
54
56
|
Finding.new(check: "signing_keys", message: "No signing_keys configured — the manifest ships unsigned.")
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
# The confidence gate's backend, checked only once one is selected
|
|
60
|
+
# (PORTAGE_DECISION_BACKEND): the gate is off by default, and a
|
|
61
|
+
# missing key for a backend nobody chose is noise. Once one is
|
|
62
|
+
# selected, anything short of ready holds every `--yes` purchase, so
|
|
63
|
+
# it's flagged here rather than first surfacing mid-checkout. Covers
|
|
64
|
+
# the gem not being installed, an unknown backend name, a missing
|
|
65
|
+
# JEV_API_KEY, a missing Laya bridge, and a bad PORTAGE_MIN_CONFIDENCE.
|
|
66
|
+
def decision_backend_finding
|
|
67
|
+
problem = ConfidenceCheck.new.configuration_problem
|
|
68
|
+
problem && Finding.new(check: "decision_backend",
|
|
69
|
+
message: "#{problem} — until then every `portage buy --yes` is held for the shopper.")
|
|
70
|
+
rescue ArgumentError => e
|
|
71
|
+
Finding.new(check: "decision_backend", message: e.message)
|
|
72
|
+
end
|
|
73
|
+
|
|
57
74
|
def payment_handlers_finding
|
|
58
75
|
return unless Array(config.payment_handlers).empty?
|
|
59
76
|
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "portage/ucp/client"
|
|
|
4
4
|
|
|
5
5
|
require_relative "search_backends"
|
|
6
6
|
require_relative "probe_cache"
|
|
7
|
+
require_relative "decisions"
|
|
7
8
|
|
|
8
9
|
module Portage
|
|
9
10
|
module Cli
|
|
@@ -166,14 +167,13 @@ module Portage
|
|
|
166
167
|
amount: amount, currency: currency, url: field(product, "url") }
|
|
167
168
|
end
|
|
168
169
|
|
|
169
|
-
# Buyable first, then cheapest, then unpriced
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
end
|
|
170
|
+
# Buyable first, then cheapest, then unpriced (see Decisions.rank —
|
|
171
|
+
# core's Support::OfferRanking, the rule portage-ucp-decision's
|
|
172
|
+
# OfferRanking wraps), so an agent loop ranking its own candidate list
|
|
173
|
+
# gets the same order this command prints. Sorting on price alone
|
|
174
|
+
# would float a browse-only store above one you can actually check
|
|
175
|
+
# out from, which is the wrong answer to "buy me this".
|
|
176
|
+
def rank(offers) = Decisions.rank(offers)
|
|
177
177
|
|
|
178
178
|
# --- Shapes ---
|
|
179
179
|
|
data/lib/portage/cli/history.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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)
|
data/lib/portage/cli/notifier.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
2
4
|
require_relative "config"
|
|
5
|
+
require_relative "setting"
|
|
3
6
|
|
|
4
7
|
module Portage
|
|
5
8
|
module Cli
|
|
@@ -12,15 +15,17 @@ module Portage
|
|
|
12
15
|
# speaks HTTP.
|
|
13
16
|
#
|
|
14
17
|
# Default off. Precedence for the webhook URL (open decision #1,
|
|
15
|
-
# resolved, same
|
|
18
|
+
# resolved, same Setting as CheckoutHandoff's auto-open toggle): a
|
|
16
19
|
# per-invocation `webhook_url:` override (portage buy --notify-webhook)
|
|
17
20
|
# beats PORTAGE_NOTIFY_WEBHOOK_URL, which beats ~/.portage/config.json's
|
|
18
21
|
# "notify_webhook_url" (Config).
|
|
19
22
|
class Notifier
|
|
20
|
-
include Portage::Ucp::Support::HttpClient
|
|
21
|
-
|
|
22
23
|
ENV_VAR = "PORTAGE_NOTIFY_WEBHOOK_URL".freeze
|
|
23
24
|
CONFIG_KEY = "notify_webhook_url".freeze
|
|
25
|
+
TIMEOUT = 5
|
|
26
|
+
# Enough of an error body to name the problem, without pasting a whole
|
|
27
|
+
# HTML error page into the report.
|
|
28
|
+
BODY_EXCERPT = 200
|
|
24
29
|
|
|
25
30
|
def initialize(webhook_url: nil, config: Config.load)
|
|
26
31
|
@override = webhook_url
|
|
@@ -28,12 +33,7 @@ module Portage
|
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
def webhook_url
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
env = ENV.fetch(ENV_VAR, nil)
|
|
34
|
-
return env unless env.nil? || env.empty?
|
|
35
|
-
|
|
36
|
-
@config.get(CONFIG_KEY)
|
|
36
|
+
Setting.resolve(override: @override, env: ENV_VAR, config: @config, config_key: CONFIG_KEY)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def enabled? = !webhook_url.to_s.empty?
|
|
@@ -42,34 +42,30 @@ module Portage
|
|
|
42
42
|
# raises out of `Buy#call` — the checkout itself is a real, correct
|
|
43
43
|
# outcome independent of whether this delivery succeeded.
|
|
44
44
|
#
|
|
45
|
+
# Success is any 2xx, whatever the body: Slack's incoming webhooks
|
|
46
|
+
# answer `ok` as plain text, which a JSON parse would have misreported
|
|
47
|
+
# as a failed delivery. Short timeouts, since a hand-off is waiting on
|
|
48
|
+
# this and Net::HTTP's defaults would stall it for up to two minutes.
|
|
49
|
+
#
|
|
45
50
|
# @return [String, nil] the delivery failure message, or nil when
|
|
46
51
|
# disabled or on a successful POST.
|
|
47
52
|
def call(payload)
|
|
48
53
|
return nil unless enabled?
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
nil
|
|
55
|
+
response = post(URI(webhook_url), JSON.generate(payload))
|
|
56
|
+
return nil if response.is_a?(Net::HTTPSuccess)
|
|
57
|
+
|
|
58
|
+
"webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
|
|
52
59
|
rescue StandardError => e
|
|
53
|
-
e.message
|
|
60
|
+
"webhook POST failed: #{e.message}"
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
private
|
|
57
64
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
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"
|
|
65
|
+
def post(uri, body)
|
|
66
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
67
|
+
open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
68
|
+
http.post(uri.request_uri, body, "Content-Type" => "application/json")
|
|
73
69
|
end
|
|
74
70
|
end
|
|
75
71
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# How portage-cli resolves a standing preference, in one place
|
|
4
|
+
# (docs/plans/checkout-handoff-delivery.md open decision #1): a
|
|
5
|
+
# per-invocation override (a flag) beats its PORTAGE_* env var, which
|
|
6
|
+
# beats its ~/.portage/config.json key (Config). CheckoutHandoff,
|
|
7
|
+
# Notifier, Buy and ConfidenceCheck all read their settings through it.
|
|
8
|
+
#
|
|
9
|
+
# nil or a blank string counts as unset at every level, so an exported
|
|
10
|
+
# but empty `PORTAGE_AUTO_OPEN_CHECKOUT=` falls through to config.json
|
|
11
|
+
# rather than silently meaning "off".
|
|
12
|
+
module Setting
|
|
13
|
+
TRUE_VALUES = %w[1 true yes].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param override [Object, nil] the flag's value, nil when not passed.
|
|
18
|
+
# @param env [String, nil] the env var's name.
|
|
19
|
+
# @param config [Config, nil]
|
|
20
|
+
# @param config_key [String, nil]
|
|
21
|
+
# @return [Object, nil] the value from the first level that's set.
|
|
22
|
+
def resolve(override: nil, env: nil, config: nil, config_key: nil)
|
|
23
|
+
return override if set?(override)
|
|
24
|
+
|
|
25
|
+
from_env = env && ENV.fetch(env, nil)
|
|
26
|
+
return from_env if set?(from_env)
|
|
27
|
+
|
|
28
|
+
from_config = config_key && config&.get(config_key)
|
|
29
|
+
from_config if set?(from_config)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The same precedence, read as yes/no: true, or a string in
|
|
33
|
+
# TRUE_VALUES (any case). Anything else, unset included, is no.
|
|
34
|
+
def flag?(**)
|
|
35
|
+
TRUE_VALUES.include?(resolve(**).to_s.strip.downcase)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def set?(value)
|
|
39
|
+
!(value.nil? || (value.is_a?(String) && value.strip.empty?))
|
|
40
|
+
end
|
|
41
|
+
private_class_method :set?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/portage/cli/version.rb
CHANGED