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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +1 -1
- 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 +31 -5
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module BrowserReviewGate
|
|
6
|
+
# Turns the evidence an agent lists in a submitted report into what is published.
|
|
7
|
+
#
|
|
8
|
+
# Files (screenshots, traces) are read from the directory of the report file, checked, hashed and
|
|
9
|
+
# copied into the git directory; the PR gets their names, sizes and SHA-256 digests, never the
|
|
10
|
+
# files. A digest shows that a file of that type existed when the run was published. It does not
|
|
11
|
+
# show that a browser produced it. Console and network entries are the agent's own account; they are
|
|
12
|
+
# trimmed and scrubbed of anything that looks like a credential.
|
|
13
|
+
class Evidence
|
|
14
|
+
MAX_FILE_BYTES = 20 * 1024 * 1024
|
|
15
|
+
MAX_FILES = 10
|
|
16
|
+
MAX_ENTRIES = 50
|
|
17
|
+
MAX_ENTRY = 500
|
|
18
|
+
KINDS = { "screenshots" => "screenshot", "trace" => "trace" }.freeze
|
|
19
|
+
# Accepted files by extension, each with the bytes its content must start with.
|
|
20
|
+
TYPES = {
|
|
21
|
+
".png" => [ "image/png", [ "\x89PNG\r\n\x1A\n".b ] ],
|
|
22
|
+
".jpg" => [ "image/jpeg", [ "\xFF\xD8\xFF".b ] ],
|
|
23
|
+
".jpeg" => [ "image/jpeg", [ "\xFF\xD8\xFF".b ] ],
|
|
24
|
+
".webp" => [ "image/webp", [ "RIFF".b ] ],
|
|
25
|
+
".zip" => [ "application/zip", [ "PK\x03\x04".b ] ]
|
|
26
|
+
}.freeze
|
|
27
|
+
TRACE_TYPES = %w[.zip].freeze
|
|
28
|
+
SECRET = /
|
|
29
|
+
(?<key>authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|
|
|
30
|
+
id[_-]?token|token|secret|password|passwd|session(?:[_-]?id)?|csrf[_-]?token|auth)
|
|
31
|
+
(?<sep>["']?\s*[:=]\s*["']?)(?:bearer\s+|basic\s+)?[^\s"',;&]+
|
|
32
|
+
/xi
|
|
33
|
+
BEARER = /\b(bearer|basic)\s+[A-Za-z0-9._~+\/=-]{8,}/i
|
|
34
|
+
JWT = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/
|
|
35
|
+
LONG_TOKEN = /\b[A-Za-z0-9_-]{40,}\b/
|
|
36
|
+
|
|
37
|
+
# Masks values that look like credentials. Applied to everything an agent writes into a report.
|
|
38
|
+
def self.scrub(text)
|
|
39
|
+
text.to_s.gsub(SECRET) { "#{$~[:key]}#{$~[:sep]}[REDACTED]" }.gsub(BEARER) { "#{$1} [REDACTED]" }
|
|
40
|
+
.gsub(JWT, "[REDACTED]").gsub(LONG_TOKEN, "[REDACTED]")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Origin and path only: query strings and fragments carry tokens too often to publish.
|
|
44
|
+
def self.safe_url(value)
|
|
45
|
+
uri = URI.parse(value.to_s)
|
|
46
|
+
return scrub(value.to_s.split(/[?#]/).first.to_s)[0, 300] unless uri.scheme
|
|
47
|
+
|
|
48
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
49
|
+
scrub("#{uri.scheme}://#{uri.host}#{port}#{uri.path}")[0, 300]
|
|
50
|
+
rescue URI::InvalidURIError
|
|
51
|
+
"[unparseable URL]"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :files
|
|
55
|
+
|
|
56
|
+
# `base_dir` is where the report file lives; every evidence path is relative to it.
|
|
57
|
+
def initialize(base_dir:, config: Config.new)
|
|
58
|
+
@base_dir = base_dir && File.realpath(base_dir)
|
|
59
|
+
@config = config
|
|
60
|
+
@files = []
|
|
61
|
+
rescue SystemCallError
|
|
62
|
+
raise Error, "The report directory #{base_dir} cannot be read"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the report data with each case's evidence in its published form. Raises Error naming the
|
|
66
|
+
# case and the problem when a path, file or entry is not acceptable.
|
|
67
|
+
def process(data)
|
|
68
|
+
return data unless data.is_a?(Hash) && data["cases"].is_a?(Array)
|
|
69
|
+
|
|
70
|
+
data.merge("cases" => data["cases"].each_with_index.map { |test_case, index| process_case(test_case, index + 1) })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Copies the accepted files under `dir`, by digest, so the run can be looked at later.
|
|
74
|
+
def keep(dir)
|
|
75
|
+
files.each do |file|
|
|
76
|
+
target = File.join(dir, "#{file["sha256"]}#{File.extname(file["name"])}")
|
|
77
|
+
FileUtils.mkdir_p(dir)
|
|
78
|
+
FileUtils.cp(file.fetch("source"), target) unless File.exist?(target)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def process_case(test_case, position)
|
|
85
|
+
return test_case unless test_case.is_a?(Hash) && test_case.key?("evidence")
|
|
86
|
+
|
|
87
|
+
evidence = test_case["evidence"]
|
|
88
|
+
raise Error, "case #{position} evidence must be an object" unless evidence.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
unknown = evidence.keys - %w[screenshots trace console_errors network_errors links]
|
|
91
|
+
raise Error, "case #{position} evidence has unknown keys: #{unknown.join(", ")}" if unknown.any?
|
|
92
|
+
|
|
93
|
+
published = {}
|
|
94
|
+
listed = Array(evidence["screenshots"]).map { |path| [ "screenshots", path ] }
|
|
95
|
+
listed << [ "trace", evidence["trace"] ] if evidence["trace"]
|
|
96
|
+
raise Error, "case #{position} evidence lists more than #{MAX_FILES} files" if listed.size > MAX_FILES
|
|
97
|
+
|
|
98
|
+
published["files"] = listed.map { |kind, path| file(kind, path, position) } if listed.any?
|
|
99
|
+
published["console_errors"] = entries(evidence["console_errors"], position, "console_errors") { |entry| Evidence.scrub(entry) } if evidence.key?("console_errors")
|
|
100
|
+
published["network_errors"] = entries(evidence["network_errors"], position, "network_errors") { |entry| network(entry) } if evidence.key?("network_errors")
|
|
101
|
+
published["links"] = links(evidence["links"], position) if evidence.key?("links")
|
|
102
|
+
test_case.merge("evidence" => published)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def file(kind, path, position)
|
|
106
|
+
raise Error, "case #{position} evidence files need a report file to be relative to" unless @base_dir
|
|
107
|
+
raise Error, "case #{position} #{kind} path must be a relative path inside the report directory" unless relative?(path)
|
|
108
|
+
|
|
109
|
+
full = File.join(@base_dir, path)
|
|
110
|
+
raise Error, "case #{position} #{kind} file #{path} does not exist" unless File.exist?(full)
|
|
111
|
+
raise Error, "case #{position} #{kind} file #{path} must be a regular file, not a link" if File.symlink?(full) || !File.file?(full)
|
|
112
|
+
|
|
113
|
+
real = File.realpath(full)
|
|
114
|
+
raise Error, "case #{position} #{kind} file #{path} is outside the report directory" unless real.start_with?("#{@base_dir}/")
|
|
115
|
+
|
|
116
|
+
extension = File.extname(real).downcase
|
|
117
|
+
allowed = kind == "trace" ? TRACE_TYPES : TYPES.keys - TRACE_TYPES
|
|
118
|
+
raise Error, "case #{position} #{kind} file #{path} must be one of #{allowed.join(", ")}" unless allowed.include?(extension)
|
|
119
|
+
|
|
120
|
+
bytes = File.size(real)
|
|
121
|
+
raise Error, "case #{position} #{kind} file #{path} is larger than #{MAX_FILE_BYTES / 1024 / 1024} MB" if bytes > MAX_FILE_BYTES
|
|
122
|
+
|
|
123
|
+
media_type, signatures = TYPES.fetch(extension)
|
|
124
|
+
head = File.binread(real, 16).to_s
|
|
125
|
+
raise Error, "case #{position} #{kind} file #{path} is not a #{media_type} file" unless signatures.any? { |signature| head.start_with?(signature) }
|
|
126
|
+
|
|
127
|
+
entry = { "kind" => KINDS.fetch(kind), "name" => File.basename(real)[0, 100], "media_type" => media_type,
|
|
128
|
+
"bytes" => bytes, "sha256" => Digest::SHA256.file(real).hexdigest }
|
|
129
|
+
@files << entry.merge("source" => real)
|
|
130
|
+
entry
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def relative?(path)
|
|
134
|
+
path.is_a?(String) && !path.empty? && path.length <= 300 && !path.start_with?("/", "~") &&
|
|
135
|
+
!path.include?("\0") && !path.split(%r{[/\\]}).include?("..")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def entries(value, position, key)
|
|
139
|
+
raise Error, "case #{position} evidence #{key} must be a list" unless value.is_a?(Array)
|
|
140
|
+
raise Error, "case #{position} evidence #{key} has more than #{MAX_ENTRIES} entries" if value.size > MAX_ENTRIES
|
|
141
|
+
|
|
142
|
+
value.map { |entry| yield(entry).to_s[0, MAX_ENTRY] }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A failed request as "METHOD URL -> STATUS", with the URL reduced to origin and path.
|
|
146
|
+
def network(entry)
|
|
147
|
+
return Evidence.safe_url(entry) unless entry.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
method = entry["method"].to_s.upcase[/\A[A-Z]{1,10}\z/]
|
|
150
|
+
status = entry["status"].to_s[/\A\d{1,3}|\A[a-z_:]{1,40}\z/i]
|
|
151
|
+
[ method, Evidence.safe_url(entry["url"]), status && "-> #{status}" ].compact.join(" ")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Links to evidence kept elsewhere, accepted only on hosts the project listed.
|
|
155
|
+
def links(value, position)
|
|
156
|
+
raise Error, "case #{position} evidence links must be a list" unless value.is_a?(Array)
|
|
157
|
+
raise Error, "case #{position} evidence has more than #{MAX_FILES} links" if value.size > MAX_FILES
|
|
158
|
+
|
|
159
|
+
value.map do |link|
|
|
160
|
+
uri = URI.parse(link.to_s)
|
|
161
|
+
allowed = uri.scheme == "https" && uri.userinfo.nil? && @config.evidence["link_hosts"].include?(uri.host)
|
|
162
|
+
raise Error, "case #{position} evidence link #{link.to_s[0, 100]} must be https on a host listed in evidence.link_hosts" unless allowed
|
|
163
|
+
|
|
164
|
+
link.to_s[0, 300]
|
|
165
|
+
rescue URI::InvalidURIError
|
|
166
|
+
raise Error, "case #{position} evidence link is not a valid URL"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -39,7 +39,7 @@ module BrowserReviewGate
|
|
|
39
39
|
return [ :open, "Browser verification waived by #{waived_by}." ] if waived_by
|
|
40
40
|
return [ :closed, "The browser-testing assessment for this commit is not available." ] unless assessment
|
|
41
41
|
return [ :open, "Browser verification is not needed." ] unless assessment.required?
|
|
42
|
-
return [ :open, "Required browser cases are verified." ] if verified?(pull_request, comments, assessment)
|
|
42
|
+
return [ :open, "Required browser cases are verified." ] if verified?(number, pull_request, comments, assessment)
|
|
43
43
|
|
|
44
44
|
[ :closed, "Browser run needed: #{@config.command} #{number}" ]
|
|
45
45
|
end
|
|
@@ -48,8 +48,11 @@ module BrowserReviewGate
|
|
|
48
48
|
pull_request.fetch("labels").any? { |entry| entry["name"] == label }
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
# Only cases tested on a commit of this PR count: a report about other code proves nothing here.
|
|
52
|
+
def verified?(number, pull_request, comments, assessment)
|
|
53
|
+
labelled?(pull_request, @config.label) &&
|
|
54
|
+
assessment.covered_by?(Report.latest(comments, config: @config), commits: @github.pull_request_commits(number),
|
|
55
|
+
reuse_passing: @config.scenarios["reuse_passing"])
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
# A waiver counts only from someone other than the author, unless the project allows otherwise.
|
|
@@ -146,8 +149,8 @@ module BrowserReviewGate
|
|
|
146
149
|
def notice(number, assessment, comments)
|
|
147
150
|
return "Review request paused. The browser-testing assessment for this commit is not available. Request review again to retry it." unless assessment
|
|
148
151
|
|
|
149
|
-
report = Report.
|
|
150
|
-
missing = report && !assessment.covered_by?(report) ? "the published browser run does not cover the latest commits" :
|
|
152
|
+
report = Report.latest(comments, config: @config)
|
|
153
|
+
missing = report&.passed? && !assessment.covered_by?(report, commits: @github.pull_request_commits(number)) ? "the published browser run does not cover the latest commits" :
|
|
151
154
|
"no successful local browser run has been published yet"
|
|
152
155
|
"Review request paused. Browser testing is required for this change, and #{missing}. " \
|
|
153
156
|
"Run `#{@config.command} #{number}` in your AI coding agent from the PR branch."
|
|
@@ -51,6 +51,13 @@ module BrowserReviewGate
|
|
|
51
51
|
raise Error, "gh returned output that is not a pull request list"
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Full SHAs of the PR's commits, oldest first. GitHub lists at most 250.
|
|
55
|
+
def pull_request_commits(number)
|
|
56
|
+
pages("repos/#{repository}/pulls/#{number}/commits").map { |commit| commit.fetch("sha") }
|
|
57
|
+
rescue KeyError
|
|
58
|
+
raise Error, "gh returned commits without a sha for PR #{number}"
|
|
59
|
+
end
|
|
60
|
+
|
|
54
61
|
def files(number)
|
|
55
62
|
pages("repos/#{repository}/pulls/#{number}/files")
|
|
56
63
|
end
|
|
@@ -24,6 +24,9 @@ module BrowserReviewGate
|
|
|
24
24
|
raise Error, "Set `model` in #{Config::PATH} to assess with #{provider}" unless @model
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Token counts of the last call as the provider reported them, or nil. Providers report no cost.
|
|
28
|
+
attr_reader :last_usage
|
|
29
|
+
|
|
27
30
|
# Returns the model's answer as JSON text. Raises Error when the call fails or gives no answer.
|
|
28
31
|
def complete(system:, user:, schema:)
|
|
29
32
|
key = ENV[@key_name].to_s
|
|
@@ -32,6 +35,7 @@ module BrowserReviewGate
|
|
|
32
35
|
url, headers, body = send("#{@provider}_request", key, system, user, schema)
|
|
33
36
|
status, text = @transport.call(url, headers, body)
|
|
34
37
|
response = parse(text)
|
|
38
|
+
@last_usage = usage(response)
|
|
35
39
|
raise Error, "The model gave no answer: HTTP #{status} #{failure(response, text)}" unless status.to_i == 200
|
|
36
40
|
|
|
37
41
|
answer = parse(send("#{@provider}_answer", response))
|
|
@@ -81,6 +85,18 @@ module BrowserReviewGate
|
|
|
81
85
|
Array(response.dig("candidates", 0, "content", "parts")).filter_map { |part| part["text"] if part.is_a?(Hash) }.join
|
|
82
86
|
end
|
|
83
87
|
|
|
88
|
+
def usage(response)
|
|
89
|
+
input, output = case @provider
|
|
90
|
+
when "anthropic" then [ response.dig("usage", "input_tokens"), response.dig("usage", "output_tokens") ]
|
|
91
|
+
when "openai" then [ response.dig("usage", "prompt_tokens"), response.dig("usage", "completion_tokens") ]
|
|
92
|
+
else [ response.dig("usageMetadata", "promptTokenCount"), response.dig("usageMetadata", "candidatesTokenCount") ]
|
|
93
|
+
end
|
|
94
|
+
return unless input.is_a?(Integer) || output.is_a?(Integer)
|
|
95
|
+
|
|
96
|
+
{ "provider" => @provider, "model" => (response["model"] || response["modelVersion"] || @model).to_s,
|
|
97
|
+
"input_tokens" => input, "output_tokens" => output }.compact
|
|
98
|
+
end
|
|
99
|
+
|
|
84
100
|
def parse(text)
|
|
85
101
|
data = JSON.parse(text.to_s)
|
|
86
102
|
data.is_a?(Hash) ? data : {}
|
|
@@ -32,6 +32,9 @@ module BrowserReviewGate
|
|
|
32
32
|
@runner = runner || method(:run)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Token counts and cost of the last call as the CLI reported them, or nil.
|
|
36
|
+
attr_reader :last_usage
|
|
37
|
+
|
|
35
38
|
# Returns the model's answer as JSON text. Raises Error when the call fails or gives no answer.
|
|
36
39
|
def complete(system:, user:, schema:)
|
|
37
40
|
argv = @command + [ "--print", "--tools", "", "--setting-sources", "", "--system-prompt", system,
|
|
@@ -39,6 +42,7 @@ module BrowserReviewGate
|
|
|
39
42
|
argv += [ "--model", @model ] if @model
|
|
40
43
|
stdout, stderr, success = @runner.call(environment, argv, user)
|
|
41
44
|
result = parse(stdout)
|
|
45
|
+
@last_usage = usage(result)
|
|
42
46
|
answer = result["structured_output"] if success && result["is_error"] == false
|
|
43
47
|
return JSON.generate(answer) if answer.is_a?(Hash)
|
|
44
48
|
|
|
@@ -47,6 +51,12 @@ module BrowserReviewGate
|
|
|
47
51
|
|
|
48
52
|
private
|
|
49
53
|
|
|
54
|
+
def usage(result)
|
|
55
|
+
tokens = result["usage"].is_a?(Hash) ? result["usage"] : {}
|
|
56
|
+
{ "provider" => "claude-cli", "model" => @model, "input_tokens" => tokens["input_tokens"],
|
|
57
|
+
"output_tokens" => tokens["output_tokens"], "cost_usd" => result["total_cost_usd"] }.compact.then { |found| found.size > 1 ? found : nil }
|
|
58
|
+
end
|
|
59
|
+
|
|
50
60
|
def parse(stdout)
|
|
51
61
|
result = JSON.parse(stdout.to_s)
|
|
52
62
|
result.is_a?(Hash) ? result : {}
|
|
@@ -30,10 +30,10 @@ module BrowserReviewGate
|
|
|
30
30
|
text(RULES)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def assessment
|
|
33
|
+
def assessment(max_count: 12)
|
|
34
34
|
parts = [ FRAME_HEAD, "## Decision\n\n#{policy}" ]
|
|
35
35
|
parts << "## Project rules\n\n#{PROJECT_RULES_NOTE}\n\n#{rules}" if rules
|
|
36
|
-
parts << FRAME_TAIL
|
|
36
|
+
parts << format(FRAME_TAIL, max_count: max_count)
|
|
37
37
|
parts.join("\n\n")
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -58,9 +58,14 @@ module BrowserReviewGate
|
|
|
58
58
|
|
|
59
59
|
When the decision is `required`, `scenarios` lists what a person must do in the browser to exercise
|
|
60
60
|
every behavior this pull request changes, including what the project rules require for the areas
|
|
61
|
-
it touches. One entry per scenario, at most
|
|
62
|
-
to do and what must be seen, in one sentence. `id` is a short label such as `SIGN-IN-1`:
|
|
63
|
-
digits and hyphens.
|
|
61
|
+
it touches. One entry per scenario, at most %<max_count>d, the fewest that cover the change. `name` says
|
|
62
|
+
what to do and what must be seen, in one sentence. `id` is a short label such as `SIGN-IN-1`:
|
|
63
|
+
letters, digits and hyphens. Derive the id from the behavior, not from its position, so the same
|
|
64
|
+
behavior gets the same id when the pull request changes. `priority` is `critical` when a failure
|
|
65
|
+
would block people from a core task or expose data, otherwise `normal`. `category` is one short
|
|
66
|
+
lowercase word for the area, such as `forms`, `navigation` or `permissions`. `why` is one sentence
|
|
67
|
+
saying which change makes this scenario necessary. Scenarios the project settings make mandatory
|
|
68
|
+
are added after you answer; do not list them.
|
|
64
69
|
|
|
65
70
|
When `report_cases` is present, those are the cases of an earlier browser run. If one of them
|
|
66
71
|
already exercises a scenario, give that scenario the `id` of that case, exactly. Give every other
|
|
@@ -27,6 +27,7 @@ module BrowserReviewGate
|
|
|
27
27
|
saved_report = SavedReport.new(sha: local_sha, shell: @shell)
|
|
28
28
|
data = load_report(saved_report)
|
|
29
29
|
ensure_report_sha!(data, local_sha)
|
|
30
|
+
ensure_environment!(data)
|
|
30
31
|
actor = @github.login
|
|
31
32
|
data["verified_by"] = "AI browser agent (run by #{actor})"
|
|
32
33
|
|
|
@@ -37,9 +38,10 @@ module BrowserReviewGate
|
|
|
37
38
|
complete = report.passed?
|
|
38
39
|
labelled = pull_request.fetch("labels").any? { |label| label["name"] == @config.label }
|
|
39
40
|
@github.remove_label(number, @config.label) if labelled && !complete
|
|
40
|
-
write_comment(number, comments, actor, body(report, complete))
|
|
41
|
+
write_comment(number, comments, actor, body(report, complete, @github.pull_request_commits(number)))
|
|
41
42
|
@github.add_label(number, @config.label) if complete
|
|
42
43
|
saved_report.delete unless @report_path
|
|
44
|
+
SavedReport.prune(shell: @shell, days: @config.evidence["retention_days"])
|
|
43
45
|
complete
|
|
44
46
|
rescue KeyError => error
|
|
45
47
|
raise Error, "Browser verification data is incomplete: #{error.message}"
|
|
@@ -53,11 +55,13 @@ module BrowserReviewGate
|
|
|
53
55
|
ensure_clean_worktree!
|
|
54
56
|
data = read_report_file
|
|
55
57
|
ensure_report_sha!(data, local_sha)
|
|
56
|
-
|
|
58
|
+
ensure_environment!(data)
|
|
59
|
+
report = Report.new(data.merge("verified_by" => "pending"), config: @config)
|
|
57
60
|
ensure_acceptable!(report)
|
|
58
61
|
|
|
59
62
|
saved_report = SavedReport.new(sha: local_sha, shell: @shell)
|
|
60
63
|
saved_report.write(data)
|
|
64
|
+
SavedReport.prune(shell: @shell, days: @config.evidence["retention_days"])
|
|
61
65
|
saved_report.path
|
|
62
66
|
rescue JSON::ParserError => error
|
|
63
67
|
raise Error, "Browser verification data is not valid JSON: #{error.message}"
|
|
@@ -89,14 +93,28 @@ module BrowserReviewGate
|
|
|
89
93
|
raise Error, "No saved browser verification report for the current commit"
|
|
90
94
|
end
|
|
91
95
|
|
|
96
|
+
# Evidence files are read here, next to the report, and kept in the git directory by digest.
|
|
92
97
|
def read_report_file
|
|
93
98
|
raise Error, "A report file is required" unless @report_path
|
|
94
99
|
|
|
95
|
-
JSON.parse(File.read(@report_path))
|
|
100
|
+
data = JSON.parse(File.read(@report_path))
|
|
101
|
+
evidence = Evidence.new(base_dir: File.dirname(File.expand_path(@report_path)), config: @config)
|
|
102
|
+
processed = evidence.process(data)
|
|
103
|
+
evidence.keep(SavedReport.evidence_dir(shell: @shell))
|
|
104
|
+
processed
|
|
96
105
|
rescue SystemCallError => error
|
|
97
106
|
raise Error, "Cannot read report file #{@report_path}: #{error.message}"
|
|
98
107
|
end
|
|
99
108
|
|
|
109
|
+
# A run names the environment it used; with environments configured it must be one of them.
|
|
110
|
+
def ensure_environment!(data)
|
|
111
|
+
name = data["environment"]
|
|
112
|
+
return if @config.environments.empty? ? Config::NAME_PATTERN.match?(name.to_s) : @config.environments.key?(name)
|
|
113
|
+
|
|
114
|
+
known = @config.environments.keys
|
|
115
|
+
raise Error, "Report environment must be #{known.empty? ? "a short lowercase name" : "one of #{known.join(", ")}"}, not #{name.inspect}"
|
|
116
|
+
end
|
|
117
|
+
|
|
100
118
|
# A report made before the PR existed may arrive before the gate has assessed the commit.
|
|
101
119
|
def ensure_browser_run_wanted!(number, comments, tested_sha)
|
|
102
120
|
assessment = Assessment.latest(comments)
|
|
@@ -106,11 +124,16 @@ module BrowserReviewGate
|
|
|
106
124
|
end
|
|
107
125
|
|
|
108
126
|
def merged_report(data, comments)
|
|
109
|
-
submitted = Report.new(data)
|
|
127
|
+
submitted = Report.new(data, config: @config)
|
|
110
128
|
ensure_acceptable!(submitted)
|
|
111
129
|
|
|
112
130
|
prior = Report.latest_comment(comments)
|
|
113
|
-
|
|
131
|
+
prior_data = begin
|
|
132
|
+
prior ? Report.data_from(prior["body"]) : {}
|
|
133
|
+
rescue Error
|
|
134
|
+
{}
|
|
135
|
+
end
|
|
136
|
+
report = Report.merge(prior_data, submitted.to_h, config: @config)
|
|
114
137
|
raise Error, report.errors.join("; ") unless report.valid?
|
|
115
138
|
|
|
116
139
|
report
|
|
@@ -118,15 +141,15 @@ module BrowserReviewGate
|
|
|
118
141
|
|
|
119
142
|
# A run is taken only with the evidence of each case: the page and what was observed there.
|
|
120
143
|
def ensure_acceptable!(report)
|
|
121
|
-
problems = report.valid? ? report.
|
|
144
|
+
problems = report.valid? ? report.submission_errors : report.errors
|
|
122
145
|
raise Error, problems.join("; ") if problems.any?
|
|
123
146
|
end
|
|
124
147
|
|
|
125
|
-
def body(report, complete)
|
|
126
|
-
return report.markdown if complete
|
|
148
|
+
def body(report, complete, commits)
|
|
149
|
+
return report.markdown(commits: commits) if complete
|
|
127
150
|
|
|
128
151
|
[ "**Not verified.** Complete every required browser scenario and pass all reported cases before human review.",
|
|
129
|
-
"", report.markdown ].join("\n")
|
|
152
|
+
"", report.markdown(commits: commits) ].join("\n")
|
|
130
153
|
end
|
|
131
154
|
|
|
132
155
|
# Only the author's own earlier report comment can be edited in place.
|