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
|
@@ -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
|
|
@@ -3,35 +3,71 @@ require "openssl"
|
|
|
3
3
|
require "uri"
|
|
4
4
|
|
|
5
5
|
module BrowserReviewGate
|
|
6
|
-
# What an agent needs to know before a browser run: the decision for the PR head
|
|
6
|
+
# What an agent needs to know before a browser run: the decision for the PR head, the scenarios still
|
|
7
|
+
# owed with their status, the environment to use, the policy, and the next step.
|
|
7
8
|
class Status
|
|
8
9
|
RUN_STEPS = %w[run_and_publish run_and_save].freeze
|
|
9
10
|
|
|
10
|
-
# `probe` takes
|
|
11
|
+
# `probe` takes a URL and says whether something answers there.
|
|
11
12
|
def initialize(github:, config: Config.new, shell: Shell.new, probe: nil, prompts: Prompts.new)
|
|
12
13
|
@prompts = prompts
|
|
13
14
|
@github = github
|
|
14
15
|
@config = config
|
|
15
16
|
@shell = shell
|
|
16
|
-
@probe = probe || method(:answers?)
|
|
17
|
+
@probe = probe || Status.method(:answers?)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
def to_h(number = nil)
|
|
20
|
+
def to_h(number = nil, environment: nil)
|
|
20
21
|
result = facts(number)
|
|
21
22
|
result["project_rules"] = Prompts::RULES if @prompts.rules
|
|
22
|
-
result["
|
|
23
|
+
result["environment"] = environment_details(environment)
|
|
24
|
+
result["policy"] = @config.policy
|
|
25
|
+
result["report"] = report_instructions
|
|
23
26
|
# The agent never starts the app itself: a person decides what runs on their machine.
|
|
24
|
-
result["next"] = "ask_to_start_app" if RUN_STEPS.include?(result["next"]) && result["
|
|
27
|
+
result["next"] = "ask_to_start_app" if RUN_STEPS.include?(result["next"]) && result["environment"]["reachable"] == false
|
|
25
28
|
result
|
|
26
29
|
end
|
|
27
30
|
|
|
31
|
+
# Any HTTP answer counts, redirects are not followed, and only http(s) is tried. A local
|
|
32
|
+
# certificate is not checked. The URL comes from the project settings.
|
|
33
|
+
def self.answers?(url)
|
|
34
|
+
uri = URI(url)
|
|
35
|
+
return false unless %w[http https].include?(uri.scheme) && uri.host
|
|
36
|
+
|
|
37
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
|
|
38
|
+
open_timeout: 2, read_timeout: 3) { |http| http.head(uri.request_uri) }
|
|
39
|
+
true
|
|
40
|
+
rescue StandardError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The value of `header` in the answer at `url`, or nil. Same limits as answers?.
|
|
45
|
+
def self.header_at(url, header)
|
|
46
|
+
uri = URI(url)
|
|
47
|
+
return unless %w[http https].include?(uri.scheme) && uri.host
|
|
48
|
+
|
|
49
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
|
|
50
|
+
open_timeout: 2, read_timeout: 3) { |http| http.head(uri.request_uri)[header] }
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The URL a health check asks: an absolute `healthcheck`, or its path on the environment URL.
|
|
56
|
+
def self.healthcheck_url(environment)
|
|
57
|
+
check = environment["healthcheck"]
|
|
58
|
+
return environment["url"] if check.nil?
|
|
59
|
+
return check if check.start_with?("http://", "https://")
|
|
60
|
+
|
|
61
|
+
URI.join(environment["url"], check).to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
28
64
|
private
|
|
29
65
|
|
|
30
66
|
def facts(number)
|
|
31
67
|
local_sha = @shell.call("git", "rev-parse", "HEAD").strip
|
|
32
68
|
saved = SavedReport.new(sha: local_sha, shell: @shell).exist?
|
|
33
69
|
number ||= @github.pull_request_number_for_branch
|
|
34
|
-
return { "pull_request" => nil, "local_head" => local_sha, "saved_run" => saved, "next" => saved ? "open_pull_request" : "run_and_save" } unless number
|
|
70
|
+
return { "pull_request" => nil, "local_head" => local_sha, "saved_run" => saved, "scenarios" => [], "next" => saved ? "open_pull_request" : "run_and_save" } unless number
|
|
35
71
|
|
|
36
72
|
pull_request = @github.pull_request(number)
|
|
37
73
|
comments = @github.trusted_comments(number)
|
|
@@ -39,17 +75,25 @@ module BrowserReviewGate
|
|
|
39
75
|
assessment = Assessment.latest(comments)
|
|
40
76
|
assessment = nil unless assessment&.for?(head_sha)
|
|
41
77
|
labels = pull_request.fetch("labels").map { |label| label["name"] }
|
|
42
|
-
report = Report.latest(comments)
|
|
43
|
-
|
|
78
|
+
report = Report.latest(comments, config: @config)
|
|
79
|
+
commits = @github.pull_request_commits(number)
|
|
80
|
+
reuse = @config.scenarios["reuse_passing"]
|
|
81
|
+
verified = labels.include?(@config.label) && assessment&.covered_by?(report, commits: commits, reuse_passing: reuse) == true
|
|
44
82
|
waived = labels.include?(@config.waiver_label)
|
|
45
83
|
|
|
46
84
|
{ "pull_request" => number, "pr_head" => head_sha, "local_head" => local_sha,
|
|
47
85
|
"decision" => assessment&.decision, "reason" => assessment&.reason,
|
|
48
86
|
# What the run must exercise; each id is the `id` of the case that reports it.
|
|
49
|
-
"scenarios" => assessment ? assessment
|
|
87
|
+
"scenarios" => assessment ? scenarios(assessment, report, commits, reuse) : [], "verified" => verified, "waived" => waived,
|
|
50
88
|
"saved_run" => saved, "next" => next_step(assessment, verified || waived, saved, head_sha == local_sha) }
|
|
51
89
|
end
|
|
52
90
|
|
|
91
|
+
def scenarios(assessment, report, commits, reuse)
|
|
92
|
+
assessment.outstanding(report, commits: commits, reuse_passing: reuse).map do |scenario|
|
|
93
|
+
scenario.merge("status" => report ? report.status_of(scenario["id"], commits: commits) : "missing")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
53
97
|
def next_step(assessment, cleared, saved, on_pr_head)
|
|
54
98
|
return "nothing" if cleared || (assessment && !assessment.required?)
|
|
55
99
|
return "check_out_pr_head" unless on_pr_head
|
|
@@ -59,20 +103,18 @@ module BrowserReviewGate
|
|
|
59
103
|
"run_and_publish"
|
|
60
104
|
end
|
|
61
105
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
106
|
+
def environment_details(name)
|
|
107
|
+
environment = @config.environment(name)
|
|
108
|
+
return { "name" => nil, "reachable" => nil, "note" => "No environment is configured in #{Config::PATH}." } unless environment
|
|
109
|
+
|
|
110
|
+
details = environment.slice("name", "url", "healthcheck", "start_command", "sign_in")
|
|
111
|
+
details["reachable"] = environment["url"] ? @probe.call(Status.healthcheck_url(environment)) : nil
|
|
65
112
|
details
|
|
66
113
|
end
|
|
67
114
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
|
|
72
|
-
open_timeout: 2, read_timeout: 3) { |http| http.head(uri.path.empty? ? "/" : uri.path) }
|
|
73
|
-
true
|
|
74
|
-
rescue StandardError
|
|
75
|
-
false
|
|
115
|
+
def report_instructions
|
|
116
|
+
{ "schema_version" => Report::SCHEMA_VERSION, "results" => Report::RESULTS, "capabilities" => Report::CAPABILITIES,
|
|
117
|
+
"evidence_paths" => "relative to the directory of the report file, outside the repository" }
|
|
76
118
|
end
|
|
77
119
|
end
|
|
78
120
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!-- browser_review_gate:start -->
|
|
2
2
|
## Browser verification before human review
|
|
3
3
|
|
|
4
|
-
A PR that changes browser-visible behavior needs a browser run before a human reviews it; CI takes back reviewer requests until one is published. When asked to run `<%= command %>` (with or without a PR number), or to verify a branch or PR in the browser, read `<%= playbook_path %>` and follow it exactly. Right after opening a PR with `gh pr create`, run `browser-review-gate
|
|
4
|
+
A PR that changes browser-visible behavior needs a browser run before a human reviews it; CI takes back reviewer requests until one is published. When asked to run `<%= command %>` (with or without a PR number), or to verify a branch or PR in the browser, read `<%= playbook_path %>` and follow it exactly. Right after opening a PR with `gh pr create`, run `browser-review-gate verify`: it publishes a run saved for this commit.
|
|
5
5
|
<!-- browser_review_gate:end -->
|
|
@@ -1,10 +1,79 @@
|
|
|
1
1
|
# Settings for browser_review_gate. Every key is optional.
|
|
2
|
+
#
|
|
3
|
+
# [enforced] settings are checked by the gem: it refuses a report or keeps review on hold.
|
|
4
|
+
# [agent] settings are instructions the AI coding agent receives; the gem cannot make a browser
|
|
5
|
+
# tool take a screenshot, it can only refuse a run without one when `gate.require_evidence` is on.
|
|
2
6
|
|
|
3
|
-
# Where the
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
#
|
|
7
|
-
|
|
7
|
+
# Where the app runs for a browser run. The agent opens `url`; `healthcheck` (a path on `url`, or a
|
|
8
|
+
# full URL) is what `verify`, `status` and `doctor` ask to see whether the app answers. Nobody starts
|
|
9
|
+
# the app automatically: `start_command` is what the person runs. `sign_in` is a hint such as "use the
|
|
10
|
+
# seed account from db/seeds.rb"; never put real credentials here, this file is in the repository.
|
|
11
|
+
# A cloud agent cannot reach localhost: give it an environment with a URL it can reach (staging, a
|
|
12
|
+
# preview deployment) and select it with `--environment`.
|
|
13
|
+
environments:
|
|
14
|
+
local:
|
|
15
|
+
url:<%= url ? " #{url.inspect}" : "" %>
|
|
16
|
+
healthcheck:<%= url ? ' "/up"' : "" %>
|
|
17
|
+
start_command:<%= start_command ? " #{start_command.inspect}" : "" %>
|
|
18
|
+
sign_in:
|
|
19
|
+
# staging:
|
|
20
|
+
# url: "https://staging.example.com"
|
|
21
|
+
# healthcheck: "/up"
|
|
22
|
+
|
|
23
|
+
verification:
|
|
24
|
+
# The environment used when none is named with --environment.
|
|
25
|
+
environment: local
|
|
26
|
+
# [enforced] A case that passes but reports console errors / failed network requests counts as failed.
|
|
27
|
+
fail_on_console_errors: false
|
|
28
|
+
fail_on_network_errors: false
|
|
29
|
+
|
|
30
|
+
evidence:
|
|
31
|
+
# [agent] never, on_failure or always. [enforced] with gate.require_evidence, for tools that can.
|
|
32
|
+
screenshots: on_failure
|
|
33
|
+
trace: never
|
|
34
|
+
# [agent] Record console errors and failed requests per case. [enforced] with gate.require_evidence.
|
|
35
|
+
console_logs: true
|
|
36
|
+
network_logs: true
|
|
37
|
+
# [enforced] Saved runs and kept evidence files in the git directory are removed after this many days.
|
|
38
|
+
retention_days: 14
|
|
39
|
+
# [enforced] Hosts that evidence links may point to, e.g. your artifact bucket. Empty: no links.
|
|
40
|
+
link_hosts: []
|
|
41
|
+
|
|
42
|
+
scenarios:
|
|
43
|
+
# [enforced] The most scenarios the assessing model may ask for (1-30). Mandatory ones come on top.
|
|
44
|
+
max_count: 12
|
|
45
|
+
# [enforced] A passing case from an earlier commit of the PR keeps counting after unrelated commits.
|
|
46
|
+
# false: every scenario must pass on the newest commit.
|
|
47
|
+
reuse_passing: true
|
|
48
|
+
# [enforced] Scenarios added to every assessment that needs a run, whatever the model proposes. With
|
|
49
|
+
# `paths`, only when the change touches them, and then a run is needed whatever the model decides.
|
|
50
|
+
# `matrix: true` repeats the scenario for every combination below (at most 24 cases in total).
|
|
51
|
+
mandatory: []
|
|
52
|
+
# - id: SIGN-IN
|
|
53
|
+
# name: Sign in and land on the dashboard
|
|
54
|
+
# why: Every change to authentication must keep sign-in working.
|
|
55
|
+
# paths: ["app/controllers/sessions_controller.rb", "app/views/sessions/**"]
|
|
56
|
+
# matrix: true
|
|
57
|
+
matrix: {}
|
|
58
|
+
# role: [admin, member]
|
|
59
|
+
# viewport: [desktop, mobile]
|
|
60
|
+
|
|
61
|
+
gate:
|
|
62
|
+
# [enforced] Refuse runs whose passing cases lack the evidence asked for above.
|
|
63
|
+
require_evidence: false
|
|
64
|
+
# [enforced] Any failing case keeps review on hold, not only failing required scenarios.
|
|
65
|
+
block_on_failure: true
|
|
66
|
+
|
|
67
|
+
provenance:
|
|
68
|
+
# [enforced] Check each case's Playwright trace against the tested commit. off: no check. warn: the
|
|
69
|
+
# result is shown in the PR. require: a passing case counts only when its trace shows a response
|
|
70
|
+
# from the tested commit (via revision_header), a request to the case's page, and request times
|
|
71
|
+
# inside the run. The app must send the header: add BrowserReviewGate::RevisionHeader to the
|
|
72
|
+
# development middleware. With warn or require the agent is asked for a trace of every case.
|
|
73
|
+
trace: "off"
|
|
74
|
+
revision_header: "X-App-Revision"
|
|
75
|
+
check_pages: true
|
|
76
|
+
clock_skew_seconds: 300
|
|
8
77
|
|
|
9
78
|
# Who assesses a PR in CI. Unset: the Claude Code CLI with a CLAUDE_CODE_OAUTH_TOKEN or
|
|
10
79
|
# ANTHROPIC_API_KEY secret; with only an OPENAI_API_KEY or GEMINI_API_KEY secret, that provider's API.
|
|
@@ -1,56 +1,96 @@
|
|
|
1
1
|
<!-- browser_review_gate:managed <%= BrowserReviewGate::VERSION %> — re-run `browser-review-gate install` to update. -->
|
|
2
2
|
# Browser verification before human review
|
|
3
3
|
|
|
4
|
-
A pull request that changes what a person sees or does in a browser must be exercised in a browser before a human reviews it. CI decides whether a PR needs that and writes the decision in one "Browser verification" comment. Until a
|
|
4
|
+
A pull request that changes what a person sees or does in a browser must be exercised in a browser before a human reviews it. CI decides whether a PR needs that and writes the decision in one "Browser verification" comment, with the scenarios a run must pass. Until a run covering them is published, reviewer requests on such a PR are taken back.
|
|
5
5
|
|
|
6
6
|
This file is the playbook for the AI coding agent that does the run. The person starts it with `<%= command %> NUMBER` for an open PR, or `<%= command %>` on a branch that has no PR yet.
|
|
7
7
|
|
|
8
|
+
**Who does what.** You, the agent, drive the browser with your browser tool (Playwright, Chrome DevTools MCP or similar) and write down what you saw. `browser-review-gate` does the bookkeeping: which scenarios are owed, which environment to use, whether the app answers, and checking and publishing your report. It never drives a browser, and it cannot see what you did: your report is your account, so it must be accurate.
|
|
9
|
+
|
|
8
10
|
## Steps
|
|
9
11
|
|
|
10
|
-
1. Run `browser-review-gate
|
|
11
|
-
- `nothing`: no run is needed, or the PR is already verified. Say that review can be requested.
|
|
12
|
+
1. Run `browser-review-gate verify --json` (add `--pr NUMBER` when a number was given, `--environment NAME` when the person named one). Follow `outcome`:
|
|
13
|
+
- `nothing`: no run is needed, or the PR is already verified or waived. Say that review can be requested.
|
|
12
14
|
- `check_out_pr_head`: check out the PR head with a clean worktree, then start again.
|
|
13
|
-
- `
|
|
14
|
-
- `
|
|
15
|
+
- `assessment_requested`: CI was asked to assess the PR. Wait about a minute, then start again.
|
|
16
|
+
- `published` / `not_verified`: a run saved for this commit was published. Report the result and stop.
|
|
15
17
|
- `open_pull_request`: a run is saved and waits for the PR. Tell the person to open it.
|
|
16
|
-
- `ask_to_start_app`: the
|
|
18
|
+
- `ask_to_start_app`: the environment does not answer. Do not start it yourself: ask the person to start it (`environment.start_command` says how) and stop until they have.
|
|
17
19
|
- `run_and_publish` or `run_and_save`: continue with step 2.
|
|
18
20
|
2. Decide what to run.
|
|
19
|
-
- `scenarios`
|
|
20
|
-
- With no PR yet, or an empty list, choose the scenarios yourself: read the diff against the base branch, then `<%= rules_path %>`, which says what this project always checks
|
|
21
|
-
- Do not re-run
|
|
22
|
-
3. Open
|
|
23
|
-
4. Run every scenario end to end.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
- `scenarios` is the list CI wrote for this PR, with each one's `status` (`missing`, `failed`, `blocked`, `skipped`, `stale`). Run every one of them and give each case the `id` of its scenario. Scenarios marked `critical` or in the `mandatory` category come from the project settings; they cannot be left out. You may add cases of your own; you may not drop or rename one from the list.
|
|
22
|
+
- With no PR yet, or an empty list, choose the scenarios yourself: read the diff against the base branch, then `<%= rules_path %>`, which says what this project always checks. Its rules are mandatory. CI compares your cases with its own list once the PR is open and asks for what is missing.
|
|
23
|
+
- Do not re-run a scenario whose earlier case still counts; `verify` lists only what is owed.
|
|
24
|
+
3. Open `environment.url` in your browser tool. `environment.sign_in` is a sign-in hint. Never start, restart or stop the app yourself. Use test data and do not destroy existing data. If the app, the data or a browser tool is missing, report the blocker. Reading code and running unit tests does not replace a browser run.
|
|
25
|
+
4. Run every scenario end to end. For each case record:
|
|
26
|
+
- `url`: the page it was exercised on; `details`: what you observed;
|
|
27
|
+
- evidence, as `policy.agent` in the `verify` output asks: screenshots (`never`, `on_failure`, `always`), a trace, and the console errors and failed requests you saw (`[]` when there were none). Save files in a directory outside the repository, next to the report.
|
|
28
|
+
- When `policy.enforced.trace_check` is `warn` or `require`, record a Playwright trace (`.zip`) or a HAR network log (`.har`, which Playwright and Chrome DevTools can save) for every case, one per case, and list it as `evidence.trace`. If your browser tool can save neither, stop and tell the person: with `require`, every passing case would be refused. The gem opens it and checks that the app answered with the tested commit, that the case's page was requested, and that the requests fall between `started_at` and `finished_at`. With `require`, a passing case without such a trace does not count. Record the trace while running the case; a trace from another run will not match.
|
|
29
|
+
- A scenario you could not run is `blocked` (something prevented it) or `skipped` (deliberately not run), with a `reason`. Neither is a pass.
|
|
30
|
+
5. Write the report as JSON next to the evidence (see the shape below). Set `coverage_complete` to true only when every scenario from step 2 ran.
|
|
31
|
+
6. Publish, from the checkout that was tested, with everything committed and pushed: `browser-review-gate verify --report PATH` (add `--pr NUMBER` when the branch has several PRs). With a PR it is published; without one it is saved and published when the PR for this commit is opened.
|
|
32
|
+
7. Check the result. Exit status 0 means review can proceed; 7 means the report was published but review stays on hold (a scenario is missing, failed, or broke a project policy); 1 means the report was refused, with the reason. A refused report is not published: fix it and publish again.
|
|
33
|
+
8. Request a reviewer only when the person named one and only after the run passed. Reviewers whose requests CI took back earlier are asked again automatically.
|
|
34
|
+
|
|
35
|
+
## What the gem checks, and what it cannot
|
|
36
|
+
|
|
37
|
+
- It refuses a report with an unknown key, a missing field, an evidence path outside the report directory, a file that is not the image or archive its name says, a link to a host the project did not list, or a commit other than the checked-out one.
|
|
38
|
+
- With `gate.require_evidence`, a passing case without the screenshots, trace or logs the project asks for does not count, unless your tool lacks that capability (say so in `tool.capabilities`).
|
|
39
|
+
- With `verification.fail_on_console_errors` / `fail_on_network_errors`, a passing case that lists such errors counts as failed.
|
|
40
|
+
- Only cases tested on a commit of this PR count.
|
|
41
|
+
- With `provenance.trace`, it reads each case's Playwright trace and checks it against the tested commit and the case's page.
|
|
42
|
+
- It cannot tell whether a browser really did what you wrote. File digests show that files existed when the run was published, not who made them.
|
|
30
43
|
|
|
31
44
|
## When a scenario cannot be run
|
|
32
45
|
|
|
33
|
-
Some scenarios cannot be exercised locally, for example a sign-in through an external provider.
|
|
46
|
+
Some scenarios cannot be exercised locally, for example a sign-in through an external provider. Report it as `blocked` with the reason, or, when the person decided so, list it under `excluded`. Neither counts as a pass, so review stays on hold for that scenario: tell the person. Someone other than the author can put the `<%= waiver_label %>` label on the PR to let review proceed without a run.
|
|
34
47
|
|
|
35
48
|
## Report shape
|
|
36
49
|
|
|
37
50
|
```json
|
|
38
51
|
{
|
|
39
|
-
"
|
|
52
|
+
"schema_version": 2,
|
|
53
|
+
"tested_sha": "<full 40-character SHA of the commit that was tested>",
|
|
54
|
+
"environment": "local",
|
|
55
|
+
"tool": { "name": "Playwright MCP", "version": "0.0.40", "capabilities": ["screenshots", "trace", "console", "network"] },
|
|
56
|
+
"started_at": "2026-11-11T10:00:00Z",
|
|
57
|
+
"finished_at": "2026-11-11T10:06:30Z",
|
|
40
58
|
"coverage_complete": true,
|
|
41
59
|
"cases": [
|
|
42
|
-
{
|
|
60
|
+
{
|
|
61
|
+
"id": "BROWSER-1",
|
|
62
|
+
"name": "Submit the sign-up form and land on the dashboard",
|
|
63
|
+
"result": "pass",
|
|
64
|
+
"url": "/signup",
|
|
65
|
+
"details": "The form submitted; the dashboard showed the new account's name.",
|
|
66
|
+
"evidence": {
|
|
67
|
+
"screenshots": ["artifacts/signup-done.png"],
|
|
68
|
+
"trace": "artifacts/signup-trace.zip",
|
|
69
|
+
"console_errors": [],
|
|
70
|
+
"network_errors": []
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "SIGN-IN.member.mobile",
|
|
75
|
+
"name": "Sign in as a member at phone width",
|
|
76
|
+
"result": "blocked",
|
|
77
|
+
"reason": "The member seed account is missing from the local database.",
|
|
78
|
+
"details": "The sign-in form rejected the seed credentials."
|
|
79
|
+
}
|
|
43
80
|
],
|
|
44
81
|
"excluded": [
|
|
45
|
-
{ "name": "Sign-in through an external provider", "reason": "Needs a real provider account; covered by integration tests." }
|
|
82
|
+
{ "id": "OAUTH-1", "name": "Sign-in through an external provider", "reason": "Needs a real provider account; covered by integration tests." }
|
|
46
83
|
]
|
|
47
84
|
}
|
|
48
85
|
```
|
|
49
86
|
|
|
50
|
-
`result`
|
|
87
|
+
- `result`: `pass`, `fail`, `blocked` or `skipped`. `url` is an http(s) URL or a path starting with `/`; required for `pass` and `fail`. `reason` is required for `blocked` and `skipped`.
|
|
88
|
+
- `tool.capabilities`: what your browser tool could record, from `screenshots`, `trace`, `console`, `network`. Claim only what it really can.
|
|
89
|
+
- `evidence` paths are relative to the report file's directory. Accepted: `.png`, `.jpg`, `.jpeg`, `.webp` screenshots and a `.zip` Playwright trace or `.har` network log, at most 10 files of 20 MB each. `network_errors` entries may be strings or `{ "method", "url", "status" }`; query strings are dropped. `links` may point to evidence kept elsewhere, on hosts listed in `evidence.link_hosts`.
|
|
90
|
+
- Everything you write is scrubbed of values that look like credentials, but do not rely on it: never put cookies, tokens, passwords or customer data into a report, a screenshot or a trace. Keep customer and real company names out of the report.
|
|
51
91
|
|
|
52
92
|
## Rules
|
|
53
93
|
|
|
54
|
-
- Run the awkward path in the browser, or
|
|
55
|
-
- Keep the report file and
|
|
56
|
-
- After an unrelated commit, do not run everything again:
|
|
94
|
+
- Run the awkward path in the browser, or report it as blocked and leave review on hold. Do not declare a run done from tests and code reading.
|
|
95
|
+
- Keep the report file and evidence outside the repository. The publisher refuses a checkout with modified or untracked files and lists them.
|
|
96
|
+
- After an unrelated commit, do not run everything again: `verify` lists only what is new or no longer counts.
|
|
@@ -16,7 +16,10 @@ one is ignored. The built-in rules stay in force; these are added on top and win
|
|
|
16
16
|
## Always check
|
|
17
17
|
|
|
18
18
|
<!-- Example: Run every scenario once as an administrator and once as a regular member.
|
|
19
|
-
Example: Repeat the main scenario at a phone-sized window.
|
|
19
|
+
Example: Repeat the main scenario at a phone-sized window.
|
|
20
|
+
These are read by the model and the agent. To make a scenario or a role/viewport combination
|
|
21
|
+
impossible to leave out, declare it under `scenarios.mandatory` and `scenarios.matrix` in
|
|
22
|
+
.github/browser-review-gate.yml: those are added by the gem and checked case by case. -->
|
|
20
23
|
|
|
21
24
|
## Scenarios by area
|
|
22
25
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module BrowserReviewGate
|
|
6
|
+
# Reads a Playwright trace (trace.zip) or a HAR network log (.har, which Playwright, Chrome DevTools
|
|
7
|
+
# and other tools write) and checks that it shows the case really ran against the
|
|
8
|
+
# tested code: a response carried the app's revision header with the tested commit, the case's page
|
|
9
|
+
# was requested, and the requests fall inside the run's time window.
|
|
10
|
+
#
|
|
11
|
+
# This catches an agent that reports a run it did not do. It does not stop someone who builds a
|
|
12
|
+
# trace by hand; a trace is a file like any other.
|
|
13
|
+
class TraceCheck
|
|
14
|
+
NETWORK_ENTRY = /(?:\A|\/)(?:\d+-)?trace\.network\z/
|
|
15
|
+
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@settings = config.provenance
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns { "status" => "verified" | "failed", "revision" => ..., "requests" => n, "findings" => [...] }.
|
|
21
|
+
def call(path, tested_sha:, url:, started_at:, finished_at:)
|
|
22
|
+
requests = read(path)
|
|
23
|
+
findings = []
|
|
24
|
+
revisions = requests.filter_map { |request| request[:revision] }.uniq
|
|
25
|
+
matched = revisions.find { |revision| tested_sha.start_with?(revision) }
|
|
26
|
+
if revisions.empty?
|
|
27
|
+
findings << "no response carried the #{@settings["revision_header"]} header"
|
|
28
|
+
elsif matched.nil?
|
|
29
|
+
findings << "the app answered with revision #{revisions.first[0, 12]}, not the tested commit"
|
|
30
|
+
end
|
|
31
|
+
findings << "the case's page #{url} was not requested" if @settings["check_pages"] && url && !page_requested?(requests, url)
|
|
32
|
+
findings.concat(time_findings(requests, started_at, finished_at))
|
|
33
|
+
findings << "the trace has no network requests" if requests.empty?
|
|
34
|
+
|
|
35
|
+
{ "status" => findings.empty? ? "verified" : "failed", "revision" => matched && tested_sha[0, 12],
|
|
36
|
+
"requests" => requests.size, "findings" => findings.first(5) }.compact
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def read(path)
|
|
42
|
+
return read_har(path) if File.extname(path).casecmp?(".har")
|
|
43
|
+
|
|
44
|
+
requests = []
|
|
45
|
+
ZipReader.new(path).each(->(name) { NETWORK_ENTRY.match?(name) }) do |_name, content|
|
|
46
|
+
content.each_line do |line|
|
|
47
|
+
request = parse(line)
|
|
48
|
+
requests << request if request
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
requests
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read_har(path)
|
|
55
|
+
text = File.binread(path, ZipReader::MAX_ENTRY_BYTES + 1).to_s.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF")
|
|
56
|
+
log = JSON.parse(text)
|
|
57
|
+
entries = log.is_a?(Hash) ? log.dig("log", "entries") : nil
|
|
58
|
+
raise Error, "the HAR file has no log.entries" unless entries.is_a?(Array)
|
|
59
|
+
|
|
60
|
+
entries.filter_map { |entry| request(entry) }
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
raise Error, "the HAR file is not valid JSON"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# One HAR-like resource snapshot per line; anything else is skipped.
|
|
66
|
+
def parse(line)
|
|
67
|
+
event = JSON.parse(line)
|
|
68
|
+
event.is_a?(Hash) && event["type"] == "resource-snapshot" ? request(event["snapshot"]) : nil
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A HAR entry, which is also what a Playwright resource snapshot is.
|
|
74
|
+
def request(snapshot)
|
|
75
|
+
return unless snapshot.is_a?(Hash) && snapshot["request"].is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
headers = snapshot.dig("response", "headers")
|
|
78
|
+
header = Array(headers).find { |entry| entry.is_a?(Hash) && entry["name"].to_s.casecmp?(@settings["revision_header"]) }
|
|
79
|
+
{ url: snapshot["request"]["url"].to_s, time: time(snapshot["startedDateTime"]),
|
|
80
|
+
revision: header && header["value"].to_s.strip.downcase[/\A\h{7,40}\z/] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def time(value)
|
|
84
|
+
Time.iso8601(value.to_s)
|
|
85
|
+
rescue ArgumentError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The case's page counts as requested when a request has the same path, and the same host when
|
|
90
|
+
# the case names a full URL.
|
|
91
|
+
def page_requested?(requests, url)
|
|
92
|
+
wanted = URI.parse(url)
|
|
93
|
+
requests.any? do |request|
|
|
94
|
+
seen = URI.parse(request[:url])
|
|
95
|
+
seen.path.to_s.chomp("/") == wanted.path.to_s.chomp("/") && (wanted.host.nil? || wanted.host == seen.host)
|
|
96
|
+
rescue URI::InvalidURIError
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
rescue URI::InvalidURIError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def time_findings(requests, started_at, finished_at)
|
|
104
|
+
return [] unless started_at && finished_at
|
|
105
|
+
|
|
106
|
+
skew = @settings["clock_skew_seconds"]
|
|
107
|
+
from = Time.iso8601(started_at) - skew
|
|
108
|
+
to = Time.iso8601(finished_at) + skew
|
|
109
|
+
times = requests.filter_map { |request| request[:time] }
|
|
110
|
+
return [ "the trace has no request times" ] if times.empty? && requests.any?
|
|
111
|
+
return [] if times.empty?
|
|
112
|
+
|
|
113
|
+
outside = times.count { |seen| seen < from || seen > to }
|
|
114
|
+
outside.positive? ? [ "#{outside} of #{times.size} requests fall outside the run's started_at..finished_at" ] : []
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|