watchfor 0.14.0 → 0.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5710747dad8c121a91b701267655d89cfdfdacdc895a8413f15b7e80ca1998e0
4
- data.tar.gz: ce69c62e364fdc582a8f2bdab673bfa5f3d144814918730579fd823d260345be
3
+ metadata.gz: 3af4a4c0ee9b7d5b20fc4873a6710d37cc3d4c2d5197ff8a41fc108bc5c1bd38
4
+ data.tar.gz: 4ca5dea1cb43c7f1a2afc51c0eaf05048e1f5fd2db6f2545b4ebcc7d822e227b
5
5
  SHA512:
6
- metadata.gz: f05f3baa7d1b773dcc32d25641f633c7577f2711139e4af7827d7b11e53e22e0cb24f3d21ce692013386cf0650030b24e02f34df1566f328ec56af471d09a289
7
- data.tar.gz: c2285f93fe30565356cc821bb60cb096869afa46246ce70e4cec05ed3689a3c66a73ad23c43689a7b58fd3a858a4d0a7e241b926e46a27e6b07420dcdc7cb910
6
+ metadata.gz: 8ff5740ebc2e684b7aace9f419e0801b6e01d03c1770202a4670361023818c64ae2c43fbdb76787bffc714999411f6be30d7c765daaee73300ce04f1e9573a1e
7
+ data.tar.gz: 90f73616551e75ddf5e0093653609cd3375f7445d3bd1880b0159d0981cfd1947051037e908765feaa75bb7a2489cf2352a20cb150666fec3ed7c2727ca8ca0d
data/README.md CHANGED
@@ -59,6 +59,19 @@ wf.monitors.checks(mon["id"], hours: 24, limit: 100, cursor: page["next_cursor"]
59
59
 
60
60
  # Delete a contact group that alert rules still use (409 without force)
61
61
  wf.contact_groups.delete("<group-id>", force: true)
62
+
63
+ # What the alerting engine sees right now: alerted, recovering 1/3, confirming 2 of 3…
64
+ state = wf.monitors.get(mon["id"])["alerting_state"]
65
+
66
+ # Response times on a real time axis (5-min buckets for 24h, 15 min for 7d)
67
+ up = wf.monitors.uptime(mon["id"], "24h", series: true)
68
+ (up["response_time_series"] || []).each { |b| puts "#{b['at']} #{b['avg_ms']}" }
69
+
70
+ # Pause up to 100 monitors at once; post a status-page announcement
71
+ wf.monitors.bulk("pause", ["<id-1>", "<id-2>"])
72
+ notice = wf.status_pages.create_announcement("<page-id>",
73
+ { title: "Checkout errors", body: "We are looking into it." })
74
+ wf.status_pages.update_announcement("<page-id>", notice["id"], { body: "Fixed.", status: "resolved" })
62
75
  ```
63
76
 
64
77
  ### Live diagnostics
@@ -100,13 +113,49 @@ Runs need a `write` key (a probe sends real traffic from WatchFor's IPs)
100
113
  and spend the same per-plan hourly allowance as the dashboard Toolbox.
101
114
  Details: <https://watchfor.io/docs/api/diagnostics>
102
115
 
103
- Namespaces: `wf.monitors`, `wf.alert_rules`, `wf.incidents`,
116
+ Namespaces: `wf.organizations`, `wf.monitors`, `wf.alert_rules`, `wf.incidents`,
104
117
  `wf.hosts`, `wf.status_pages`, `wf.on_call`, `wf.notification_channels`, `wf.maintenance_windows`, `wf.contacts`, `wf.contact_groups`,
105
- `wf.diagnostics`. Top-level:
118
+ `wf.diagnostics`, `wf.playwright`, `wf.variables`. Top-level:
106
119
  `wf.summary`, `wf.plan`, `wf.me`, `wf.locations`, `wf.monitor_types`,
107
- `wf.incident_stats(period)`, `wf.notifications`, `wf.activity`. Errors raise
120
+ `wf.incident_stats(period)`, `wf.report`, `wf.notifications`, `wf.activity`,
121
+ `wf.batch([...])`, `wf.metrics`. Errors raise
108
122
  `Watchfor::Error` with `#status`, `#code`, `#message`.
109
123
 
124
+ ## Organizations
125
+
126
+ An API key belongs to one organization. An **OAuth access token** acts as
127
+ the user who approved it, so it reaches every organization that user
128
+ belongs to; choose one with `organization:` — the SDK sends it as the
129
+ `X-WatchFor-Organization` header on every request:
130
+
131
+ ```ruby
132
+ wf = Watchfor::Client.new(api_key: oauth_access_token)
133
+ wf.organizations.list["data"].each do |org|
134
+ puts "#{org['id']} #{org['name']} #{org['role']} is_default=#{org['is_default']} is_current=#{org['is_current']}"
135
+ end
136
+
137
+ acme = Watchfor::Client.new(api_key: oauth_access_token, organization: "<organization-id>")
138
+ acme.me # {"organization" => {"id", "name", "slug"}, "scope" => ...}
139
+ other = acme.with_organization("<another-organization-id>") # new client, same token
140
+ other.monitors.list
141
+ ```
142
+
143
+ - `organizations.list` returns the ids to use: every organization of the
144
+ token's user with their `role`, `is_default` (used without `organization:`)
145
+ and `is_current` (what this client acts on). With an API key it returns just
146
+ the key's own organization (`role` is `nil`).
147
+ - Without `organization:`, an OAuth token acts on the user's oldest owned
148
+ organization (else their oldest membership).
149
+ - The user must be a member of that organization (otherwise `403
150
+ forbidden`), and their role there applies: members read, acknowledge
151
+ incidents and run diagnostics; changes need owner or admin.
152
+ - With an API key leave it unset; it may only repeat the key's own
153
+ organization id, and any other id is `403 forbidden` (a key never
154
+ switches organization).
155
+ - `with_organization(nil)` drops the header again.
156
+
157
+ Details: <https://watchfor.io/docs/api/authentication#how-oauth-maps-to-your-data>
158
+
110
159
  ## Prometheus metrics (Pro plan and above)
111
160
 
112
161
  ```ruby
@@ -124,6 +173,13 @@ watchfor monitors
124
173
  watchfor incidents
125
174
  watchfor checks <monitor_id> [--cursor <next_cursor>]
126
175
  watchfor report --period 30d
176
+ watchfor me # which organization + scope
177
+ watchfor organizations # ids for --organization
178
+
179
+ # OAuth token: pick the organization per call or via the environment
180
+ export WATCHFOR_API_KEY=<oauth-access-token>
181
+ watchfor --organization <organization-id> monitors
182
+ WATCHFOR_ORGANIZATION=<organization-id> watchfor summary
127
183
  ```
128
184
 
129
185
  Reference: <https://watchfor.io/openapi.json> · <https://watchfor.io/docs/api>
data/exe/watchfor CHANGED
@@ -8,6 +8,22 @@ api_key = ENV["WATCHFOR_API_KEY"]
8
8
  base_url = ENV.fetch("WATCHFOR_BASE_URL", Watchfor::DEFAULT_BASE_URL)
9
9
 
10
10
  args = ARGV.dup
11
+
12
+ # Global --organization <id> / --organization=<id>, anywhere on the line;
13
+ # WATCHFOR_ORGANIZATION is the default. OAuth tokens reach every
14
+ # organization their user belongs to; an API key only accepts its own id.
15
+ organization = ENV.fetch("WATCHFOR_ORGANIZATION", nil)
16
+ if (i = args.index("--organization"))
17
+ organization = args[i + 1].to_s
18
+ args.slice!(i, 2)
19
+ if organization.strip.empty?
20
+ warn "--organization needs an organization id"
21
+ exit 2
22
+ end
23
+ elsif (i = args.index { |a| a.start_with?("--organization=") })
24
+ organization = args.delete_at(i).split("=", 2)[1].to_s
25
+ end
26
+
11
27
  command = args.shift
12
28
 
13
29
  if command == "--version"
@@ -16,15 +32,17 @@ if command == "--version"
16
32
  end
17
33
 
18
34
  if api_key.nil? || api_key.empty?
19
- warn "No API key. Set WATCHFOR_API_KEY."
35
+ warn "No API key. Set WATCHFOR_API_KEY (an API key or an OAuth access token)."
20
36
  exit 2
21
37
  end
22
38
 
23
- wf = Watchfor::Client.new(api_key: api_key, base_url: base_url)
39
+ wf = Watchfor::Client.new(api_key: api_key, base_url: base_url, organization: organization)
24
40
 
25
41
  begin
26
42
  result =
27
43
  case command
44
+ when "me" then wf.me
45
+ when "organizations", "orgs" then wf.organizations.list
28
46
  when "summary" then wf.summary
29
47
  when "plan" then wf.plan
30
48
  when "monitors" then wf.monitors.list
@@ -42,7 +60,10 @@ begin
42
60
  print wf.metrics(tag: tag, type: type)
43
61
  exit 0
44
62
  else
45
- warn "Usage: watchfor [summary|plan|monitors|locations|monitor-types|incidents|checks <monitor_id> [--cursor c]|report [--period 7d|30d]|metrics [--tag t] [--type t]]"
63
+ warn "Usage: watchfor [--organization <id>] " \
64
+ "[me|organizations|summary|plan|monitors|locations|monitor-types|incidents|checks <monitor_id> [--cursor c]|" \
65
+ "report [--period 7d|30d]|metrics [--tag t] [--type t]]\n" \
66
+ "Env: WATCHFOR_API_KEY (API key or OAuth access token), WATCHFOR_ORGANIZATION, WATCHFOR_BASE_URL"
46
67
  exit 2
47
68
  end
48
69
  puts JSON.pretty_generate(result)
@@ -6,6 +6,8 @@ require "uri"
6
6
 
7
7
  module Watchfor
8
8
  DEFAULT_BASE_URL = "https://watchfor.io/api/v1"
9
+ # The request header that selects the organization an OAuth token acts on.
10
+ ORGANIZATION_HEADER = "X-WatchFor-Organization"
9
11
 
10
12
  # Raised when the API returns a non-2xx response. Carries the HTTP
11
13
  # +status+ and the API's structured +code+ and +message+.
@@ -24,17 +26,29 @@ module Watchfor
24
26
  # wf = Watchfor::Client.new(api_key: "wf_live_...")
25
27
  # wf.summary
26
28
  # wf.monitors.list["data"].each { |m| puts "#{m['name']} #{m['status']}" }
29
+ #
30
+ # An OAuth access token reaches every organization its user belongs to;
31
+ # pick one with +organization:+ (sent as X-WatchFor-Organization):
32
+ #
33
+ # acme = Watchfor::Client.new(api_key: oauth_token, organization: "<org id>")
34
+ # other = acme.with_organization("<another org id>")
35
+ #
36
+ # An API key belongs to one organization: leave +organization:+ unset, or
37
+ # pass that same id (any other id is 403 forbidden).
27
38
  class Client
28
- attr_reader :monitors, :alert_rules, :incidents, :hosts, :status_pages, :on_call, :notification_channels,
39
+ attr_reader :organizations, :monitors, :alert_rules, :incidents, :hosts, :status_pages, :on_call, :notification_channels,
29
40
  :maintenance_windows, :contacts, :contact_groups,
30
- :diagnostics, :playwright, :variables
41
+ :diagnostics, :playwright, :variables, :organization
31
42
 
32
- def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: 30)
43
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: 30, organization: nil)
33
44
  raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
34
45
 
35
46
  @api_key = api_key
36
47
  @base_url = base_url.chomp("/")
37
48
  @timeout = timeout
49
+ org = organization.to_s.strip
50
+ @organization = org.empty? ? nil : org
51
+ @organizations = Organizations.new(self)
38
52
  @monitors = Monitors.new(self)
39
53
  @alert_rules = AlertRules.new(self)
40
54
  @incidents = Incidents.new(self)
@@ -50,6 +64,14 @@ module Watchfor
50
64
  @variables = Variables.new(self)
51
65
  end
52
66
 
67
+ # A new client acting on another organization (same credential and
68
+ # settings); nil drops the header so the credential's default
69
+ # organization applies. This client is left unchanged.
70
+ def with_organization(organization)
71
+ self.class.new(api_key: @api_key, base_url: @base_url, timeout: @timeout,
72
+ organization: organization)
73
+ end
74
+
53
75
  # Core request. +query+ and +body+ are optional hashes.
54
76
  def request(method, path, query: nil, body: nil, idempotency_key: nil, text: false)
55
77
  uri = URI("#{@base_url}#{path}")
@@ -68,6 +90,7 @@ module Watchfor
68
90
  req["Accept"] = text ? "text/plain, application/json" : "application/json"
69
91
  req["User-Agent"] = "watchfor-ruby/#{Watchfor::VERSION}"
70
92
  req["Idempotency-Key"] = idempotency_key if idempotency_key
93
+ req[ORGANIZATION_HEADER] = @organization if @organization
71
94
  unless body.nil?
72
95
  req["Content-Type"] = "application/json"
73
96
  req.body = JSON.generate(body)
@@ -92,7 +115,17 @@ module Watchfor
92
115
  end
93
116
 
94
117
  # Top-level reads
118
+ # The credential's scope and the organization it acts on (with an OAuth
119
+ # token, the one +organization:+ selected).
95
120
  def me = request("GET", "/me")
121
+
122
+ # Run up to 20 operations ({ method:, path:, body: } with a relative
123
+ # /api/v1 path) in one request, with the same credential and
124
+ # organization; "results" come back in request order as {status, body}.
125
+ def batch(operations) = request("POST", "/batch", body: { operations: operations })
126
+
127
+ # The OpenAPI 3.1 document this client wraps (no key needed).
128
+ def openapi_spec = request("GET", "/openapi.json")
96
129
  def summary = request("GET", "/summary")
97
130
 
98
131
  # The organization in Prometheus text format (Pro plan and above):
@@ -108,11 +141,24 @@ module Watchfor
108
141
  # Org report vs the previous period (period: "7d"/"30d", or from/to ISO 8601
109
142
  # on advanced-reports plans): uptime, incidents, MTTR/MTBF, response-time
110
143
  # trend, per-monitor rows, type-aware sections and plain-language insights.
111
- def report(period: nil, from: nil, to: nil)
144
+ #
145
+ # Scoping (advanced-reports plans): monitor_ids:/tags: (union), sections:
146
+ # (locations, ssl, domains, cwv, heartbeat, blacklist, mcp, hosts),
147
+ # incident_status: (acknowledged, resolved, open) — arrays or
148
+ # comma-separated strings. false for response_time:/monitors_table:/
149
+ # insights: drops that block.
150
+ def report(period: nil, from: nil, to: nil, monitor_ids: nil, tags: nil, sections: nil,
151
+ response_time: nil, monitors_table: nil, insights: nil, incident_status: nil)
152
+ csv = ->(v) { v.is_a?(Array) ? v.join(",") : v }
153
+ query = { monitor_ids: csv.call(monitor_ids), tags: csv.call(tags), sections: csv.call(sections),
154
+ incident_status: csv.call(incident_status),
155
+ response_time: response_time.nil? ? nil : response_time.to_s,
156
+ monitors_table: monitors_table.nil? ? nil : monitors_table.to_s,
157
+ insights: insights.nil? ? nil : insights.to_s }
112
158
  if from || to
113
- request("GET", "/reports", query: { from: from, to: to })
159
+ request("GET", "/reports", query: query.merge(from: from, to: to))
114
160
  else
115
- request("GET", "/reports", query: { period: period })
161
+ request("GET", "/reports", query: query.merge(period: period))
116
162
  end
117
163
  end
118
164
  # The org's two report-email cadences (weekly + monthly); set toggles
@@ -131,8 +177,17 @@ module Watchfor
131
177
 
132
178
  class Monitors < Namespace
133
179
  def list(**query) = @c.request("GET", "/monitors", query: query)
134
- # The monitor plus available_metrics (alert metrics valid for its type).
180
+ # The monitor plus available_metrics (alert metrics valid for its type)
181
+ # and alerting_state — what the alerting engine sees right now (alerted,
182
+ # recovering n of m, failing/confirming n of m; nil when every rule is
183
+ # healthy).
135
184
  def get(id) = @c.request("GET", "/monitors/#{id}")
185
+ # Pause, resume or delete (+action+) up to 100 monitors in one call; ids
186
+ # that are not yours or need no change come back in "skipped".
187
+ def bulk(action, ids, idempotency_key: nil)
188
+ @c.request("POST", "/monitors/bulk", body: { action: action, ids: ids },
189
+ idempotency_key: idempotency_key)
190
+ end
136
191
  # Credentials (catalog fields marked secret: mailbox/SMTP/FTP passwords,
137
192
  # SSH keys, HTTP/API/MCP auth passwords, bearer tokens and credential
138
193
  # header values) are write-only: stored encrypted, returned as
@@ -142,7 +197,9 @@ module Watchfor
142
197
  # config is merged (omitted keys kept, nil removes). A "[redacted]"
143
198
  # credential is kept when omitted or sent back; changing the server,
144
199
  # port, TLS mode or login it is used with (HTTP: the URL's host, port
145
- # or scheme) needs it again (else 400).
200
+ # or scheme) needs it again (else 400). "check_queued" => true in the
201
+ # result means the change dispatched one immediate check, outside the
202
+ # Run-now budget.
146
203
  def update(id, body) = @c.request("PATCH", "/monitors/#{id}", body: body)
147
204
  def delete(id) = @c.request("DELETE", "/monitors/#{id}")
148
205
  def pause(id) = @c.request("POST", "/monitors/#{id}/pause")
@@ -153,7 +210,13 @@ module Watchfor
153
210
  # monitor and starts uptime over ("since reset"); the monitor and its
154
211
  # configuration, alert rules, tags and locations stay.
155
212
  def reset(id) = @c.request("POST", "/monitors/#{id}/reset")
156
- def uptime(id, period = nil) = @c.request("GET", "/monitors/#{id}/uptime", query: { period: period })
213
+ # Uptime (confirmed critical incidents only), check success and
214
+ # response-time stats. series: true (periods up to 7d) adds
215
+ # "response_time_series" — buckets of "series_bucket_seconds" (300 for
216
+ # 24h, 900 for 7d) on a real time axis, oldest first.
217
+ def uptime(id, period = nil, series: false)
218
+ @c.request("GET", "/monitors/#{id}/uptime", query: { period: period, series: series ? "true" : nil })
219
+ end
157
220
  # Recent probe results, newest first. Page with the response's next_cursor -> cursor:
158
221
  # (keep the same hours/success); "locations" is the per-location breakdown for the whole window.
159
222
  def checks(id, hours: nil, limit: nil, success: nil, cursor: nil, **query)
@@ -206,10 +269,21 @@ module Watchfor
206
269
  def delete(name) = @c.request("DELETE", "/variables/#{name}")
207
270
  end
208
271
 
272
+ # The organizations this credential can act on: the ids +organization:+ /
273
+ # +with_organization+ accept. An OAuth token lists every organization of
274
+ # its user ("role", plus "is_default" for the one used without
275
+ # +organization:+ and "is_current" for the one the request acted on); an
276
+ # API key only its own ("role" nil).
277
+ class Organizations < Namespace
278
+ def list = @c.request("GET", "/organizations")
279
+ end
280
+
209
281
  class AlertRules < Namespace
210
282
  def list(monitor_id) = @c.request("GET", "/monitors/#{monitor_id}/alert-rules")
211
283
  def get(monitor_id, rule_id) = @c.request("GET", "/monitors/#{monitor_id}/alert-rules/#{rule_id}")
212
- def create(monitor_id, body) = @c.request("POST", "/monitors/#{monitor_id}/alert-rules", body: body)
284
+ def create(monitor_id, body, idempotency_key: nil)
285
+ @c.request("POST", "/monitors/#{monitor_id}/alert-rules", body: body, idempotency_key: idempotency_key)
286
+ end
213
287
  def update(monitor_id, rule_id, body) = @c.request("PATCH", "/monitors/#{monitor_id}/alert-rules/#{rule_id}", body: body)
214
288
  def delete(monitor_id, rule_id) = @c.request("DELETE", "/monitors/#{monitor_id}/alert-rules/#{rule_id}")
215
289
  end
@@ -225,12 +299,40 @@ module Watchfor
225
299
  def exclude(id, reason: nil) = @c.request("POST", "/incidents/#{id}/exclude", body: reason.nil? ? {} : { reason: reason })
226
300
  # Undo #exclude - the incident counts as downtime again.
227
301
  def include(id) = @c.request("POST", "/incidents/#{id}/include")
302
+ # Retroactively count a RESOLVED incident as planned maintenance: it
303
+ # leaves uptime and SLA. Returns the "maintenance_window" recorded for it
304
+ # (marking again returns the same one); delete that window to undo.
305
+ def mark_maintenance(id, idempotency_key: nil)
306
+ @c.request("POST", "/incidents/#{id}/mark-maintenance", idempotency_key: idempotency_key)
307
+ end
228
308
  end
229
309
 
230
310
  class StatusPages < Namespace
231
311
  def list(**query) = @c.request("GET", "/status-pages", query: query)
232
312
  def get(id) = @c.request("GET", "/status-pages/#{id}")
313
+ # Create a page (slug, name, optional components...). Password protection,
314
+ # logo upload and custom subdomains are dashboard-only.
315
+ def create(body, idempotency_key: nil) = @c.request("POST", "/status-pages", body: body, idempotency_key: idempotency_key)
316
+ # Partial update; a slug change frees the old URL at once.
317
+ def update(id, body) = @c.request("PATCH", "/status-pages/#{id}", body: body)
318
+ def delete(id) = @c.request("DELETE", "/status-pages/#{id}")
319
+ # Announcements, newest first.
233
320
  def announcements(id, **query) = @c.request("GET", "/status-pages/#{id}/announcements", query: query)
321
+ def get_announcement(id, announcement_id) = @c.request("GET", "/status-pages/#{id}/announcements/#{announcement_id}")
322
+ # Publish an incident notice (title, body, optional status, severity,
323
+ # affectedComponentIds); body becomes the first timeline entry.
324
+ def create_announcement(id, body, idempotency_key: nil)
325
+ @c.request("POST", "/status-pages/#{id}/announcements", body: body, idempotency_key: idempotency_key)
326
+ end
327
+ # Post an update (body + status): appends to the public timeline;
328
+ # "resolved" closes it.
329
+ def update_announcement(id, announcement_id, body)
330
+ @c.request("PATCH", "/status-pages/#{id}/announcements/#{announcement_id}", body: body)
331
+ end
332
+ # Remove from the public page (soft delete).
333
+ def delete_announcement(id, announcement_id)
334
+ @c.request("DELETE", "/status-pages/#{id}/announcements/#{announcement_id}")
335
+ end
234
336
  end
235
337
 
236
338
  class OnCall < Namespace
@@ -249,6 +351,7 @@ module Watchfor
249
351
  end
250
352
 
251
353
  class MaintenanceWindows < Namespace
354
+ # state: "active" / "upcoming" keeps only live windows.
252
355
  def list(**query) = @c.request("GET", "/maintenance-windows", query: query)
253
356
  def get(id) = @c.request("GET", "/maintenance-windows/#{id}")
254
357
  def create(body, idempotency_key: nil) = @c.request("POST", "/maintenance-windows", body: body, idempotency_key: idempotency_key)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Watchfor
4
- VERSION = "0.14.0"
4
+ VERSION = "0.16.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: watchfor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - WatchFor
@@ -24,11 +24,11 @@ files:
24
24
  - lib/watchfor.rb
25
25
  - lib/watchfor/client.rb
26
26
  - lib/watchfor/version.rb
27
- homepage: https://watchfor.io/docs/api
27
+ homepage: https://watchfor.io
28
28
  licenses:
29
29
  - MIT
30
30
  metadata:
31
- homepage_uri: https://watchfor.io/docs/api
31
+ homepage_uri: https://watchfor.io
32
32
  documentation_uri: https://watchfor.io/docs/api
33
33
  changelog_uri: https://watchfor.io/changelog
34
34
  rubygems_mfa_required: 'true'