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.
@@ -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
- def verified?(pull_request, comments, assessment)
52
- labelled?(pull_request, @config.label) && assessment.covered_by?(Report.latest(comments))
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.latest_passing(comments)
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. Fill start_command, url and sign_in in #{Config::PATH}.
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 12, the fewest that cover the change. `name` says what
62
- to do and what must be seen, in one sentence. `id` is a short label such as `SIGN-IN-1`: letters,
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