browser_review_gate 0.2.2 → 0.4.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 +48 -0
- data/README.md +152 -55
- 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 +241 -10
- data/lib/browser_review_gate/doctor.rb +252 -0
- data/lib/browser_review_gate/evidence.rb +183 -0
- data/lib/browser_review_gate/gate.rb +8 -5
- data/lib/browser_review_gate/github.rb +7 -0
- data/lib/browser_review_gate/installer.rb +2 -1
- 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 +247 -47
- data/lib/browser_review_gate/revision_header.rb +72 -0
- 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 +63 -21
- data/lib/browser_review_gate/templates/agents_block.md.erb +1 -1
- data/lib/browser_review_gate/templates/config.yml.erb +74 -5
- data/lib/browser_review_gate/templates/playbook.md.erb +65 -25
- data/lib/browser_review_gate/templates/rules.md.erb +4 -1
- data/lib/browser_review_gate/trace_check.rb +119 -0
- data/lib/browser_review_gate/verify.rb +95 -0
- data/lib/browser_review_gate/version.rb +1 -1
- data/lib/browser_review_gate/zip_reader.rb +103 -0
- data/lib/browser_review_gate.rb +6 -0
- metadata +7 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "stringio"
|
|
3
|
+
|
|
4
|
+
module BrowserReviewGate
|
|
5
|
+
# Checks a project's setup and says what to fix. A check that cannot be verified from here is SKIP,
|
|
6
|
+
# never PASS. Nothing secret is printed: secrets are checked by name only.
|
|
7
|
+
class Doctor
|
|
8
|
+
Check = Struct.new(:name, :status, :message, :fix) do
|
|
9
|
+
def to_h = { "name" => name, "status" => status, "message" => message, "fix" => fix }.compact
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
SECRETS = %w[CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY].freeze
|
|
13
|
+
BROWSER_TOOLS = { "playwright" => "Playwright", "chrome-devtools" => "Chrome DevTools MCP", "browsermcp" => "Browser MCP",
|
|
14
|
+
"puppeteer" => "Puppeteer" }.freeze
|
|
15
|
+
AGENT_FILES = {
|
|
16
|
+
"claude" => [ ".claude/skills/browser-pr-verification/SKILL.md", ".claude/settings.json" ],
|
|
17
|
+
"cursor" => [ ".cursor/commands/browser-pr-verification.md", ".cursor/hooks.json" ],
|
|
18
|
+
"codex" => [ "AGENTS.md", ".codex/hooks.json" ]
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# `probe` takes a URL and says whether something answers there.
|
|
22
|
+
# `header_probe` takes a URL and a header name and returns the header's value there, or nil.
|
|
23
|
+
def initialize(root:, shell: Shell.new, github: nil, probe: nil, config_loader: nil, header_probe: nil)
|
|
24
|
+
@header_probe = header_probe || Status.method(:header_at)
|
|
25
|
+
@root = root
|
|
26
|
+
@shell = shell
|
|
27
|
+
@github = github
|
|
28
|
+
@probe = probe || Status.method(:answers?)
|
|
29
|
+
@config_loader = config_loader || -> { Config.load(root) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def checks
|
|
33
|
+
@checks = []
|
|
34
|
+
ruby
|
|
35
|
+
gem_executable
|
|
36
|
+
repository
|
|
37
|
+
config = configuration
|
|
38
|
+
gh_ready = github_cli
|
|
39
|
+
repository_access if gh_ready
|
|
40
|
+
environments(config) if config
|
|
41
|
+
agents(config)
|
|
42
|
+
browser_tools(config)
|
|
43
|
+
workflow(config) if config
|
|
44
|
+
required_check(config) if config && gh_ready
|
|
45
|
+
provider_secrets(config) if config && gh_ready
|
|
46
|
+
@checks
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.text(checks)
|
|
50
|
+
width = checks.map { |check| check.name.length }.max.to_i
|
|
51
|
+
lines = checks.map do |check|
|
|
52
|
+
line = "#{check.status.ljust(4)} #{check.name.ljust(width)} #{check.message}"
|
|
53
|
+
check.fix ? "#{line}\n#{" " * (width + 8)}fix: #{check.fix}" : line
|
|
54
|
+
end
|
|
55
|
+
counts = %w[PASS WARN FAIL SKIP].map { |status| "#{checks.count { |check| check.status == status }} #{status}" }
|
|
56
|
+
(lines + [ "", counts.join(", ") ]).join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.json(checks) = JSON.pretty_generate("checks" => checks.map(&:to_h), "ok" => checks.none? { |check| check.status == "FAIL" })
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def add(name, status, message, fix = nil) = @checks << Check.new(name, status, message, fix)
|
|
64
|
+
def exist?(path) = File.exist?(File.join(@root, path))
|
|
65
|
+
def read(path) = exist?(path) ? File.read(File.join(@root, path)) : ""
|
|
66
|
+
|
|
67
|
+
def run(*argv)
|
|
68
|
+
@shell.call(*argv)
|
|
69
|
+
rescue Error
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ruby
|
|
74
|
+
return add("ruby", "PASS", "Ruby #{RUBY_VERSION}") if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2")
|
|
75
|
+
|
|
76
|
+
add("ruby", "FAIL", "Ruby #{RUBY_VERSION} is older than 3.2", "Use Ruby 3.2 or newer")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def gem_executable
|
|
80
|
+
found = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "browser-review-gate")) }
|
|
81
|
+
return add("gem", "PASS", "browser_review_gate #{VERSION} runs; a browser-review-gate executable is on PATH") if found
|
|
82
|
+
|
|
83
|
+
add("gem", "WARN", "browser_review_gate #{VERSION} runs, but browser-review-gate is not on PATH",
|
|
84
|
+
"bundle add browser_review_gate --group development, or run it with bundle exec")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def repository
|
|
88
|
+
top = run("git", "rev-parse", "--show-toplevel")
|
|
89
|
+
return add("repository", "PASS", "git repository at #{top.strip}") if top
|
|
90
|
+
|
|
91
|
+
add("repository", "FAIL", "not inside a git repository", "Run from the project checkout")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def configuration
|
|
95
|
+
config = @config_loader.call
|
|
96
|
+
add("settings", exist?(Config::PATH) ? "PASS" : "WARN", exist?(Config::PATH) ? "#{Config::PATH} is valid" : "#{Config::PATH} is missing; defaults apply",
|
|
97
|
+
exist?(Config::PATH) ? nil : "browser-review-gate install")
|
|
98
|
+
config
|
|
99
|
+
rescue Error => error
|
|
100
|
+
add("settings", "FAIL", error.message, "Fix #{Config::PATH}")
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def github_cli
|
|
105
|
+
unless run("gh", "--version")
|
|
106
|
+
add("gh", "FAIL", "the GitHub CLI is not installed", "Install gh: https://cli.github.com")
|
|
107
|
+
return false
|
|
108
|
+
end
|
|
109
|
+
unless run("gh", "auth", "status")
|
|
110
|
+
add("gh", "FAIL", "the GitHub CLI is not signed in", "gh auth login")
|
|
111
|
+
return false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
add("gh", "PASS", "installed and signed in")
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def repository_access
|
|
119
|
+
github = @github || GitHub.new(shell: @shell)
|
|
120
|
+
name = github.repository
|
|
121
|
+
push = run("gh", "api", "repos/#{name}", "--jq", ".permissions.push").to_s.strip
|
|
122
|
+
return add("repository access", "PASS", "#{name}: you can push, so your reports count") if push == "true"
|
|
123
|
+
return add("repository access", "WARN", "#{name}: you cannot push; reports you publish are ignored", "Ask for write access") if push == "false"
|
|
124
|
+
|
|
125
|
+
add("repository access", "SKIP", "#{name}: permissions could not be read")
|
|
126
|
+
rescue Error => error
|
|
127
|
+
add("repository access", "FAIL", error.message.lines.first.to_s.strip, "Check that the repository has a GitHub remote")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def environments(config)
|
|
131
|
+
return add("environments", "WARN", "no environment is configured", "Add environments.local.url in #{Config::PATH}") if config.environments.empty?
|
|
132
|
+
|
|
133
|
+
config.environments.each do |name, settings|
|
|
134
|
+
next add("environment #{name}", "WARN", "no url", "Set environments.#{name}.url") unless settings["url"]
|
|
135
|
+
|
|
136
|
+
url = Status.healthcheck_url(settings.merge("name" => name))
|
|
137
|
+
if @probe.call(url)
|
|
138
|
+
add("environment #{name}", "PASS", "answers at #{url}")
|
|
139
|
+
revision_header(config, name, settings["url"])
|
|
140
|
+
else
|
|
141
|
+
add("environment #{name}", "WARN", "does not answer at #{url}",
|
|
142
|
+
settings["start_command"] ? "Start it with #{settings["start_command"]} before a run" : "Make it reachable from where the agent runs")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# With trace checks on, a run can only count when the app names the commit it serves.
|
|
148
|
+
def revision_header(config, name, url)
|
|
149
|
+
mode = config.provenance["trace"]
|
|
150
|
+
return if mode == "off"
|
|
151
|
+
|
|
152
|
+
header = config.provenance["revision_header"]
|
|
153
|
+
served = @header_probe.call(url, header).to_s.strip.downcase
|
|
154
|
+
head = run("git", "-C", @root, "rev-parse", "HEAD").to_s.strip
|
|
155
|
+
label = "revision header #{name}"
|
|
156
|
+
fix = "Add `config.middleware.use BrowserReviewGate::RevisionHeader` to config/environments/development.rb"
|
|
157
|
+
return add(label, mode == "require" ? "FAIL" : "WARN", "#{url} sends no #{header} header; traces cannot be checked", fix) if served.empty?
|
|
158
|
+
return add(label, "PASS", "#{header} matches the checked-out commit") if !head.empty? && head.start_with?(served)
|
|
159
|
+
|
|
160
|
+
add(label, "WARN", "#{header} is #{served[0, 12]}, the checkout is at #{head[0, 12]}",
|
|
161
|
+
"Serve the checked-out code (restart the server, or run it from this worktree)")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def agents(config)
|
|
165
|
+
installer = Installer.new(root: @root, log: StringIO.new)
|
|
166
|
+
installer.detect_agents.each do |agent|
|
|
167
|
+
entry, hooks = AGENT_FILES.fetch(agent)
|
|
168
|
+
wired = agent == "codex" ? read(entry).include?("browser_review_gate:start") : exist?(entry)
|
|
169
|
+
hooked = read(hooks).include?("browser-review-gate hook #{agent}")
|
|
170
|
+
if wired && hooked
|
|
171
|
+
add("agent #{agent}", "PASS", "instructions and post-command hook installed")
|
|
172
|
+
else
|
|
173
|
+
add("agent #{agent}", "WARN", [ wired ? nil : "#{entry} missing", hooked ? nil : "hook missing in #{hooks}" ].compact.join("; "),
|
|
174
|
+
"browser-review-gate install --agents #{agent}")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
add("playbook", exist?(Installer::PLAYBOOK) ? "PASS" : "WARN", exist?(Installer::PLAYBOOK) ? Installer::PLAYBOOK : "#{Installer::PLAYBOOK} missing",
|
|
178
|
+
exist?(Installer::PLAYBOOK) ? nil : "browser-review-gate install")
|
|
179
|
+
rules = Prompts.new(@root).rules
|
|
180
|
+
add("project rules", rules ? "PASS" : "WARN", rules ? "#{Prompts::RULES} has rules" : "#{Prompts::RULES} has no rules yet",
|
|
181
|
+
rules ? nil : "Describe what this application always checks")
|
|
182
|
+
config
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Only what the project's own files show. A tool configured elsewhere cannot be seen from here.
|
|
186
|
+
def browser_tools(config)
|
|
187
|
+
files = [ ".mcp.json", ".cursor/mcp.json", ".codex/config.toml", ".claude/settings.json", "package.json" ]
|
|
188
|
+
text = files.map { |path| read(path) }.join("\n").downcase
|
|
189
|
+
found = BROWSER_TOOLS.select { |key, _label| text.include?(key) }.values
|
|
190
|
+
if found.any?
|
|
191
|
+
add("browser tool", "PASS", "configured in the project: #{found.join(", ")}")
|
|
192
|
+
else
|
|
193
|
+
add("browser tool", "SKIP", "none found in the project's files; one may be configured per user",
|
|
194
|
+
"Give the agent a browser tool, e.g. Playwright MCP or Chrome DevTools MCP")
|
|
195
|
+
end
|
|
196
|
+
trace_tool(config, text) if config
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# With trace checks on, every case needs a Playwright trace or a HAR file. Only Playwright is known
|
|
200
|
+
# to write both; other tools may write HAR, but that cannot be told from here.
|
|
201
|
+
def trace_tool(config, text)
|
|
202
|
+
mode = config.provenance["trace"]
|
|
203
|
+
return if mode == "off"
|
|
204
|
+
return add("trace recording", "PASS", "Playwright is configured in the project; it records traces and HAR files") if text.include?("playwright")
|
|
205
|
+
|
|
206
|
+
add("trace recording", mode == "require" ? "WARN" : "SKIP",
|
|
207
|
+
"provenance.trace is #{mode}, and no Playwright is configured in the project's files. Each case needs a Playwright " \
|
|
208
|
+
"trace (.zip) or a HAR file (.har); a browser tool that cannot save either #{mode == "require" ? "gets every passing case refused" : "gets no check"}",
|
|
209
|
+
"Give the agent Playwright (e.g. Playwright MCP), or a tool that saves HAR files, or set provenance.trace to warn or off")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def workflow(config)
|
|
213
|
+
path = ".github/workflows/#{config.workflow}"
|
|
214
|
+
return add("workflow", "FAIL", "#{path} is missing", "browser-review-gate install") unless exist?(path)
|
|
215
|
+
|
|
216
|
+
version = read(path)[/browser_review_gate:managed (\S+)/, 1]
|
|
217
|
+
return add("workflow", "PASS", "#{path} (#{version})") if version == VERSION
|
|
218
|
+
return add("workflow", "SKIP", "#{path} was not written by the installer; cannot tell its version") unless version
|
|
219
|
+
|
|
220
|
+
add("workflow", "WARN", "#{path} was written by #{version}; this is #{VERSION}", "browser-review-gate install")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def required_check(config)
|
|
224
|
+
github = @github || GitHub.new(shell: @shell)
|
|
225
|
+
branch = run("gh", "api", "repos/#{github.repository}", "--jq", ".default_branch").to_s.strip
|
|
226
|
+
return add("required check", "SKIP", "the default branch could not be read") if branch.empty?
|
|
227
|
+
|
|
228
|
+
contexts = run("gh", "api", "repos/#{github.repository}/branches/#{branch}/protection/required_status_checks", "--jq", ".contexts[]")
|
|
229
|
+
return add("required check", "SKIP", "branch protection of #{branch} could not be read (needs admin access, or none is set)",
|
|
230
|
+
"To block merging, make #{config.status_context} a required status check") unless contexts
|
|
231
|
+
return add("required check", "PASS", "#{config.status_context} is required on #{branch}") if contexts.lines.map(&:strip).include?(config.status_context)
|
|
232
|
+
|
|
233
|
+
add("required check", "WARN", "#{config.status_context} is not required on #{branch}; the gate does not block merging",
|
|
234
|
+
"Make #{config.status_context} a required status check")
|
|
235
|
+
rescue Error
|
|
236
|
+
add("required check", "SKIP", "could not be checked")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def provider_secrets(config)
|
|
240
|
+
names = run("gh", "secret", "list", "--json", "name", "--jq", ".[].name")
|
|
241
|
+
return add("model credentials", "SKIP", "repository secrets could not be listed (needs admin access)") unless names
|
|
242
|
+
|
|
243
|
+
present = names.lines.map(&:strip) & SECRETS
|
|
244
|
+
return add("model credentials", "FAIL", "no model secret in the repository", "Add one of #{SECRETS.join(", ")}") if present.empty?
|
|
245
|
+
if config.provider.to_s.match?(/\A(openai|gemini)\z/) && config.model.nil?
|
|
246
|
+
return add("model credentials", "FAIL", "#{config.provider} needs `model`", "Set model in #{Config::PATH}")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
add("model credentials", "PASS", "secret present: #{present.join(", ")}")
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
# A HAR network log: JSON, so it starts with "{" after optional whitespace or a byte order mark.
|
|
27
|
+
".har" => [ "application/json", [ "{".b ] ]
|
|
28
|
+
}.freeze
|
|
29
|
+
TRACE_TYPES = %w[.zip .har].freeze
|
|
30
|
+
SECRET = /
|
|
31
|
+
(?<key>authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|
|
|
32
|
+
id[_-]?token|token|secret|password|passwd|session(?:[_-]?id)?|csrf[_-]?token|auth)
|
|
33
|
+
(?<sep>["']?\s*[:=]\s*["']?)(?:bearer\s+|basic\s+)?[^\s"',;&]+
|
|
34
|
+
/xi
|
|
35
|
+
BEARER = /\b(bearer|basic)\s+[A-Za-z0-9._~+\/=-]{8,}/i
|
|
36
|
+
JWT = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/
|
|
37
|
+
LONG_TOKEN = /\b[A-Za-z0-9_-]{40,}\b/
|
|
38
|
+
|
|
39
|
+
# Masks values that look like credentials. Applied to everything an agent writes into a report.
|
|
40
|
+
def self.scrub(text)
|
|
41
|
+
text.to_s.gsub(SECRET) { "#{$~[:key]}#{$~[:sep]}[REDACTED]" }.gsub(BEARER) { "#{$1} [REDACTED]" }
|
|
42
|
+
.gsub(JWT, "[REDACTED]").gsub(LONG_TOKEN, "[REDACTED]")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Origin and path only: query strings and fragments carry tokens too often to publish.
|
|
46
|
+
def self.safe_url(value)
|
|
47
|
+
uri = URI.parse(value.to_s)
|
|
48
|
+
return scrub(value.to_s.split(/[?#]/).first.to_s)[0, 300] unless uri.scheme
|
|
49
|
+
|
|
50
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
51
|
+
scrub("#{uri.scheme}://#{uri.host}#{port}#{uri.path}")[0, 300]
|
|
52
|
+
rescue URI::InvalidURIError
|
|
53
|
+
"[unparseable URL]"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
attr_reader :files
|
|
57
|
+
|
|
58
|
+
# `base_dir` is where the report file lives; every evidence path is relative to it.
|
|
59
|
+
def initialize(base_dir:, config: Config.new)
|
|
60
|
+
@base_dir = base_dir && File.realpath(base_dir)
|
|
61
|
+
@config = config
|
|
62
|
+
@files = []
|
|
63
|
+
rescue SystemCallError
|
|
64
|
+
raise Error, "The report directory #{base_dir} cannot be read"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns the report data with each case's evidence in its published form. Raises Error naming the
|
|
68
|
+
# case and the problem when a path, file or entry is not acceptable.
|
|
69
|
+
def process(data)
|
|
70
|
+
return data unless data.is_a?(Hash) && data["cases"].is_a?(Array)
|
|
71
|
+
|
|
72
|
+
@run = data
|
|
73
|
+
data.merge("cases" => data["cases"].each_with_index.map { |test_case, index| process_case(test_case, index + 1) })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Copies the accepted files under `dir`, by digest, so the run can be looked at later.
|
|
77
|
+
def keep(dir)
|
|
78
|
+
files.each do |file|
|
|
79
|
+
target = File.join(dir, "#{file["sha256"]}#{File.extname(file["name"])}")
|
|
80
|
+
FileUtils.mkdir_p(dir)
|
|
81
|
+
FileUtils.cp(file.fetch("source"), target) unless File.exist?(target)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def process_case(test_case, position)
|
|
88
|
+
return test_case unless test_case.is_a?(Hash) && test_case.key?("evidence")
|
|
89
|
+
|
|
90
|
+
evidence = test_case["evidence"]
|
|
91
|
+
raise Error, "case #{position} evidence must be an object" unless evidence.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
unknown = evidence.keys - %w[screenshots trace console_errors network_errors links]
|
|
94
|
+
raise Error, "case #{position} evidence has unknown keys: #{unknown.join(", ")}" if unknown.any?
|
|
95
|
+
|
|
96
|
+
published = {}
|
|
97
|
+
listed = Array(evidence["screenshots"]).map { |path| [ "screenshots", path ] }
|
|
98
|
+
listed << [ "trace", evidence["trace"] ] if evidence["trace"]
|
|
99
|
+
raise Error, "case #{position} evidence lists more than #{MAX_FILES} files" if listed.size > MAX_FILES
|
|
100
|
+
|
|
101
|
+
published["files"] = listed.map { |kind, path| file(kind, path, position) } if listed.any?
|
|
102
|
+
trace = @files.reverse.find { |file| file["kind"] == "trace" && published.fetch("files", []).any? { |entry| entry["sha256"] == file["sha256"] } }
|
|
103
|
+
published["trace_check"] = check_trace(trace["source"], test_case) if trace && @config.provenance["trace"] != "off"
|
|
104
|
+
published["console_errors"] = entries(evidence["console_errors"], position, "console_errors") { |entry| Evidence.scrub(entry) } if evidence.key?("console_errors")
|
|
105
|
+
published["network_errors"] = entries(evidence["network_errors"], position, "network_errors") { |entry| network(entry) } if evidence.key?("network_errors")
|
|
106
|
+
published["links"] = links(evidence["links"], position) if evidence.key?("links")
|
|
107
|
+
test_case.merge("evidence" => published)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def file(kind, path, position)
|
|
111
|
+
raise Error, "case #{position} evidence files need a report file to be relative to" unless @base_dir
|
|
112
|
+
raise Error, "case #{position} #{kind} path must be a relative path inside the report directory" unless relative?(path)
|
|
113
|
+
|
|
114
|
+
full = File.join(@base_dir, path)
|
|
115
|
+
raise Error, "case #{position} #{kind} file #{path} does not exist" unless File.exist?(full)
|
|
116
|
+
raise Error, "case #{position} #{kind} file #{path} must be a regular file, not a link" if File.symlink?(full) || !File.file?(full)
|
|
117
|
+
|
|
118
|
+
real = File.realpath(full)
|
|
119
|
+
raise Error, "case #{position} #{kind} file #{path} is outside the report directory" unless real.start_with?("#{@base_dir}/")
|
|
120
|
+
|
|
121
|
+
extension = File.extname(real).downcase
|
|
122
|
+
allowed = kind == "trace" ? TRACE_TYPES : TYPES.keys - TRACE_TYPES
|
|
123
|
+
raise Error, "case #{position} #{kind} file #{path} must be one of #{allowed.join(", ")}" unless allowed.include?(extension)
|
|
124
|
+
|
|
125
|
+
bytes = File.size(real)
|
|
126
|
+
raise Error, "case #{position} #{kind} file #{path} is larger than #{MAX_FILE_BYTES / 1024 / 1024} MB" if bytes > MAX_FILE_BYTES
|
|
127
|
+
|
|
128
|
+
media_type, signatures = TYPES.fetch(extension)
|
|
129
|
+
head = File.binread(real, 64).to_s.delete_prefix("\xEF\xBB\xBF".b).lstrip
|
|
130
|
+
raise Error, "case #{position} #{kind} file #{path} is not a #{media_type} file" unless signatures.any? { |signature| head.start_with?(signature) }
|
|
131
|
+
|
|
132
|
+
entry = { "kind" => KINDS.fetch(kind), "name" => File.basename(real)[0, 100], "media_type" => media_type,
|
|
133
|
+
"bytes" => bytes, "sha256" => Digest::SHA256.file(real).hexdigest }
|
|
134
|
+
@files << entry.merge("source" => real)
|
|
135
|
+
entry
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A trace that cannot be read is a failed check, not an error: the PR shows why.
|
|
139
|
+
def check_trace(path, test_case)
|
|
140
|
+
TraceCheck.new(@config).call(path, tested_sha: @run["tested_sha"].to_s, url: test_case["url"],
|
|
141
|
+
started_at: @run["started_at"], finished_at: @run["finished_at"])
|
|
142
|
+
rescue Error => error
|
|
143
|
+
{ "status" => "failed", "requests" => 0, "findings" => [ error.message[0, 300] ] }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def relative?(path)
|
|
147
|
+
path.is_a?(String) && !path.empty? && path.length <= 300 && !path.start_with?("/", "~") &&
|
|
148
|
+
!path.include?("\0") && !path.split(%r{[/\\]}).include?("..")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def entries(value, position, key)
|
|
152
|
+
raise Error, "case #{position} evidence #{key} must be a list" unless value.is_a?(Array)
|
|
153
|
+
raise Error, "case #{position} evidence #{key} has more than #{MAX_ENTRIES} entries" if value.size > MAX_ENTRIES
|
|
154
|
+
|
|
155
|
+
value.map { |entry| yield(entry).to_s[0, MAX_ENTRY] }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A failed request as "METHOD URL -> STATUS", with the URL reduced to origin and path.
|
|
159
|
+
def network(entry)
|
|
160
|
+
return Evidence.safe_url(entry) unless entry.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
method = entry["method"].to_s.upcase[/\A[A-Z]{1,10}\z/]
|
|
163
|
+
status = entry["status"].to_s[/\A\d{1,3}|\A[a-z_:]{1,40}\z/i]
|
|
164
|
+
[ method, Evidence.safe_url(entry["url"]), status && "-> #{status}" ].compact.join(" ")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Links to evidence kept elsewhere, accepted only on hosts the project listed.
|
|
168
|
+
def links(value, position)
|
|
169
|
+
raise Error, "case #{position} evidence links must be a list" unless value.is_a?(Array)
|
|
170
|
+
raise Error, "case #{position} evidence has more than #{MAX_FILES} links" if value.size > MAX_FILES
|
|
171
|
+
|
|
172
|
+
value.map do |link|
|
|
173
|
+
uri = URI.parse(link.to_s)
|
|
174
|
+
allowed = uri.scheme == "https" && uri.userinfo.nil? && @config.evidence["link_hosts"].include?(uri.host)
|
|
175
|
+
raise Error, "case #{position} evidence link #{link.to_s[0, 100]} must be https on a host listed in evidence.link_hosts" unless allowed
|
|
176
|
+
|
|
177
|
+
link.to_s[0, 300]
|
|
178
|
+
rescue URI::InvalidURIError
|
|
179
|
+
raise Error, "case #{position} evidence link is not a valid URL"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
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
|
|
@@ -193,9 +193,10 @@ module BrowserReviewGate
|
|
|
193
193
|
<<~TEXT
|
|
194
194
|
Next:
|
|
195
195
|
1. Add one secret to the repository: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY.
|
|
196
|
-
2.
|
|
196
|
+
2. Describe your environments (url, healthcheck, start_command, sign_in) in #{Config::PATH}.
|
|
197
197
|
3. Describe what this application always checks in #{Prompts::RULES}.
|
|
198
198
|
4. Commit and merge to the default branch: the workflow runs from there.
|
|
199
|
+
5. Run browser-review-gate doctor to check the setup.
|
|
199
200
|
TEXT
|
|
200
201
|
end
|
|
201
202
|
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
|