browser_review_gate 0.2.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +134 -62
- data/lib/browser_review_gate/assessment.rb +74 -33
- data/lib/browser_review_gate/assessor.rb +38 -10
- data/lib/browser_review_gate/cli.rb +44 -19
- data/lib/browser_review_gate/config.rb +223 -10
- data/lib/browser_review_gate/doctor.rb +216 -0
- data/lib/browser_review_gate/evidence.rb +170 -0
- data/lib/browser_review_gate/gate.rb +8 -5
- data/lib/browser_review_gate/github.rb +7 -0
- data/lib/browser_review_gate/model_api.rb +16 -0
- data/lib/browser_review_gate/model_client.rb +10 -0
- data/lib/browser_review_gate/prompts.rb +10 -5
- data/lib/browser_review_gate/publisher.rb +32 -9
- data/lib/browser_review_gate/report.rb +222 -47
- data/lib/browser_review_gate/saved_report.rb +15 -1
- data/lib/browser_review_gate/stats.rb +90 -6
- data/lib/browser_review_gate/status.rb +52 -21
- data/lib/browser_review_gate/templates/agents_block.md.erb +1 -1
- data/lib/browser_review_gate/templates/config.yml.erb +63 -5
- data/lib/browser_review_gate/templates/playbook.md.erb +63 -25
- data/lib/browser_review_gate/templates/rules.md.erb +4 -1
- data/lib/browser_review_gate/verify.rb +86 -0
- data/lib/browser_review_gate/version.rb +1 -1
- data/lib/browser_review_gate.rb +3 -0
- metadata +4 -1
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
77
|
-
#
|
|
78
|
-
def
|
|
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
|
-
|
|
81
|
-
|
|
134
|
+
position = index + 1
|
|
82
135
|
found = []
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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 =
|
|
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}
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
errors << "case #{position} tested_sha must be a
|
|
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
|
|
158
|
-
errors << "case #{position}
|
|
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) &&
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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", "
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|