specguard-ruby 0.3.1

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,942 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ require_relative "../rspec"
7
+ require_relative "ingest_reporter"
8
+ require_relative "transport"
9
+
10
+ # `specguard-ingest`'s command line — the other end of `log/test_results.jsonl`.
11
+ #
12
+ # == Why this file is not on `require "specguard/rspec"`'s chain
13
+ #
14
+ # `specguard-lint` runs on machines that never make a network call, and putting
15
+ # this file on the umbrella's chain would put `net/http`, `uri` and `zlib` on
16
+ # the linter's load path to serve a command it never invokes. So it is loaded by
17
+ # its own path, exactly as the formatter is and for the same reason —
18
+ # `require "specguard/rspec/ingest_cli"`, which `bin/specguard-ingest` does.
19
+ module SpecGuard
20
+ module RSpec
21
+ # Replays a saved run: reads a `log/test_results.jsonl` back and re-delivers
22
+ # each line through the {Transport} this gem already ships.
23
+ #
24
+ # == The file is not a new wire format, and that is the whole design
25
+ #
26
+ # `SpecGuard::RSpecFormatter#deliver` hands the *same* Hash to
27
+ # `Transport#deliver` and to its own `append`, and each of them calls
28
+ # `JSON.generate` on it once. A line in the sink is therefore byte-for-byte
29
+ # the body the endpoint refused, so replaying it needs no parser, no
30
+ # migration and no versioning — only something that reads a line back and
31
+ # POSTs it. That is all this class is.
32
+ #
33
+ # == The exit contract, which is {CLI}'s reasoning transferred verbatim
34
+ #
35
+ # 0 every line was accepted
36
+ # 1 at least one line was refused by the endpoint
37
+ # 2 this tool could not do its job
38
+ #
39
+ # Ruby exits **1** for an uncaught exception and for an uncaught
40
+ # `OptionParser::InvalidOption`, and 1 is already spent here on "the
41
+ # endpoint said no". Left alone, `specguard-ingest --dry-runn` and an
42
+ # unreadable file would both report that the platform refused a run it was
43
+ # never offered — a tool failure wearing the costume of a content failure,
44
+ # which is the defect {CLI} exists to keep out of the linter. So {#run}
45
+ # rescues and *returns* rather than exits, and {EXIT_REFUSED} is produced in
46
+ # exactly one place — {#exit_code}, over a `:refused` {LineResult} — so it
47
+ # means that and nothing else.
48
+ #
49
+ # `Interrupt`, `SignalException` and `SystemExit` are deliberately not
50
+ # caught. Ctrl-C halfway through a 40-line file must stay Ctrl-C.
51
+ #
52
+ # == Where the line between 1 and 2 actually falls
53
+ #
54
+ # Not where {Transport::Result} draws it. That struct answers `:rejected`
55
+ # for *any* non-2xx, and "a non-2xx came back" is not the same claim as
56
+ # "the endpoint read this payload and judged it" — so this class redraws
57
+ # the line rather than inheriting it.
58
+ #
59
+ # The platform emits exactly one verdict about a payload:
60
+ # `Api::V1::IngestsController#create` reaches
61
+ # `render_bad_request(payload.errors)`, a **400**. Every other non-2xx is
62
+ # the endpoint declining to look at the run — a 401 is answered by
63
+ # `authenticate_api_key!`'s `before_action` before the action runs at all,
64
+ # so `Ingest::Payload` is never even constructed — or the endpoint not
65
+ # being there (404), or the platform failing (429, 5xx). **Nothing was
66
+ # stored in any of them**, and none of them is a statement about anyone's
67
+ # suite. They are `:undelivered`, and they are a 2.
68
+ #
69
+ # `:failed` — connection refused, DNS, TLS, a read timeout — is a 2 for the
70
+ # same reason arrived at from further away: nothing was delivered and no
71
+ # verdict exists. A socket error reported as a 1 would be this tool telling
72
+ # an operator their run is bad on the strength of a broken pipe; a 404
73
+ # reported as a 1 would be it telling them the same thing on the strength
74
+ # of a typo in `SPECGUARD_ENDPOINT`, while its own advice line says to go
75
+ # fix that variable.
76
+ #
77
+ # That last case is the one that fixes the rule in place: an **unset**
78
+ # `SPECGUARD_ENDPOINT` is a 2 from {#build_transport}, so an endpoint set
79
+ # to the *wrong URL* must be a 2 as well. Same operator mistake, same fix,
80
+ # and the case where this tool knows more must not report worse.
81
+ #
82
+ # 2 also dominates: a file where line 3 was refused and line 7 never
83
+ # arrived exits 2, because the second fact is the one that leaves work
84
+ # undone. Both are printed either way — the exit code chooses what to
85
+ # shout, never what to say.
86
+ #
87
+ # == It re-delivers EVERY line, and will not guess which ones were failures
88
+ #
89
+ # The sink is not a failure log. `#deliver` writes to it when a delivery
90
+ # failed *and* when no API key was set at all (`return append(data) if
91
+ # blank?(configuration.api_key)`), and the two are indistinguishable on the
92
+ # line — there is no field to tell them apart. A developer's local file is
93
+ # therefore a file of ordinary laptop runs, and pointing this command at it
94
+ # sends all of them.
95
+ #
96
+ # A heuristic here would be worse than the gap: it would be this tool
97
+ # guessing at somebody's intent from data that does not carry it, and being
98
+ # confidently wrong about which runs reach the platform. So the tool is
99
+ # explicit and user-initiated instead, `--help` says so in as many words,
100
+ # and no line is filtered.
101
+ #
102
+ # `--from-line` and `--lines` are the two things that narrow the set, and
103
+ # both narrow it by numbers the user typed after reading the report — which
104
+ # is the opposite of a guess. They exist because {LineResult}'s numbering is
105
+ # only half of criterion 3: a report that says line 7 was refused is worth
106
+ # little if acting on it means re-sending lines 1 through 6, and re-sending
107
+ # a line that carries no `ci_run_id` is not free — `RunRecorder` has no
108
+ # identity to fold it onto, so it becomes a second row.
109
+ #
110
+ # == Why a suffix was not enough, and why `--lines` is not a heuristic
111
+ #
112
+ # `--from-line N` can express only a *suffix*, and the set a report points
113
+ # at is a suffix at most once. The sink is append-only and mixes both
114
+ # sources, so ordinary keyless laptop runs keep landing *after* the CI
115
+ # failures somebody wants to replay. Worse, {CONTENT_REFUSAL_CODES} is a
116
+ # content verdict — a 400 will be refused every time it is offered — so a
117
+ # 400 sitting at line 3 of a 40-line file is a line no suffix can step over,
118
+ # and the file can never be replayed to completion.
119
+ #
120
+ # `--lines 3,7,12-15` is the same category of thing as `--from-line`: a set
121
+ # the user typed, over the file's own numbering, after reading `--list`.
122
+ # Nothing about the line's *content* is consulted, which is the line the
123
+ # paragraph above draws and this flag stays on the explicit side of.
124
+ #
125
+ # The two are refused *together* — {UsageError}, a 2. They answer the same
126
+ # question, and an intersection would silently drop a number the user typed
127
+ # (`--from-line 5 --lines 3,7` delivers only 7, and the 3 vanishes without a
128
+ # word). Quietly narrowing what it was asked for is the failure this whole
129
+ # file is arranged against, so the combination is refused rather than
130
+ # resolved. Carving the file up with `sed` is not the alternative either: a
131
+ # carved temp file renumbers, and the whole value of resuming from a report
132
+ # is that line 12 is still line 12.
133
+ #
134
+ # A *repeated* selector is the other half of that question and gets the
135
+ # opposite answer deliberately, not by omission: `--lines 1,2 --lines 4`
136
+ # delivers line 4, and `--from-line 2 --from-line 5` starts at 5, both by
137
+ # last-wins. The reason the cross-flag case is refused does not reach this
138
+ # one. Refusing `--from-line` with `--lines` is about two *different* flags
139
+ # answering one question, where combining them yields a set smaller than
140
+ # either one names and the user cannot see which of their numbers went. A
141
+ # repeat is one flag answering its own question twice, and the later answer
142
+ # *replaces* the earlier rather than intersecting it — the delivered set is
143
+ # exactly the last one typed, which is the one thing on the command line
144
+ # that is unambiguously current. It is also what makes a selector
145
+ # overridable at all: a wrapper script or shell alias that bakes in
146
+ # `--lines` is corrected by appending a new one, and refusing the repeat
147
+ # would take that away for no gain in clarity. Last-wins is `OptionParser`'s
148
+ # convention and the shell's; this file adopts it on purpose and pins it in
149
+ # the spec, so it is a decision rather than a default nobody looked at.
150
+ #
151
+ # == `--list`, which is what makes "check the file first" an instruction
152
+ #
153
+ # Having refused to guess *for* the user, this command owes them what they
154
+ # need to decide. {#list} prints one row per line off the fields the
155
+ # envelope already carries — `branch`, `commit_sha`, `ci_run_id` or its
156
+ # absence, how many examples, how long — and delivers nothing. Reading the
157
+ # file by hand is not the alternative: one line is one whole run, and at
158
+ # this project's design point that is megabytes of JSON on a single
159
+ # physical line.
160
+ #
161
+ # It short-circuits **ahead of {#build_transport}**, deliberately, and that
162
+ # ordering is the load-bearing part. `build_transport` raises for a blank
163
+ # `SPECGUARD_ENDPOINT` or `SPECGUARD_API_KEY`, and the file that most needs
164
+ # checking is the one written *because no API key was set*
165
+ # (`Formatter` — `return append(data) if blank?(configuration.api_key)`).
166
+ # Requiring a key to look at the file would withdraw the instrument in
167
+ # exactly the situation that produces the hazard.
168
+ #
169
+ # Listing delivers nothing, so it can never be a content verdict: it
170
+ # answers 0 or 2 and never routes through {#exit_code}, which stays the one
171
+ # place {EXIT_REFUSED} is produced.
172
+ #
173
+ # == What it will not claim
174
+ #
175
+ # The natural question about a replay is whether a line *folded onto* an
176
+ # existing run or *created* a new one, and the platform does not answer it:
177
+ # `Ingest::RunRecorder#record` find-or-creates by `ci_run_id` and the 202
178
+ # body carries no created-versus-updated flag. So this class reports what it
179
+ # can see — the `test_run_id` that came back, and whether the line carried a
180
+ # `ci_run_id` at all — and states folding only where two lines *observed*
181
+ # it: same `ci_run_id` in, same `test_run_id` out is one row, not an
182
+ # inference about one. See {#folded_runs}.
183
+ #
184
+ # == `--json`, and the one thing the human report cannot say
185
+ #
186
+ # A 400 is the only *permanent* verdict here ({CONTENT_REFUSAL_CODES}), so
187
+ # the only way to land a refused line is to learn which specs the platform
188
+ # objected to and fix the payload. It names every one of them — one error per
189
+ # bad spec — and {Transport::Result#reason} renders three, truncated to 300
190
+ # characters, because it exists for the one stderr line an in-run CI warning
191
+ # is allowed. On a systemic client bug over a 20,000-example suite, the
192
+ # command whose entire job is to fix and re-send a refused run was showing
193
+ # three of twenty thousand reasons.
194
+ #
195
+ # `--json` is the second channel that cap's own grounds hand over, and it is
196
+ # a RENDERER: stdout carries one document instead of the human report, in
197
+ # both modes, over the same {LineResult}s, the same counts, the same
198
+ # groupings and the same {#exit_code}. Nothing about what this command
199
+ # *decides* moves — the cap, `#reason` and the formatter's warning are
200
+ # untouched, and the default output is byte-identical (pinned in
201
+ # `spec/specguard/rspec/regression_targets_spec.rb`). See {IngestReporter}
202
+ # for the document, and for which runs emit one.
203
+ class IngestCLI
204
+ BANNER = "Usage: specguard-ingest [options] <file>"
205
+
206
+ # Shown by `--help`. The second paragraph is the one that has to be there:
207
+ # the sink mixes failed deliveries with ordinary keyless local runs and
208
+ # nothing on the line tells them apart, so a developer must not be able to
209
+ # discover only afterwards that they pushed their laptop's history.
210
+ DESCRIPTION = <<~TEXT.freeze
211
+ Re-delivers a saved run to SpecGuard's ingest endpoint. <file> is a
212
+ log/test_results.jsonl written by the RSpec formatter — one whole run per
213
+ line, byte-for-byte the body the endpoint was offered.
214
+
215
+ EVERY line in <file> is delivered — or, when you narrow it, every line
216
+ --from-line or --lines names. The formatter writes to this file both
217
+ when a delivery failed and when no API key was configured at all, and
218
+ the two are indistinguishable on the line, so a laptop's file is a file
219
+ of ordinary local runs and all of them will be sent. Nothing is
220
+ filtered and nothing is guessed at.
221
+
222
+ So check the file first: --list prints one row per line — branch, commit,
223
+ ci_run_id or its absence, how many examples, how long — and delivers
224
+ nothing at all. It needs no SPECGUARD_ENDPOINT and no SPECGUARD_API_KEY,
225
+ because the file most worth checking is the one written when no API key
226
+ was set. It composes with --from-line and --lines, so you can list the
227
+ exact set you are about to send.
228
+
229
+ Each line is delivered once, with no retry, and reported by its line
230
+ number in <file>. Use --from-line to resume a file that was only partly
231
+ accepted, or --lines to send an arbitrary set of them — 3,7,12-15 — over
232
+ that same numbering, instead of re-sending all of it. Both narrow the
233
+ same file, so give one or the other and never both.
234
+
235
+ Reads SPECGUARD_ENDPOINT, SPECGUARD_API_KEY and SPECGUARD_TIMEOUT.
236
+
237
+ --json replaces the human report with one JSON document on stdout, in
238
+ both modes. It carries every line's status, the endpoint's HTTP code and
239
+ the FULL list of reasons a refusal named — the human line has room for
240
+ three of them — plus the same summary counts and folding observations.
241
+ Warnings stay on stderr, and a run that never got as far as reading
242
+ <file> writes no document at all.
243
+
244
+ Exit codes:
245
+ 0 every line was accepted — or, with --list, the file was listed
246
+ 1 at least one line was refused by the endpoint — it read the payload
247
+ and said no (HTTP 400). Unreachable with --list, which delivers
248
+ nothing and so can never carry a verdict about a run
249
+ 2 this tool could not do its job — bad flags, no endpoint or API key,
250
+ an unreadable file, an unparseable line, a delivery that never
251
+ reached the endpoint, or one the endpoint answered without ever
252
+ reading it (401, 404, 429, 5xx — nothing was stored, so none of
253
+ them is a verdict about your run). With --list the only reachable
254
+ 2s are a bad flag and a file that cannot be read: listing needs no
255
+ credentials, and an unparseable line becomes a row in the listing
256
+ rather than an exit code
257
+ TEXT
258
+
259
+ # Every line in the file was accepted by the endpoint — including the
260
+ # vacuous case of a file with no lines in it, which is loud on stderr for
261
+ # exactly the reason `specguard-lint`'s empty selection is: the contract
262
+ # has no code for "there was nothing to do", so the warning carries it.
263
+ EXIT_OK = 0
264
+ # At least one line was refused. The only code that says something about
265
+ # the *content* of a run, and the only path to it is a non-2xx whose
266
+ # status is in {CONTENT_REFUSAL_CODES}.
267
+ EXIT_REFUSED = 1
268
+ # This tool could not do its job: bad flags, no endpoint or API key
269
+ # configured, a file it could not read, a line it could not parse, a
270
+ # delivery that never reached the endpoint at all, or one the endpoint
271
+ # answered without ever reading — a 401, 404, 429 or 5xx.
272
+ EXIT_MISUSE = 2
273
+
274
+ # The status codes that carry a verdict about the payload — which on this
275
+ # platform is exactly one.
276
+ #
277
+ # `Api::V1::IngestsController#create` reaches
278
+ # `render_bad_request(payload.errors)` and nothing else. A 401 comes from
279
+ # `authenticate_api_key!`'s `before_action` before the action runs, so
280
+ # `Ingest::Payload` is never constructed and no verdict about the run
281
+ # exists to report; a 403, 404, 429 or 5xx never gets as far as a body
282
+ # either. See the class comment for what that costs an operator when it
283
+ # is got wrong.
284
+ #
285
+ # A list of one rather than `code == 400`, so a platform that grows a
286
+ # second verdict is a one-line change here — and deliberately *not*
287
+ # pre-seeded with a 422 the platform does not send. An unlisted refusal
288
+ # is reported as `:undelivered`, which under-claims, and under-claiming
289
+ # is the safe direction: it sends an operator to look at their setup
290
+ # rather than at a suite that was never judged.
291
+ CONTENT_REFUSAL_CODES = [400].freeze
292
+
293
+ # What happened to one line of the file. `number` is its 1-based position
294
+ # in the file *as given*, counting the blank lines that were skipped, so
295
+ # the report addresses the same lines an editor does and a partially
296
+ # accepted file can be resumed from rather than blindly re-sent.
297
+ #
298
+ # `detail` is the flattened one-line rendering the text report prints, and
299
+ # `code` and `reasons` are the two structured facts flattening it destroys
300
+ # — the numeric HTTP status, and the **whole** array off
301
+ # {Transport::Result} rather than the three {Transport::Result#reason} has
302
+ # room for. They are carried rather than derived because a document cannot
303
+ # be reconstructed from prose: `and 19997 more` is not the 19,997.
304
+ #
305
+ # `reasons` is whatever there is to say about why this line did not land,
306
+ # as it arrived: the platform's own strings on a refusal (verbatim,
307
+ # including a `nil` where the body said nothing readable), the parse
308
+ # problem where the line was never a run, the exception's rendering where
309
+ # nothing reached the endpoint, and `[]` on an acceptance. Normalising it
310
+ # to a list of strings is {IngestReporter}'s job, because that guarantee
311
+ # is the document's rather than this struct's.
312
+ LineResult = Struct.new(:number, :status, :detail, :code, :reasons, :test_run_id, :ci_run_id,
313
+ keyword_init: true)
314
+
315
+ # The envelope facts a *listing* states about one line — the same five
316
+ # {#list_row} prints, extracted once so both renderers read one set of
317
+ # facts rather than each deciding for itself what a non-scalar `branch` or
318
+ # a non-Array `specs` means. `problem` names why the line is not a run,
319
+ # and is `nil` for every line that is one.
320
+ #
321
+ # Every other member is `nil` exactly where the row says `no branch`,
322
+ # `no specs` or `no duration_seconds`: the line does not carry that fact.
323
+ ListedLine = Struct.new(:number, :problem, :branch, :commit_sha, :ci_run_id, :examples,
324
+ :duration_seconds, keyword_init: true)
325
+
326
+ # Two or more accepted lines that went out with one `ci_run_id` and came
327
+ # back with one `test_run_id` — folding, observed rather than inferred.
328
+ # Grouped once ({#folded_runs}) and rendered twice, as a sentence by
329
+ # {#folding_observation} and as data by {IngestReporter}.
330
+ Folding = Struct.new(:ci_run_id, :test_run_id, :numbers, keyword_init: true)
331
+
332
+ # The file, as this tool reads it: the numbered lines that carry a
333
+ # payload, a count of the blank ones that do not, and a count of the ones
334
+ # the selector held back — with `selector` naming which flag did it, so
335
+ # the summary can say. The blanks and the skips are counted rather than
336
+ # dropped because a summary that quietly narrows what it is summarising is
337
+ # the failure this project keeps finding.
338
+ Source = Struct.new(:path, :lines, :blank, :skipped, :selector, keyword_init: true)
339
+
340
+ # What the command line asked for. A struct rather than a bare path,
341
+ # because `--from-line` is the second half of the same question — which
342
+ # lines of which file — and threading it as an ivar would put a value
343
+ # {#run} depends on somewhere {#run} does not name. `line_set` is that
344
+ # same half asked the other way, as an explicit set rather than a
345
+ # starting point, and the two are mutually exclusive (see the class
346
+ # comment). `list` is the third half: whether those lines are to be
347
+ # *shown* or *sent*. `json` is orthogonal to all three — it chooses the
348
+ # renderer, never the set and never the verdict.
349
+ #
350
+ # `line_set` is an Array of Ranges rather than an expanded Array of
351
+ # Integers, so `--lines 1-90000000` costs nothing to hold. `nil` means the
352
+ # flag was not given and `from_line` is the selector.
353
+ Options = Struct.new(:path, :from_line, :list, :line_set, :json, keyword_init: true)
354
+
355
+ # One entry of a `--lines` spec: `12` or `12-15`, and nothing else. No
356
+ # sign, no open end, no whitespace inside — {#parse_line_set} strips each
357
+ # entry before matching, so `3, 7` is fine, but `12-` and `5 - 7` are not
358
+ # near-misses to be repaired, they are typos to be reported. This is the
359
+ # `Integer`-coercion rationale on `--from-line` applied to a richer
360
+ # grammar: a spec that half-parses would silently deliver the wrong set,
361
+ # and delivering the wrong set is the one outcome a selector exists to
362
+ # prevent.
363
+ LINE_SPEC_ENTRY = /\A(\d+)(?:-(\d+))?\z/
364
+
365
+ STATUS_LABELS = {
366
+ accepted: "accepted",
367
+ refused: "refused",
368
+ undelivered: "not delivered",
369
+ unparseable: "unparseable"
370
+ }.freeze
371
+
372
+ def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
373
+ @stdout = stdout
374
+ @stderr = stderr
375
+ @env = env
376
+ end
377
+
378
+ # @param argv [Array<String>]
379
+ # @return [Integer] 0, 1 or 2 — never anything else, and never by letting
380
+ # an exception reach the shell
381
+ def run(argv)
382
+ options = parse_options(argv)
383
+ return EXIT_OK if options.nil? # --help / --version already printed
384
+
385
+ # Ahead of `build_transport`, and that is the whole point of the branch
386
+ # being here rather than after it. Listing sends nothing, so it needs no
387
+ # endpoint and no key — and the file that most wants looking at is the
388
+ # one the formatter wrote *because* no key was set. A listing that
389
+ # demanded credentials would be unavailable in exactly the case it
390
+ # exists for.
391
+ return list(options) if options.list
392
+
393
+ # Before the file is opened, deliberately. "There is nowhere to send
394
+ # this" is the earlier question — which lines to send does not matter
395
+ # when nothing is going to accept them — and asking it first means an
396
+ # unconfigured run reads its one real problem instead of a complaint
397
+ # about a path that was never the point.
398
+ transport = build_transport
399
+
400
+ source = read_source(options)
401
+ results = source.lines.map { |number, text| deliver_line(number, text, transport) }
402
+
403
+ report(source, results, json: options.json)
404
+ exit_code(results)
405
+ rescue UsageError => e
406
+ @stderr.puts "specguard-ingest: error: #{e.message}"
407
+ EXIT_MISUSE
408
+ rescue ScriptError, StandardError => e
409
+ # The backstop that makes exit 1 mean one thing. Anything reaching here
410
+ # is a bug in this tool, not a verdict from the endpoint about anyone's
411
+ # run, so it is a 2 and it says so in those words.
412
+ @stderr.puts "specguard-ingest: internal error: #{e.class}: #{e.message}"
413
+ EXIT_MISUSE
414
+ end
415
+
416
+ private
417
+
418
+ # One expression, over the whole file. The `:undelivered` and
419
+ # `:unparseable` clause is first because 2 dominates: a line that never
420
+ # reached the endpoint leaves the job unfinished, and reporting that as
421
+ # "your content was refused" is the exact confusion the contract exists to
422
+ # prevent.
423
+ def exit_code(results)
424
+ return EXIT_MISUSE if results.any? { |result| %i[undelivered unparseable].include?(result.status) }
425
+ return EXIT_REFUSED if results.any? { |result| result.status == :refused }
426
+
427
+ EXIT_OK
428
+ end
429
+
430
+ # `--list`: read the file, print what is in it, deliver nothing.
431
+ #
432
+ # Note what this does *not* do — it does not call {#exit_code}. Listing
433
+ # makes no request, so no endpoint has read anything and no verdict about
434
+ # anyone's run exists; routing it through the shared code would put a
435
+ # second producer behind {EXIT_REFUSED} and cost exit 1 the single meaning
436
+ # the class comment is built around. The two codes reachable from here are
437
+ # 0 (listed) and, via the {UsageError} {#read_source} raises, 2.
438
+ #
439
+ # It reuses {#read_source} rather than reading the file itself, which is
440
+ # what makes the numbers here the same numbers `--from-line` and `--lines`
441
+ # take, what makes a listing under either flag preview exactly the set a
442
+ # delivery would send, and what makes an invalid-UTF-8 line arrive as a
443
+ # line to be named rather than an exception.
444
+ #
445
+ # Under `--json` the rows become one document and the warning stays where
446
+ # it is. An empty listing still writes the document — the file was read,
447
+ # and `"lines": []` over a summary of zeroes is a true statement about it —
448
+ # whereas a file that could not be read raises out of {#read_source}
449
+ # before there is anything to be a document about.
450
+ def list(options)
451
+ source = read_source(options)
452
+ lines = source.lines.map { |number, text| listed_line(number, text) }
453
+
454
+ if lines.empty?
455
+ @stderr.puts "specguard-ingest: warning: #{source.path} holds no runs to list#{empty_detail(source)}"
456
+ return EXIT_OK unless options.json
457
+ end
458
+
459
+ if options.json
460
+ @stdout.puts IngestReporter.render_listing(source: source, lines: lines, counts: listed_counts(lines))
461
+ return EXIT_OK
462
+ end
463
+
464
+ lines.each { |line| @stdout.puts list_row(line) }
465
+ @stdout.puts list_summary(source)
466
+ EXIT_OK
467
+ end
468
+
469
+ # One line of the file, as facts rather than as a judgement. An
470
+ # unparseable line is listed *as unparseable* — the same discipline
471
+ # {#deliver_line} applies, for the same reason: a line silently dropped
472
+ # from a preview is a preview that under-reports what the delivery would
473
+ # do.
474
+ #
475
+ # The envelope is free-form, so every field goes through {#scalar} for the
476
+ # reason {#deliver_line} reports `ci_run_id` that way: rendering
477
+ # `{"a"=>1}` as a branch would be this tool inventing structure the line
478
+ # does not have. `0 examples` and `no specs` are likewise different facts —
479
+ # an empty list is a run that carried none, a missing or non-Array `specs`
480
+ # is a line that does not say — so the count is `nil` rather than 0 for the
481
+ # second, and both renderers inherit that distinction rather than each
482
+ # making it.
483
+ def listed_line(number, text)
484
+ payload, problem = parse_payload(text)
485
+ return ListedLine.new(number: number, problem: problem) if payload.nil?
486
+
487
+ specs = payload["specs"]
488
+ duration = payload["duration_seconds"]
489
+
490
+ ListedLine.new(number: number,
491
+ branch: scalar(payload["branch"]),
492
+ commit_sha: scalar(payload["commit_sha"]),
493
+ ci_run_id: scalar(payload["ci_run_id"]),
494
+ examples: specs.is_a?(Array) ? specs.length : nil,
495
+ duration_seconds: duration.is_a?(Numeric) ? duration : nil)
496
+ end
497
+
498
+ # The row, off the facts above and nothing else — which is what makes the
499
+ # document and the listing two renderings of one reading of the line.
500
+ def list_row(line)
501
+ return "line #{line.number}: #{STATUS_LABELS.fetch(:unparseable)} — #{line.problem}" if line.problem
502
+
503
+ "line #{line.number}: #{listed_fields(line).join(', ')}"
504
+ end
505
+
506
+ # `ci_run_id` is the decision-relevant one — a line without it has nothing
507
+ # for `RunRecorder` to fold onto and becomes a second run — so its absence
508
+ # is stated rather than left as a gap in the row.
509
+ def listed_fields(line)
510
+ [named("branch", line.branch),
511
+ named("commit_sha", line.commit_sha),
512
+ named("ci_run_id", line.ci_run_id),
513
+ line.examples ? "#{line.examples} example#{'s' unless line.examples == 1}" : "no specs",
514
+ line.duration_seconds ? "#{line.duration_seconds}s" : "no duration_seconds"]
515
+ end
516
+
517
+ def named(name, value)
518
+ value ? "#{name} #{value}" : "no #{name}"
519
+ end
520
+
521
+ # Says what was listed and, last and unconditionally, that nothing left
522
+ # the machine — the one thing a reader of a preview must not have to infer
523
+ # from the absence of a delivery report.
524
+ def list_summary(source)
525
+ parts = ["specguard-ingest: listed #{source.lines.length} line#{'s' unless source.lines.length == 1} " \
526
+ "from #{source.path}"]
527
+
528
+ parts << blank_clause(source) if source.blank.positive?
529
+ parts << skipped_clause(source) if source.skipped.positive?
530
+ parts << "nothing was delivered"
531
+
532
+ parts.join("; ")
533
+ end
534
+
535
+ # One line, one attempt, one result — and no retry loop.
536
+ #
537
+ # The gem's no-retry decision is about in-run cost to CI wall clock, and
538
+ # this tool runs out of band where that argument does not apply. It still
539
+ # does not retry: the line is on disk and re-running the command is the
540
+ # retry, made by someone who can see why the first attempt failed rather
541
+ # than by a loop that cannot.
542
+ def deliver_line(number, text, transport)
543
+ payload, problem = parse_payload(text)
544
+ if payload.nil?
545
+ return LineResult.new(number: number, status: :unparseable, detail: problem, reasons: [problem])
546
+ end
547
+
548
+ ci_run_id = scalar(payload["ci_run_id"])
549
+ result = transport.deliver(payload)
550
+
551
+ case result.outcome
552
+ when :success
553
+ LineResult.new(number: number, status: :accepted, detail: "HTTP #{result.code}", code: result.code,
554
+ reasons: [], test_run_id: result.test_run_id, ci_run_id: ci_run_id)
555
+ when :rejected
556
+ # A non-2xx is not automatically a verdict. {CONTENT_REFUSAL_CODES}
557
+ # names the ones the platform actually forms an opinion in; the rest
558
+ # arrived, stored nothing, and said nothing about this run.
559
+ status = CONTENT_REFUSAL_CODES.include?(result.code) ? :refused : :undelivered
560
+ # `result.reasons` verbatim — the whole array, not the three
561
+ # `result.reason` flattens it to. A body that said nothing readable
562
+ # leaves it nil, which {IngestReporter} renders as `[]`.
563
+ LineResult.new(number: number, status: status, detail: result.reason, code: result.code,
564
+ reasons: result.reasons, ci_run_id: ci_run_id)
565
+ else
566
+ # No code, because no answer: the exception's rendering is the whole of
567
+ # what there is to say, and it is the only thing `reasons` can carry.
568
+ LineResult.new(number: number, status: :undelivered, detail: result.reason,
569
+ reasons: [result.reason], ci_run_id: ci_run_id)
570
+ end
571
+ end
572
+
573
+ # `[payload, nil]`, or `[nil, problem]` naming why the line is not a run.
574
+ # A pair rather than one value of two types, because `JSON.parse` answers
575
+ # a bare `"text"` line with a String and a sentinel String would then be
576
+ # indistinguishable from a payload — and rather than an exception, because
577
+ # an unparseable line is a per-line result like any other: one corrupt
578
+ # line (a sink truncated by a killed process, most likely) must not stop
579
+ # the other thirty-nine from being delivered.
580
+ def parse_payload(text)
581
+ # Before `JSON.parse`, which raises an encoding error rather than a
582
+ # `JSON::ParserError` for these — and whose message would carry the
583
+ # offending bytes into a `String#strip` that raises in turn.
584
+ return [nil, "the line is not valid UTF-8, so it cannot be a run"] unless text.valid_encoding?
585
+
586
+ parsed = JSON.parse(text)
587
+ return [parsed, nil] if parsed.is_a?(Hash)
588
+
589
+ [nil, "the line is #{parsed.class} JSON, and a run is an object"]
590
+ rescue JSON::ParserError => e
591
+ [nil, "could not parse the line as JSON: #{e.message.lines.first.to_s.strip}"]
592
+ end
593
+
594
+ def build_transport
595
+ configuration = Configuration.new(env: @env)
596
+
597
+ # Both named, and named separately. They fail for different reasons and
598
+ # are fixed in different places, and "delivery is not configured" would
599
+ # leave an operator who set one of the two guessing which.
600
+ raise UsageError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if blank?(configuration.endpoint)
601
+ raise UsageError, "no API key is configured (set SPECGUARD_API_KEY)" if blank?(configuration.api_key)
602
+
603
+ transport = Transport.new(endpoint: configuration.endpoint, api_key: configuration.api_key,
604
+ timeout: configuration.timeout)
605
+ # Asked once, here, so a malformed `SPECGUARD_ENDPOINT` is one exit 2
606
+ # rather than N identical `:failed` lines. `Transport#deliver` would
607
+ # otherwise swallow the same `ArgumentError` once per line and report a
608
+ # delivery problem for what is a configuration problem.
609
+ validate_endpoint(transport)
610
+
611
+ transport
612
+ end
613
+
614
+ def validate_endpoint(transport)
615
+ transport.uri
616
+ rescue ArgumentError => e
617
+ raise UsageError, e.message
618
+ end
619
+
620
+ # @param options [Options] which lines of which file. Whichever selector
621
+ # was given, everything it does not name is counted and held back, never
622
+ # renumbered — the whole value of resuming from a report is that line 12
623
+ # is still line 12.
624
+ # @raise [UsageError] for every way a file can refuse to be read. All of
625
+ # them are exit 2 — the tool was pointed at something it cannot work
626
+ # from, which is not a verdict about anybody's run.
627
+ def read_source(options)
628
+ path = options.path
629
+ raise UsageError, "no such file: #{path}" unless File.exist?(path)
630
+ raise UsageError, "not a file: #{path}" unless File.file?(path)
631
+
632
+ lines = []
633
+ blank = 0
634
+ skipped = 0
635
+
636
+ # Numbered from the file, not from the payloads: a blank line still
637
+ # advances the count, so line 12 in this report is line 12 in an editor.
638
+ #
639
+ # The selector is asked FIRST, in the one branch position the suffix
640
+ # test used to hold, so both narrowings inherit the numbering, the blank
641
+ # counting and the invalid-UTF-8 handling below unchanged.
642
+ #
643
+ # `strip` RAISES on a line that is not valid UTF-8 — pointing this
644
+ # command at a binary file by mistake is the obvious way to get one —
645
+ # and such a line is not blank, it is a line that cannot be a run. It is
646
+ # kept, so {#parse_payload} says exactly that about it and the rest of
647
+ # the file still delivers. Swallowing it here would lose a line silently
648
+ # and letting it raise would report a bug in this tool.
649
+ File.foreach(path).with_index(1) do |text, number|
650
+ if held_back?(number, options)
651
+ skipped += 1
652
+ elsif text.valid_encoding? && text.strip.empty?
653
+ blank += 1
654
+ else
655
+ lines << [number, text]
656
+ end
657
+ end
658
+
659
+ Source.new(path: path, lines: lines, blank: blank, skipped: skipped,
660
+ selector: options.line_set ? :line_set : :from_line)
661
+ rescue SystemCallError, IOError => e
662
+ raise UsageError, "could not read #{path}: #{e.message}"
663
+ end
664
+
665
+ # The whole of the selection, and the only place it is decided. `--lines`
666
+ # is an explicit set, so a line it does not name is held back wherever in
667
+ # the file it sits; `--from-line` is a suffix, so only the lines before it
668
+ # are. The two never both apply — {#parse_options} refuses the pair.
669
+ def held_back?(number, options)
670
+ return !options.line_set.any? { |range| range.cover?(number) } if options.line_set
671
+
672
+ number < options.from_line
673
+ end
674
+
675
+ # The per-line report on stdout — it is the product — with the diagnostics
676
+ # about this tool's own situation on stderr, which is the split
677
+ # `specguard-lint` already makes. `--json` moves the product and leaves the
678
+ # diagnostics: a run that delivered nothing is still loud on stderr, in
679
+ # both renderers, because the warning is a statement about this tool's
680
+ # situation and not a result.
681
+ #
682
+ # == Two renderers, one set of facts
683
+ #
684
+ # The counts and the folding groups are computed ONCE here and handed to
685
+ # whichever renderer runs. Both the text summary line and the document's
686
+ # `summary` are statements about the same numbers, and a command whose two
687
+ # renderers can disagree about how much of a file it delivered is worse
688
+ # than one that only prints prose: the disagreement is unfalsifiable from
689
+ # outside the process.
690
+ def report(source, results, json:)
691
+ if results.empty?
692
+ @stderr.puts "specguard-ingest: warning: #{source.path} holds no runs to deliver#{empty_detail(source)}"
693
+ return unless json
694
+ end
695
+
696
+ counts = status_counts(results)
697
+ foldings = folded_runs(results)
698
+
699
+ if json
700
+ @stdout.puts IngestReporter.render_delivery(source: source, results: results, counts: counts,
701
+ foldings: foldings)
702
+ return
703
+ end
704
+
705
+ results.each { |result| @stdout.puts line_report(result) }
706
+ @stdout.puts summary_line(source, results, counts)
707
+ foldings.each { |folding| @stdout.puts folding_observation(folding) }
708
+ end
709
+
710
+ def status_counts(results)
711
+ results.group_by(&:status).transform_values(&:length)
712
+ end
713
+
714
+ # The listing's counterpart to {#status_counts}. A preview delivers
715
+ # nothing, so `unparseable` is the only status a listed line can hold —
716
+ # but it is counted *here*, next to the delivery path's counts, rather
717
+ # than inside the renderer: {IngestReporter} states that its counts are
718
+ # handed in, and a count computed in the one place that promises not to
719
+ # compute them is the invariant holding by luck instead of by structure.
720
+ def listed_counts(lines)
721
+ { unparseable: lines.count(&:problem) }
722
+ end
723
+
724
+ # Why there was nothing to do, when there is a reason other than "the file
725
+ # is empty". A `--from-line` past the end of the file, a `--lines` that
726
+ # names only lines the file does not have, and a genuinely empty file are
727
+ # the same silence otherwise, and only two of them are the user's mistake.
728
+ def empty_detail(source)
729
+ parts = []
730
+ parts << "#{source.blank} blank line#{'s' unless source.blank == 1}" if source.blank.positive?
731
+ parts << skipped_clause(source) if source.skipped.positive?
732
+
733
+ parts.empty? ? "" : " (#{parts.join('; ')})"
734
+ end
735
+
736
+ # Named for the flag that actually held them back, and worded for what
737
+ # that flag does. `--from-line` holds back a prefix, so those lines are
738
+ # "earlier"; `--lines` holds back whatever it did not name, which can sit
739
+ # anywhere in the file, so calling them earlier would be a lie in the
740
+ # common case.
741
+ def skipped_clause(source)
742
+ count = source.skipped
743
+ return "#{count} line#{'s' unless count == 1} not selected by --lines" if source.selector == :line_set
744
+
745
+ "#{count} earlier line#{'s' unless count == 1} skipped by --from-line"
746
+ end
747
+
748
+ def blank_clause(source)
749
+ "#{source.blank} blank line#{'s' unless source.blank == 1} skipped"
750
+ end
751
+
752
+ def line_report(result)
753
+ detail = [result.detail, identity(result)].compact.join(", ")
754
+ line = "line #{result.number}: #{STATUS_LABELS.fetch(result.status)}"
755
+ detail.empty? ? line : "#{line} — #{detail}"
756
+ end
757
+
758
+ # The two facts criterion 4 can be stated from: what the endpoint said the
759
+ # run's id is, and whether the line carried a run identity of its own.
760
+ #
761
+ # A line with no `ci_run_id` is reported as having none and nothing more.
762
+ # The platform records such a run as its own row, but that happens inside
763
+ # `RunRecorder` where this tool cannot see it, and a claim it cannot
764
+ # observe is a claim it does not make.
765
+ def identity(result)
766
+ return nil unless result.status == :accepted
767
+
768
+ ["test_run_id #{result.test_run_id || '(not reported)'}",
769
+ result.ci_run_id ? "ci_run_id #{result.ci_run_id}" : "no ci_run_id"].join(", ")
770
+ end
771
+
772
+ # States how much of the file reached the endpoint, always, and in a form
773
+ # that cannot read as "all clean" when it was "nothing sent": the
774
+ # accepted count is over the total rather than on its own, and every other
775
+ # outcome gets a clause of its own instead of being folded into a
776
+ # remainder the reader has to compute.
777
+ def summary_line(source, results, counts)
778
+ parts = ["specguard-ingest: delivered #{counts.fetch(:accepted, 0)} of " \
779
+ "#{results.length} run#{'s' unless results.length == 1} from #{source.path}"]
780
+
781
+ parts << "#{counts[:refused]} refused" if counts[:refused]
782
+ parts << "#{counts[:undelivered]} could not be delivered" if counts[:undelivered]
783
+ parts << "#{counts[:unparseable]} could not be parsed" if counts[:unparseable]
784
+ parts << blank_clause(source) if source.blank.positive?
785
+ parts << skipped_clause(source) if source.skipped.positive?
786
+
787
+ parts.join("; ")
788
+ end
789
+
790
+ # Folding, stated only where it was *seen*.
791
+ #
792
+ # The proposal for this tool asked it to say whether a replayed line
793
+ # folded into an existing run or created a new one, and the 202 body does
794
+ # not carry that flag. What it does carry is the run's id, and two lines
795
+ # that went out with the same `ci_run_id` and came back with the same
796
+ # `test_run_id` are two deliveries onto one row — which is not an
797
+ # inference about folding, it is folding, observed. Anything short of two
798
+ # such lines gets no {Folding} at all rather than a hedged one.
799
+ #
800
+ # Grouped here and rendered by {#folding_observation} or by
801
+ # {IngestReporter}, rather than grouped by each of them: one observation
802
+ # rendered twice cannot disagree with itself.
803
+ #
804
+ # @return [Array<Folding>]
805
+ def folded_runs(results)
806
+ results
807
+ .select { |result| result.status == :accepted && result.ci_run_id && result.test_run_id }
808
+ .group_by { |result| [result.ci_run_id, result.test_run_id] }
809
+ .filter_map do |(ci_run_id, test_run_id), group|
810
+ next if group.length < 2
811
+
812
+ Folding.new(ci_run_id: ci_run_id, test_run_id: test_run_id, numbers: group.map(&:number))
813
+ end
814
+ end
815
+
816
+ def folding_observation(folding)
817
+ "specguard-ingest: lines #{folding.numbers.join(', ')} carried ci_run_id #{folding.ci_run_id} and each " \
818
+ "came back with test_run_id #{folding.test_run_id} — the endpoint folded them onto one run"
819
+ end
820
+
821
+ # @return [Options, nil] `nil` when `--help` or `--version` has already
822
+ # said everything the invocation was asking for.
823
+ def parse_options(argv)
824
+ from_line = nil
825
+ line_set = nil
826
+ list = false
827
+ json = false
828
+
829
+ parser = OptionParser.new do |o|
830
+ o.banner = BANNER
831
+ o.separator ""
832
+ o.separator DESCRIPTION
833
+ o.separator ""
834
+ o.separator "Options:"
835
+ # Deliberately alongside the selectors rather than instead of them:
836
+ # they compose, so the set you list is the set you are about to send.
837
+ o.on("--list", "List the runs in <file> without delivering any of them") do
838
+ list = true
839
+ end
840
+ # `Integer` rather than a String and a `to_i`: `--from-line twelve`
841
+ # would otherwise become 0 and silently deliver the whole file, which
842
+ # is the one outcome a resume flag exists to prevent. OptionParser
843
+ # raises `InvalidArgument` instead, and that is a 2 like every other
844
+ # misuse.
845
+ o.on("--from-line N", Integer, "Start at line N of <file>, skipping the lines before it") do |value|
846
+ raise UsageError, "--from-line must be 1 or greater, got #{value}" if value < 1
847
+
848
+ from_line = value
849
+ end
850
+ # No coercion to lean on for this one — and deliberately not
851
+ # OptionParser's `String`, whose acceptor rejects an empty argument
852
+ # with a message about the flag rather than about the spec. So the
853
+ # raw value comes through and {#parse_line_set} does the same job by
854
+ # hand and to the same standard: every way of mistyping a spec is a
855
+ # UsageError that names what was wrong with it, and none of them falls
856
+ # back to the whole file.
857
+ o.on("--lines SPEC",
858
+ "Deliver only the lines SPEC names — numbers and ranges over <file>'s",
859
+ "own numbering, e.g. 3,7,12-15. Not combinable with --from-line") do |value|
860
+ line_set = parse_line_set(value)
861
+ end
862
+ # Composes with everything above it: it chooses the renderer, never
863
+ # the set that is listed or sent and never the code that is returned.
864
+ o.on("--json", "Emit one JSON document on stdout instead of the human report") do
865
+ json = true
866
+ end
867
+ o.on("-v", "--version", "Print the version and exit") do
868
+ @stdout.puts "specguard-ruby #{VERSION}"
869
+ return nil
870
+ end
871
+ o.on("-h", "--help", "Print this help and exit") do
872
+ @stdout.puts o
873
+ return nil
874
+ end
875
+ end
876
+
877
+ files = parser.parse(argv)
878
+ raise UsageError, "no file given — #{BANNER}" if files.empty?
879
+ raise UsageError, "one file at a time, got #{files.length}: #{files.join(', ')}" if files.length > 1
880
+
881
+ # Refused rather than intersected. Both answer "which lines", and an
882
+ # intersection would drop a number the user typed without saying so —
883
+ # see the class comment.
884
+ if from_line && line_set
885
+ raise UsageError, "--from-line and --lines both choose which lines to send; give one or the other"
886
+ end
887
+
888
+ Options.new(path: files.first, from_line: from_line || 1, list: list, line_set: line_set, json: json)
889
+ rescue OptionParser::ParseError => e
890
+ # Uncaught, this is the likeliest way a user sees a false "the endpoint
891
+ # refused your run": OptionParser raises and Ruby exits 1. Retyping it
892
+ # is what makes a typo'd flag a 2.
893
+ raise UsageError, e.message
894
+ end
895
+
896
+ # `3,7,12-15` → `[3..3, 7..7, 12..15]`, or {UsageError} — never a set that
897
+ # is "close to" what was typed. A selector that half-understood its
898
+ # argument would deliver the wrong runs, which is worse than not running
899
+ # at all, so every entry must match {LINE_SPEC_ENTRY} whole.
900
+ #
901
+ # Ranges are kept as Ranges rather than expanded: membership is
902
+ # `Range#cover?` over a handful of entries, so a fat-fingered
903
+ # `--lines 1-90000000` is refused by the file's length rather than by
904
+ # running the machine out of memory first.
905
+ #
906
+ # @return [Array<Range>] in the order typed, which does not matter —
907
+ # delivery order is the file's, always.
908
+ def parse_line_set(spec)
909
+ entries = spec.split(",", -1).map(&:strip)
910
+ raise UsageError, "--lines needs at least one line number, got #{spec.inspect}" if entries.empty?
911
+
912
+ entries.map { |entry| parse_line_spec_entry(entry, spec) }
913
+ end
914
+
915
+ def parse_line_spec_entry(entry, spec)
916
+ raise UsageError, "--lines has an empty entry in #{spec.inspect}" if entry.empty?
917
+
918
+ match = LINE_SPEC_ENTRY.match(entry)
919
+ raise UsageError, "--lines: #{entry.inspect} is not a line number or a N-M range" if match.nil?
920
+
921
+ first = Integer(match[1], 10)
922
+ last = match[2] ? Integer(match[2], 10) : first
923
+ raise UsageError, "--lines: line numbers start at 1, got #{entry.inspect}" if first < 1
924
+ raise UsageError, "--lines: #{entry.inspect} ends before it starts" if last < first
925
+
926
+ first..last
927
+ end
928
+
929
+ def blank?(value)
930
+ value.nil? || value.to_s.strip.empty?
931
+ end
932
+
933
+ # A run identity is whatever the CI provider exported, passed through by
934
+ # `Configuration` as a free-form string. Anything that is not a scalar is
935
+ # not one, and reporting `{"a"=>1}` as a `ci_run_id` would be this tool
936
+ # inventing structure the envelope does not have.
937
+ def scalar(value)
938
+ value.is_a?(String) || value.is_a?(Numeric) ? value.to_s : nil
939
+ end
940
+ end
941
+ end
942
+ end