browser_review_gate 0.2.1 → 0.3.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.
@@ -1,14 +1,26 @@
1
1
  require "digest"
2
2
  require "json"
3
+ require "time"
3
4
 
4
5
  module BrowserReviewGate
5
- # The browser cases an agent actually ran, with their outcomes. Published as one PR comment.
6
+ # The browser cases an agent ran, with their outcomes and evidence. Published as one PR comment.
7
+ #
8
+ # Everything in a report is the agent's account except the file digests, which the publisher
9
+ # computed from files it read. The report is validated strictly: an unknown key, a malformed value
10
+ # or a missing field makes it invalid, and an invalid report counts for nothing.
6
11
  class Report
7
12
  MARKER = /<!--\s*browser-verification-report:([A-Za-z0-9_-]+)\s*-->/
8
- SHA_PATTERN = /\A\h{7,40}\z/
13
+ SCHEMA_VERSION = 2
14
+ FULL_SHA = /\A\h{40}\z/
9
15
  CASE_ID_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\z/
10
- RESULTS = %w[pass fail].freeze
16
+ RESULTS = %w[pass fail skipped blocked].freeze
17
+ CAPABILITIES = %w[screenshots trace console network].freeze
11
18
  URL_PATTERN = %r{\A(?:https?://|/)\S*\z}
19
+ TOP_KEYS = %w[schema_version tested_sha verified_by environment tool started_at finished_at coverage_complete
20
+ cases excluded runs failed_runs].freeze
21
+ CASE_KEYS = %w[id name result url details reason evidence tested_sha verified_by environment tool].freeze
22
+ EVIDENCE_KEYS = %w[files console_errors network_errors links].freeze
23
+ FILE_KEYS = %w[kind name media_type bytes sha256].freeze
12
24
 
13
25
  attr_reader :errors
14
26
 
@@ -24,34 +36,29 @@ module BrowserReviewGate
24
36
  end
25
37
 
26
38
  # The newest report on the PR when it is well formed, or nil.
27
- def self.latest(comments)
39
+ def self.latest(comments, config: Config.new)
28
40
  comment = latest_comment(comments)
29
- report = comment && new(data_from(comment["body"]))
41
+ report = comment && new(data_from(comment["body"]), config: config)
30
42
  report if report&.valid?
31
43
  rescue Error
32
44
  nil
33
45
  end
34
46
 
35
- # The newest report on the PR when it passes, or nil.
36
- def self.latest_passing(comments)
37
- report = latest(comments)
38
- report if report&.passed?
39
- end
40
-
41
47
  # Later results replace earlier ones case by case; cases that were not run again are kept. The
42
48
  # merged report counts the runs published on the PR and how many of them had a failing case.
43
- def self.merge(previous_data, latest_data)
44
- previous = new(previous_data)
45
- latest = new(latest_data)
49
+ def self.merge(previous_data, latest_data, config: Config.new)
50
+ previous = new(previous_data, config: config)
51
+ latest = new(latest_data, config: config)
46
52
  return latest unless latest.valid?
47
53
 
48
54
  combined = previous.valid? ? previous.cases.to_h { |test_case| [ test_case["id"], test_case ] } : {}
49
55
  latest.cases.each { |test_case| combined[test_case["id"]] = test_case }
50
- failed = latest.cases.all? { |test_case| test_case["result"] == "pass" } ? 0 : 1
51
- new(latest.to_h.merge("cases" => combined.values, "runs" => previous.runs + 1, "failed_runs" => previous.failed_runs + failed))
56
+ failed = latest.cases.any? { |test_case| test_case["result"] == "fail" } ? 1 : 0
57
+ new(latest.to_h.merge("cases" => combined.values, "runs" => previous.runs + 1, "failed_runs" => previous.failed_runs + failed), config: config)
52
58
  end
53
59
 
54
- def initialize(data)
60
+ def initialize(data, config: Config.new)
61
+ @config = config
55
62
  @errors = []
56
63
  @data = normalize(data.is_a?(Hash) ? data : {})
57
64
  attach_defaults_to_cases
@@ -61,49 +68,139 @@ module BrowserReviewGate
61
68
  def valid? = errors.empty?
62
69
  def tested_sha = @data["tested_sha"]
63
70
  def verified_by = @data["verified_by"]
71
+ def environment = @data["environment"]
72
+ def tool = @data["tool"].is_a?(Hash) ? @data["tool"] : {}
64
73
  def cases = @data["cases"].is_a?(Array) ? @data["cases"] : []
65
74
  def excluded = @data["excluded"].is_a?(Array) ? @data["excluded"] : []
66
75
  def runs = @data["runs"].is_a?(Integer) ? @data["runs"] : 0
67
76
  def failed_runs = @data["failed_runs"].is_a?(Integer) ? @data["failed_runs"] : 0
77
+ def coverage_complete? = @data["coverage_complete"] == true
68
78
  def to_h = JSON.parse(JSON.generate(@data))
69
- def passed_ids = cases.select { |test_case| test_case["result"] == "pass" }.map { |test_case| test_case["id"] }
79
+
80
+ # Seconds the run took, when the agent gave both times.
81
+ def duration
82
+ return unless @data["started_at"] && @data["finished_at"]
83
+
84
+ Time.iso8601(@data["finished_at"]) - Time.iso8601(@data["started_at"])
85
+ end
86
+
87
+ # Ids of the cases that count as passed under the project's policy, limited to cases tested on
88
+ # `commits` when that list is given (the commits of the PR: a case from elsewhere proves nothing).
89
+ def passed_ids(commits: nil)
90
+ cases.select { |test_case| counts?(test_case) && (commits.nil? || commits.include?(test_case["tested_sha"])) }
91
+ .map { |test_case| test_case["id"] }
92
+ end
93
+
94
+ # passed, failed, skipped, blocked, or missing when no case has the id. A case that says pass but
95
+ # breaks a policy (console errors, missing evidence) is failed.
96
+ def status_of(id, commits: nil)
97
+ test_case = cases.find { |entry| entry["id"] == id }
98
+ return "missing" unless test_case
99
+ return "stale" if commits && !commits.include?(test_case["tested_sha"])
100
+ return "passed" if counts?(test_case)
101
+
102
+ test_case["result"] == "pass" ? "failed" : { "fail" => "failed" }.fetch(test_case["result"], test_case["result"])
103
+ end
104
+
105
+ # Why a case that says pass does not count, or nil.
106
+ def policy_problem(test_case)
107
+ evidence = test_case["evidence"].is_a?(Hash) ? test_case["evidence"] : {}
108
+ if @config.verification["fail_on_console_errors"] && Array(evidence["console_errors"]).any?
109
+ return "console errors were reported"
110
+ end
111
+ if @config.verification["fail_on_network_errors"] && Array(evidence["network_errors"]).any?
112
+ return "failed network requests were reported"
113
+ end
114
+
115
+ missing_evidence(test_case, evidence)
116
+ end
117
+
118
+ def failed? = cases.any? { |test_case| status_of(test_case["id"]) == "failed" }
119
+
120
+ # Complete, and nothing failed when failures block review.
121
+ def passed?
122
+ valid? && coverage_complete? && (!@config.gate["block_on_failure"] || !failed?)
123
+ end
70
124
 
71
125
  # Changes whenever a case is added, re-run or changes its outcome.
72
126
  def fingerprint
73
127
  Digest::SHA256.hexdigest(JSON.generate(cases.map { |test_case| test_case.values_at("id", "result", "tested_sha") }))[0, 16]
74
128
  end
75
129
 
76
- # What a newly submitted run must say about each case: where it was exercised and what was seen.
77
- # Cases carried over from reports published before this was required are not checked.
78
- def evidence_errors
130
+ # What a newly submitted run must say beyond the schema: where each case ran and what was seen,
131
+ # and the evidence the project requires.
132
+ def submission_errors
79
133
  cases.each_with_index.flat_map do |test_case, index|
80
- next [] unless test_case.is_a?(Hash)
81
-
134
+ position = index + 1
82
135
  found = []
83
- found << "case #{index + 1} url must be the page that was exercised: an http(s) URL or a path starting with /" unless url?(test_case["url"])
84
- found << "case #{index + 1} details must say what was observed" unless text?(test_case["details"], 500)
136
+ if %w[pass fail].include?(test_case["result"])
137
+ found << "case #{position} url must be the page that was exercised: an http(s) URL or a path starting with /" unless url?(test_case["url"])
138
+ else
139
+ found << "case #{position} is #{test_case["result"]} and needs a reason" unless text?(test_case["reason"], 300)
140
+ end
141
+ found << "case #{position} details must say what was observed" unless text?(test_case["details"], 500)
142
+ problem = test_case["result"] == "pass" && @config.gate["require_evidence"] ? missing_evidence(test_case, test_case["evidence"] || {}) : nil
143
+ found << "case #{position} #{problem}" if problem
85
144
  found
86
145
  end
87
146
  end
88
147
 
89
- def passed?
90
- valid? && @data["coverage_complete"] && cases.all? { |test_case| test_case["result"] == "pass" }
91
- end
92
-
93
- def markdown
148
+ def markdown(scenarios: nil, commits: nil)
94
149
  return "Browser verification report is invalid: #{errors.map { |error| Markdown.escape(error) }.join("; ")}" unless valid?
95
150
 
96
- coverage = @data["coverage_complete"] ? "Complete scenario run." : "Scenario run is incomplete."
151
+ coverage = coverage_complete? ? "Complete scenario run." : "Scenario run is incomplete."
152
+ tool_name = [ tool["name"], tool["version"] ].compact.map { |part| Markdown.escape(part) }.join(" ")
97
153
  lines = [ "## Browser verification", "",
98
- "Verified by **#{Markdown.escape(verified_by)}** for commit `#{tested_sha}`.", coverage, "" ]
99
- lines.concat(cases.map { |test_case| markdown_case(test_case) })
154
+ "Verified by **#{Markdown.escape(verified_by)}** for commit `#{tested_sha}` in the `#{environment}` environment " \
155
+ "with #{tool_name}#{timing}. #{coverage}", "",
156
+ "| Case | Result | Page | Commit | Evidence |", "|---|---|---|---|---|" ]
157
+ lines.concat(cases.map { |test_case| markdown_case(test_case, commits) })
158
+ details = cases.select { |test_case| test_case["details"] || test_case["reason"] }
159
+ lines.concat([ "", "<details><summary>What the agent observed</summary>", "" ] +
160
+ details.map { |test_case| "- `#{test_case["id"]}`: #{Markdown.escape([ test_case["details"], test_case["reason"] ].compact.join(" "))}" } +
161
+ [ "", "</details>" ]) if details.any?
100
162
  lines.concat([ "", "Not exercised in the browser, by the author's decision:" ] + excluded.map { |entry| markdown_excluded(entry) }) if excluded.any?
163
+ lines.concat([ "", "Observations, pages and errors are reported by the agent. File digests were computed by the publisher " \
164
+ "from files it read; they show the files existed, not that a browser made them." ])
101
165
  payload = Marker.encode(JSON.generate(to_h))
102
166
  lines.concat([ "", "<!-- browser-verification-report:#{payload} -->" ]).join("\n")
103
167
  end
104
168
 
105
169
  private
106
170
 
171
+ def counts?(test_case)
172
+ test_case["result"] == "pass" && policy_problem(test_case).nil?
173
+ end
174
+
175
+ # The evidence the project asks for and the tool could produce. A tool that does not declare a
176
+ # capability is not asked for it; that gap is visible in the PR.
177
+ def missing_evidence(test_case, evidence)
178
+ return unless @config.gate["require_evidence"]
179
+
180
+ capabilities = Array(tool_of(test_case)["capabilities"])
181
+ files = Array(evidence["files"])
182
+ kinds = { "screenshots" => "screenshot", "trace" => "trace" }
183
+ kinds.each do |setting, kind|
184
+ mode = @config.evidence[setting]
185
+ wanted = mode == "always" || (mode == "on_failure" && test_case["result"] == "fail")
186
+ next unless wanted && capabilities.include?(setting)
187
+ return "has no #{kind} although evidence.#{setting} is #{mode}" if files.none? { |file| file["kind"] == kind }
188
+ end
189
+ { "console_logs" => [ "console", "console_errors" ], "network_logs" => [ "network", "network_errors" ] }.each do |setting, (capability, key)|
190
+ next unless @config.evidence[setting] && capabilities.include?(capability)
191
+ return "does not say whether there were #{key.tr("_", " ")} (evidence.#{key})" unless evidence.key?(key)
192
+ end
193
+ nil
194
+ end
195
+
196
+ # A carried-over case keeps the tool of the run that produced it.
197
+ def tool_of(test_case) = test_case["tool"].is_a?(Hash) ? test_case["tool"] : tool
198
+
199
+ def timing
200
+ seconds = duration
201
+ seconds ? " in #{seconds.round} s" : ""
202
+ end
203
+
107
204
  def normalize(value)
108
205
  case value
109
206
  when Hash then value.to_h { |key, nested| [ key.to_s, normalize(nested) ] }
@@ -116,21 +213,56 @@ module BrowserReviewGate
116
213
  cases.each do |test_case|
117
214
  next unless test_case.is_a?(Hash)
118
215
 
119
- test_case["tested_sha"] ||= @data["tested_sha"]
120
- test_case["verified_by"] ||= @data["verified_by"]
216
+ %w[tested_sha verified_by environment tool].each { |key| test_case[key] ||= @data[key] }
121
217
  end
122
218
  end
123
219
 
124
220
  def validate
125
- errors << "tested_sha must be a 7–40 character hexadecimal commit SHA" unless sha?(@data["tested_sha"])
221
+ unknown = @data.keys - TOP_KEYS
222
+ errors << "unknown report keys: #{unknown.join(", ")}" if unknown.any?
223
+ errors << "schema_version must be #{SCHEMA_VERSION}" unless @data["schema_version"] == SCHEMA_VERSION
224
+ errors << "tested_sha must be a full 40-character commit SHA" unless sha?(@data["tested_sha"])
126
225
  errors << "verified_by must be a non-empty string of at most 100 characters" unless text?(@data["verified_by"], 100)
226
+ errors << "environment must name an environment from the settings" unless name?(@data["environment"])
127
227
  errors << "coverage_complete must be true or false" unless [ true, false ].include?(@data["coverage_complete"])
228
+ validate_tool
229
+ validate_times
128
230
  validate_cases
129
231
  validate_excluded
232
+ %w[runs failed_runs].each do |key|
233
+ errors << "#{key} must be a whole number" unless @data[key].nil? || (@data[key].is_a?(Integer) && @data[key] >= 0)
234
+ end
235
+ end
236
+
237
+ def validate_tool
238
+ tool_data = @data["tool"]
239
+ return errors << "tool must say which browser tool ran the cases: {\"name\": ..., \"capabilities\": [...]}" unless tool_data.is_a?(Hash)
240
+
241
+ unknown = tool_data.keys - %w[name version capabilities]
242
+ errors << "unknown tool keys: #{unknown.join(", ")}" if unknown.any?
243
+ errors << "tool name must be a non-empty string of at most 60 characters" unless text?(tool_data["name"], 60)
244
+ errors << "tool version must be a string of at most 40 characters" unless tool_data["version"].nil? || text?(tool_data["version"], 40)
245
+ capabilities = tool_data["capabilities"]
246
+ return if capabilities.is_a?(Array) && (capabilities - CAPABILITIES).empty? && capabilities.uniq.size == capabilities.size
247
+
248
+ errors << "tool capabilities must be a list drawn from #{CAPABILITIES.join(", ")}"
249
+ end
250
+
251
+ def validate_times
252
+ times = %w[started_at finished_at].to_h do |key|
253
+ [ key, Time.iso8601(@data[key].to_s) ]
254
+ rescue ArgumentError
255
+ errors << "#{key} must be an ISO 8601 time"
256
+ [ key, nil ]
257
+ end
258
+ return unless times.values.all?
259
+
260
+ errors << "finished_at must not be before started_at" if times["finished_at"] < times["started_at"]
130
261
  end
131
262
 
132
263
  def validate_cases
133
264
  return errors << "cases must be a non-empty array" unless @data["cases"].is_a?(Array) && cases.any?
265
+ return errors << "cases must have at most 60 entries" if cases.size > 60
134
266
 
135
267
  seen_ids = []
136
268
  cases.each_with_index do |test_case, index|
@@ -141,6 +273,8 @@ module BrowserReviewGate
141
273
  end
142
274
 
143
275
  def validate_case(test_case, position, seen_ids)
276
+ unknown = test_case.keys - CASE_KEYS
277
+ errors << "case #{position} has unknown keys: #{unknown.join(", ")}" if unknown.any?
144
278
  id = test_case["id"]
145
279
  if !(id.is_a?(String) && CASE_ID_PATTERN.match?(id))
146
280
  errors << "case #{position} id must use letters, numbers, dots, underscores, or hyphens"
@@ -150,12 +284,40 @@ module BrowserReviewGate
150
284
  seen_ids << id
151
285
  end
152
286
  errors << "case #{position} name must be a non-empty string of at most 200 characters" unless text?(test_case["name"], 200)
153
- errors << "case #{position} result must be pass or fail" unless RESULTS.include?(test_case["result"])
154
- errors << "case #{position} tested_sha must be a 7–40 character hexadecimal commit SHA" unless sha?(test_case["tested_sha"])
287
+ errors << "case #{position} result must be one of #{RESULTS.join(", ")}" unless RESULTS.include?(test_case["result"])
288
+ errors << "case #{position} tested_sha must be a full commit SHA" unless sha?(test_case["tested_sha"])
155
289
  errors << "case #{position} verified_by must be a non-empty string of at most 100 characters" unless text?(test_case["verified_by"], 100)
290
+ errors << "case #{position} environment must be a short lowercase name" unless name?(test_case["environment"])
156
291
  errors << "case #{position} url must be a string of at most 300 characters" unless test_case["url"].nil? || url?(test_case["url"])
157
- details = test_case["details"]
158
- errors << "case #{position} details must be a string of at most 500 characters" unless details.nil? || (details.is_a?(String) && details.length <= 500)
292
+ errors << "case #{position} details must be a string of at most 500 characters" unless test_case["details"].nil? || text?(test_case["details"], 500)
293
+ errors << "case #{position} reason must be a string of at most 300 characters" unless test_case["reason"].nil? || text?(test_case["reason"], 300)
294
+ errors << "case #{position} tool must name the browser tool" unless test_case["tool"].is_a?(Hash) && text?(test_case["tool"]["name"], 60)
295
+ validate_evidence(test_case["evidence"], position) unless test_case["evidence"].nil?
296
+ end
297
+
298
+ # The published form of evidence; the submitted form is turned into this by Evidence.
299
+ def validate_evidence(evidence, position)
300
+ return errors << "case #{position} evidence must be an object" unless evidence.is_a?(Hash)
301
+
302
+ unknown = evidence.keys - EVIDENCE_KEYS
303
+ errors << "case #{position} evidence has unknown keys: #{unknown.join(", ")}" if unknown.any?
304
+ files = evidence["files"]
305
+ unless files.nil? || (files.is_a?(Array) && files.size <= Evidence::MAX_FILES && files.all? { |file| file?(file) })
306
+ errors << "case #{position} evidence files are malformed"
307
+ end
308
+ links = Array(evidence["links"])
309
+ errors << "case #{position} evidence links must be https URLs" unless links.all? { |link| link.to_s.match?(%r{\Ahttps://[^\s()<>]+\z}) }
310
+ %w[console_errors network_errors links].each do |key|
311
+ value = evidence[key]
312
+ next if value.nil? || (value.is_a?(Array) && value.size <= Evidence::MAX_ENTRIES && value.all? { |entry| entry.is_a?(String) && entry.length <= Evidence::MAX_ENTRY })
313
+
314
+ errors << "case #{position} evidence #{key} must be a list of short strings"
315
+ end
316
+ end
317
+
318
+ def file?(file)
319
+ file.is_a?(Hash) && (file.keys - FILE_KEYS).empty? && Evidence::KINDS.value?(file["kind"]) && text?(file["name"], 100) &&
320
+ file["bytes"].is_a?(Integer) && file["sha256"].to_s.match?(/\A\h{64}\z/) && Evidence::TYPES.values.map(&:first).include?(file["media_type"])
159
321
  end
160
322
 
161
323
  # Scenarios the author decided not to run locally, e.g. a sign-in through an external provider.
@@ -164,21 +326,34 @@ module BrowserReviewGate
164
326
  return errors << "excluded must be an array" unless @data["excluded"].is_a?(Array)
165
327
 
166
328
  excluded.each_with_index do |entry, index|
167
- next if entry.is_a?(Hash) && text?(entry["name"], 200) && text?(entry["reason"], 500)
329
+ next if entry.is_a?(Hash) && (entry.keys - %w[id name reason]).empty? && text?(entry["name"], 200) && text?(entry["reason"], 500) &&
330
+ (entry["id"].nil? || CASE_ID_PATTERN.match?(entry["id"].to_s))
168
331
 
169
332
  errors << "excluded #{index + 1} needs a name and a reason"
170
333
  end
171
334
  end
172
335
 
173
- def sha?(value) = value.is_a?(String) && SHA_PATTERN.match?(value)
336
+ def sha?(value) = value.is_a?(String) && FULL_SHA.match?(value)
337
+ def name?(value) = value.is_a?(String) && Config::NAME_PATTERN.match?(value)
174
338
  def text?(value, limit) = value.is_a?(String) && !value.strip.empty? && value.length <= limit
175
339
  def url?(value) = value.is_a?(String) && value.length <= 300 && URL_PATTERN.match?(value)
176
340
 
177
- def markdown_case(test_case)
178
- detail = test_case["details"].to_s.empty? ? "" : ": #{Markdown.escape(test_case["details"])}"
179
- place = test_case["url"].to_s.empty? ? "" : " at #{Markdown.escape(test_case["url"])}"
180
- "- #{test_case["result"].upcase} — #{Markdown.escape(test_case["name"])}#{place} (`#{test_case["id"]}`, tested " \
181
- "`#{test_case["tested_sha"]}` by #{Markdown.escape(test_case["verified_by"])})#{detail}"
341
+ def markdown_case(test_case, commits)
342
+ status = status_of(test_case["id"], commits: commits)
343
+ problem = test_case["result"] == "pass" && status == "failed" ? " (#{policy_problem(test_case)})" : ""
344
+ page = test_case["url"].to_s.empty? ? "" : Markdown.escape(test_case["url"])
345
+ "| `#{test_case["id"]}` #{Markdown.escape(test_case["name"])} | #{status.upcase}#{Markdown.escape(problem)} | #{page} | " \
346
+ "`#{test_case["tested_sha"][0, 7]}` | #{markdown_evidence(test_case["evidence"])} |"
347
+ end
348
+
349
+ def markdown_evidence(evidence)
350
+ return "none" unless evidence.is_a?(Hash)
351
+
352
+ parts = Array(evidence["files"]).map { |file| "#{file["kind"]} `#{Markdown.escape(file["name"])}` (#{file["bytes"]} B, sha256 `#{file["sha256"][0, 12]}`)" }
353
+ parts << "#{evidence["console_errors"].size} console errors" if evidence["console_errors"]
354
+ parts << "#{evidence["network_errors"].size} failed requests" if evidence["network_errors"]
355
+ parts.concat(Array(evidence["links"]).each_with_index.map { |link, index| "[link #{index + 1}](#{link})" })
356
+ parts.empty? ? "none" : parts.join("<br>")
182
357
  end
183
358
 
184
359
  def markdown_excluded(entry)
@@ -5,13 +5,27 @@ module BrowserReviewGate
5
5
  # Results of a browser run made before the PR exists, kept inside the git directory for one commit.
6
6
  # They are published once a PR for that commit is opened.
7
7
  class SavedReport
8
+ DIR = "browser-verification"
9
+
10
+ # Where evidence files are kept, by digest, for runs saved or published from this checkout.
11
+ def self.evidence_dir(shell: Shell.new)
12
+ shell.call("git", "rev-parse", "--git-path", "#{DIR}/evidence").strip
13
+ end
14
+
15
+ # Removes saved runs and kept evidence older than `days`.
16
+ def self.prune(shell: Shell.new, days: 14)
17
+ dir = shell.call("git", "rev-parse", "--git-path", DIR).strip
18
+ cutoff = Time.now - days * 86_400
19
+ Dir.glob(File.join(dir, "{*.json,evidence/*}")).each { |path| FileUtils.rm_f(path) if File.file?(path) && File.mtime(path) < cutoff }
20
+ end
21
+
8
22
  def initialize(sha:, shell: Shell.new)
9
23
  @sha = sha
10
24
  @shell = shell
11
25
  end
12
26
 
13
27
  def path
14
- @path ||= @shell.call("git", "rev-parse", "--git-path", "browser-verification/#{@sha}.json").strip
28
+ @path ||= @shell.call("git", "rev-parse", "--git-path", "#{DIR}/#{@sha}.json").strip
15
29
  end
16
30
 
17
31
  def exist? = File.exist?(path)
@@ -1,6 +1,12 @@
1
+ require "time"
2
+
1
3
  module BrowserReviewGate
2
- # What the gate did on recent pull requests, read back from its own comments and labels.
4
+ # What the gate did on recent pull requests, read back from its own comments and labels. Only what
5
+ # the comments record is counted: each PR keeps its latest assessment and its merged report, so
6
+ # earlier assessments of the same PR and their token usage are not visible any more.
3
7
  class Stats
8
+ COUNTS = %w[pull_requests assessed required verified waived owed found_a_failure runs failed_runs].freeze
9
+
4
10
  def initialize(github:, config: Config.new)
5
11
  @github = github
6
12
  @config = config
@@ -8,27 +14,105 @@ module BrowserReviewGate
8
14
 
9
15
  def to_h(since:)
10
16
  counts = Hash.new(0)
11
- @github.pull_requests_updated_since(since).each { |pull_request| count(pull_request, counts) }
12
- %w[pull_requests assessed required verified waived owed found_a_failure].to_h { |key| [ key, counts[key] ] }
17
+ failing = Hash.new(0)
18
+ hours = []
19
+ usage = { "assessments_with_usage" => 0, "input_tokens" => 0, "output_tokens" => 0, "assessments_with_cost" => 0, "cost_usd" => 0.0 }
20
+ @github.pull_requests_updated_since(since).each { |pull_request| count(pull_request, counts, failing, hours, usage) }
21
+
22
+ result = COUNTS.to_h { |key| [ key, counts[key] ] }
23
+ result["failing_scenarios"] = failing.sort_by { |id, times| [ -times, id ] }.first(10).to_h
24
+ result["hours_to_verification"] = hours.empty? ? nil : { "median" => median(hours).round(1), "measured" => hours.size }
25
+ usage["cost_usd"] = usage["cost_usd"].round(4)
26
+ result["assessment_usage"] = usage
27
+ result
28
+ end
29
+
30
+ def self.text(result, days)
31
+ lines = [ "Pull requests touched in the last #{days} days: #{result["pull_requests"]}",
32
+ " assessed by the gate: #{result["assessed"]}",
33
+ " needed a browser run: #{result["required"]}",
34
+ " verified by a published run: #{result["verified"]}",
35
+ " waived: #{result["waived"]}",
36
+ " open and still owing a run: #{result["owed"]}",
37
+ " a run found a failing case: #{result["found_a_failure"]}",
38
+ " browser runs published: #{result["runs"]} (#{result["failed_runs"]} with a failing case)" ]
39
+ if (time = result["hours_to_verification"])
40
+ lines << " PR opened to verified run: median #{time["median"]} h over #{time["measured"]} PRs"
41
+ end
42
+ result["failing_scenarios"].each_with_index do |(id, times), index|
43
+ lines << " #{index.zero? ? "failing scenarios:" : ""}".ljust(34) + "#{id} (#{times})"
44
+ end
45
+ usage = result["assessment_usage"]
46
+ if usage["assessments_with_usage"].positive?
47
+ lines << " latest assessments, tokens: #{usage["input_tokens"]} in, #{usage["output_tokens"]} out " \
48
+ "(#{usage["assessments_with_usage"]} reported)"
49
+ end
50
+ if usage["assessments_with_cost"].positive?
51
+ lines << " latest assessments, cost: $#{usage["cost_usd"]} (#{usage["assessments_with_cost"]} reported by the provider)"
52
+ end
53
+ lines.join("\n")
13
54
  end
14
55
 
15
56
  private
16
57
 
17
- def count(pull_request, counts)
58
+ def count(pull_request, counts, failing, hours, usage)
18
59
  counts["pull_requests"] += 1
19
- comments = @github.trusted_comments(pull_request.fetch("number"))
60
+ number = pull_request.fetch("number")
61
+ comments = @github.trusted_comments(number)
20
62
  assessment = Assessment.latest(comments)
21
63
  return unless assessment
22
64
 
23
65
  counts["assessed"] += 1
66
+ add_usage(assessment.usage, usage)
24
67
  return unless assessment.required?
25
68
 
26
69
  labels = Array(pull_request["labels"]).map { |label| label["name"] }
70
+ report = Report.latest(comments, config: @config)
27
71
  counts["required"] += 1
28
72
  counts["verified"] += 1 if labels.include?(@config.label)
29
73
  counts["waived"] += 1 if labels.include?(@config.waiver_label)
30
74
  counts["owed"] += 1 if pull_request["state"] == "open" && !labels.intersect?([ @config.label, @config.waiver_label ])
31
- counts["found_a_failure"] += 1 if Report.latest(comments)&.failed_runs.to_i.positive?
75
+ return unless report
76
+
77
+ counts["runs"] += report.runs
78
+ counts["failed_runs"] += report.failed_runs
79
+ counts["found_a_failure"] += 1 if report.failed_runs.positive?
80
+ report.cases.each { |test_case| failing[test_case["id"]] += 1 if report.status_of(test_case["id"]) == "failed" }
81
+ measure(pull_request, comments, labels, hours)
82
+ end
83
+
84
+ # From the PR being opened to the report comment's last update, for PRs that are verified now.
85
+ def measure(pull_request, comments, labels, hours)
86
+ return unless labels.include?(@config.label)
87
+
88
+ comment = Report.latest_comment(comments)
89
+ opened = pull_request["created_at"]
90
+ published = comment && (comment["updated_at"] || comment["created_at"])
91
+ return unless opened && published
92
+
93
+ hours << (Time.iso8601(published) - Time.iso8601(opened)) / 3600.0
94
+ rescue ArgumentError
95
+ nil
96
+ end
97
+
98
+ def add_usage(found, usage)
99
+ return unless found
100
+
101
+ if found["input_tokens"] || found["output_tokens"]
102
+ usage["assessments_with_usage"] += 1
103
+ usage["input_tokens"] += found["input_tokens"].to_i
104
+ usage["output_tokens"] += found["output_tokens"].to_i
105
+ end
106
+ return unless found["cost_usd"]
107
+
108
+ usage["assessments_with_cost"] += 1
109
+ usage["cost_usd"] += found["cost_usd"]
110
+ end
111
+
112
+ def median(values)
113
+ sorted = values.sort
114
+ middle = sorted.size / 2
115
+ sorted.size.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0
32
116
  end
33
117
  end
34
118
  end