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,95 @@
|
|
|
1
|
+
module BrowserReviewGate
|
|
2
|
+
# One entry point for the verification workflow. Ruby does the bookkeeping: which PR, which decision,
|
|
3
|
+
# which scenarios are still owed, which environment, whether the app answers, and publishing what an
|
|
4
|
+
# agent observed. The browser run itself happens in the AI coding agent (Claude Code, Cursor, Codex)
|
|
5
|
+
# with its browser tool; nothing here drives a browser.
|
|
6
|
+
class Verify
|
|
7
|
+
# Exit statuses, so an agent or a script can branch on the outcome.
|
|
8
|
+
EXIT = {
|
|
9
|
+
"nothing" => 0, "published" => 0, "saved" => 0,
|
|
10
|
+
"run_and_publish" => 2, "run_and_save" => 2,
|
|
11
|
+
"ask_to_start_app" => 3,
|
|
12
|
+
"request_assessment" => 4, "assessment_requested" => 4,
|
|
13
|
+
"check_out_pr_head" => 5, "open_pull_request" => 5,
|
|
14
|
+
"publish_saved" => 6, "not_verified" => 7
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(github:, config:, prompts:, shell: Shell.new, probe: nil, publisher_factory: nil)
|
|
18
|
+
@github = github
|
|
19
|
+
@config = config
|
|
20
|
+
@prompts = prompts
|
|
21
|
+
@shell = shell
|
|
22
|
+
@probe = probe
|
|
23
|
+
@publisher_factory = publisher_factory || lambda { |number, path|
|
|
24
|
+
Publisher.new(github: github, number: number, report_path: path, config: config, shell: shell)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns [outcome, details]. `report` publishes (or saves, before the PR exists) a finished run.
|
|
29
|
+
# `request` lets a missing assessment be requested from CI.
|
|
30
|
+
def run(number: nil, environment: nil, report: nil, request: true)
|
|
31
|
+
status = Status.new(github: @github, config: @config, shell: @shell, probe: @probe, prompts: @prompts).to_h(number, environment: environment)
|
|
32
|
+
outcome = status["next"]
|
|
33
|
+
return [ submit(status, report), status ] if report && %w[run_and_publish run_and_save publish_saved].include?(outcome)
|
|
34
|
+
|
|
35
|
+
if outcome == "publish_saved"
|
|
36
|
+
outcome = @publisher_factory.call(status["pull_request"], nil).publish ? "published" : "not_verified"
|
|
37
|
+
elsif outcome == "request_assessment" && request
|
|
38
|
+
@github.dispatch_workflow(@config.workflow, status["pull_request"])
|
|
39
|
+
outcome = "assessment_requested"
|
|
40
|
+
end
|
|
41
|
+
[ outcome, status ]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.exit_status(outcome) = EXIT.fetch(outcome, 1)
|
|
45
|
+
|
|
46
|
+
# What to tell the person or the agent for each outcome.
|
|
47
|
+
def self.describe(outcome, status)
|
|
48
|
+
environment = status["environment"] || {}
|
|
49
|
+
case outcome
|
|
50
|
+
when "nothing" then "Nothing to run: #{status["waived"] ? "verification was waived" : "no browser run is needed or the PR is verified"}. Human review can be requested."
|
|
51
|
+
when "published" then "Browser verification published; human review can be requested."
|
|
52
|
+
when "saved" then "Browser run saved. It is published when the PR for this commit is opened."
|
|
53
|
+
when "not_verified" then "Results published, but required scenarios still need a passing case. Review stays on hold."
|
|
54
|
+
when "assessment_requested" then "CI has been asked to assess PR #{status["pull_request"]}. Run verify again in about a minute."
|
|
55
|
+
when "request_assessment" then "PR #{status["pull_request"]} has no assessment yet. Run verify without --no-request to ask CI for one."
|
|
56
|
+
when "check_out_pr_head" then "Check out the PR head (#{status["pr_head"]}) with a clean worktree, then run verify again."
|
|
57
|
+
when "open_pull_request" then "A run is saved for this commit. Open the pull request; it is published then."
|
|
58
|
+
when "ask_to_start_app" then "The #{environment["name"]} environment does not answer at #{environment["url"]}. Ask the person to start it" \
|
|
59
|
+
"#{environment["start_command"] ? " (#{environment["start_command"]})" : ""}; do not start it yourself."
|
|
60
|
+
else run_instructions(status)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.run_instructions(status)
|
|
65
|
+
environment = status["environment"] || {}
|
|
66
|
+
scenarios = status["scenarios"]
|
|
67
|
+
list = scenarios.any? ? scenarios.map { |scenario| " - #{scenario["id"]} [#{scenario["status"]}] #{scenario["name"]}" } :
|
|
68
|
+
[ " - none listed by CI yet: choose them from the diff and #{Prompts::RULES}" ]
|
|
69
|
+
[ "A browser run is needed#{status["pull_request"] ? " for PR ##{status["pull_request"]}" : ""}.",
|
|
70
|
+
"Environment: #{environment["name"] || "not configured"}#{environment["url"] ? " at #{environment["url"]}" : ""}.",
|
|
71
|
+
"Scenarios to run in your browser tool, one case per id:", *list,
|
|
72
|
+
*trace_note(status),
|
|
73
|
+
"Then: browser-review-gate verify --report PATH (publishes, or saves before the PR exists)." ].join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.trace_note(status)
|
|
77
|
+
mode = status.dig("policy", "enforced", "trace_check")
|
|
78
|
+
return [] if mode.nil? || mode == "off"
|
|
79
|
+
|
|
80
|
+
[ "Record a Playwright trace (.zip) or a HAR file (.har) for every case and list it as evidence.trace." \
|
|
81
|
+
"#{mode == "require" ? " Without one, a passing case is refused. If your browser tool can save neither, stop and tell the person." : ""}" ]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def submit(status, report)
|
|
87
|
+
if status["pull_request"]
|
|
88
|
+
@publisher_factory.call(status["pull_request"], report).publish ? "published" : "not_verified"
|
|
89
|
+
else
|
|
90
|
+
@publisher_factory.call(nil, report).save
|
|
91
|
+
"saved"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "zlib"
|
|
2
|
+
|
|
3
|
+
module BrowserReviewGate
|
|
4
|
+
# Reads selected entries of a zip archive into memory, using only zlib. Made for Playwright traces,
|
|
5
|
+
# which come from the agent and are untrusted: entry count, sizes and compression ratio are capped,
|
|
6
|
+
# nothing is written to disk, and entry names are only compared, never used as paths.
|
|
7
|
+
class ZipReader
|
|
8
|
+
MAX_ENTRIES = 10_000
|
|
9
|
+
MAX_ENTRY_BYTES = 50 * 1024 * 1024
|
|
10
|
+
MAX_TOTAL_BYTES = 200 * 1024 * 1024
|
|
11
|
+
MAX_RATIO = 200
|
|
12
|
+
END_OF_DIRECTORY = "PK\x05\x06".b
|
|
13
|
+
DIRECTORY_ENTRY = "PK\x01\x02".b
|
|
14
|
+
LOCAL_ENTRY = "PK\x03\x04".b
|
|
15
|
+
|
|
16
|
+
def initialize(path)
|
|
17
|
+
@path = path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Yields the name and content of every entry whose name the block's filter accepts.
|
|
21
|
+
def each(filter)
|
|
22
|
+
total = 0
|
|
23
|
+
File.open(@path, "rb") do |file|
|
|
24
|
+
directory(file).each do |entry|
|
|
25
|
+
next unless filter.call(entry[:name])
|
|
26
|
+
|
|
27
|
+
content = read(file, entry)
|
|
28
|
+
total += content.bytesize
|
|
29
|
+
raise Error, "trace is larger than #{MAX_TOTAL_BYTES / 1024 / 1024} MB uncompressed" if total > MAX_TOTAL_BYTES
|
|
30
|
+
|
|
31
|
+
yield entry[:name], content
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def directory(file)
|
|
39
|
+
size = file.size
|
|
40
|
+
tail_size = [ size, 65_557 ].min
|
|
41
|
+
file.seek(size - tail_size)
|
|
42
|
+
tail = file.read(tail_size)
|
|
43
|
+
at = tail.rindex(END_OF_DIRECTORY) or raise Error, "trace is not a zip archive"
|
|
44
|
+
count, _directory_size, offset = tail[at + 10, 10].unpack("vVV")
|
|
45
|
+
raise Error, "trace has too many entries" if count > MAX_ENTRIES
|
|
46
|
+
|
|
47
|
+
file.seek(offset)
|
|
48
|
+
Array.new(count) { directory_entry(file) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def directory_entry(file)
|
|
52
|
+
header = file.read(46)
|
|
53
|
+
raise Error, "trace zip directory is damaged" unless header&.start_with?(DIRECTORY_ENTRY)
|
|
54
|
+
|
|
55
|
+
method, compressed, uncompressed, name_length, extra_length, comment_length, offset =
|
|
56
|
+
header.unpack("@10v@20VV@28vvv@42V")
|
|
57
|
+
name = file.read(name_length).to_s.force_encoding(Encoding::UTF_8)
|
|
58
|
+
file.seek(extra_length + comment_length, IO::SEEK_CUR)
|
|
59
|
+
{ name: name, method: method, compressed: compressed, uncompressed: uncompressed, offset: offset }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read(file, entry)
|
|
63
|
+
raise Error, "trace entry #{entry[:name][0, 80]} is too large" if entry[:uncompressed] > MAX_ENTRY_BYTES
|
|
64
|
+
if entry[:compressed].positive? && entry[:uncompressed] / entry[:compressed] > MAX_RATIO
|
|
65
|
+
raise Error, "trace entry #{entry[:name][0, 80]} is compressed suspiciously well"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
position = file.pos
|
|
69
|
+
file.seek(entry[:offset])
|
|
70
|
+
local = file.read(30)
|
|
71
|
+
raise Error, "trace zip entry is damaged" unless local&.start_with?(LOCAL_ENTRY)
|
|
72
|
+
|
|
73
|
+
name_length, extra_length = local.unpack("@26vv")
|
|
74
|
+
file.seek(name_length + extra_length, IO::SEEK_CUR)
|
|
75
|
+
data = file.read(entry[:compressed]).to_s
|
|
76
|
+
file.seek(position)
|
|
77
|
+
content = case entry[:method]
|
|
78
|
+
when 0 then data
|
|
79
|
+
when 8 then inflate(data)
|
|
80
|
+
else raise Error, "trace entry #{entry[:name][0, 80]} uses an unsupported compression method"
|
|
81
|
+
end
|
|
82
|
+
raise Error, "trace entry #{entry[:name][0, 80]} is not the size its header says" unless content.bytesize == entry[:uncompressed]
|
|
83
|
+
|
|
84
|
+
content
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Stops as soon as the output passes the entry cap, so a lying header cannot exhaust memory.
|
|
88
|
+
def inflate(data)
|
|
89
|
+
inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
|
|
90
|
+
out = +""
|
|
91
|
+
(0...data.bytesize).step(4096) do |offset|
|
|
92
|
+
out << inflater.inflate(data.byteslice(offset, 4096))
|
|
93
|
+
raise Error, "trace entry is larger than it says" if out.bytesize > MAX_ENTRY_BYTES
|
|
94
|
+
end
|
|
95
|
+
out << inflater.finish
|
|
96
|
+
out
|
|
97
|
+
rescue Zlib::Error
|
|
98
|
+
raise Error, "trace entry is not valid deflate data"
|
|
99
|
+
ensure
|
|
100
|
+
inflater&.close
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/browser_review_gate.rb
CHANGED
|
@@ -23,6 +23,12 @@ module BrowserReviewGate
|
|
|
23
23
|
autoload :Assessment, File.join(__dir__, "browser_review_gate/assessment")
|
|
24
24
|
autoload :Assessor, File.join(__dir__, "browser_review_gate/assessor")
|
|
25
25
|
autoload :CLI, File.join(__dir__, "browser_review_gate/cli")
|
|
26
|
+
autoload :Doctor, File.join(__dir__, "browser_review_gate/doctor")
|
|
27
|
+
autoload :Evidence, File.join(__dir__, "browser_review_gate/evidence")
|
|
28
|
+
autoload :RevisionHeader, File.join(__dir__, "browser_review_gate/revision_header")
|
|
29
|
+
autoload :TraceCheck, File.join(__dir__, "browser_review_gate/trace_check")
|
|
30
|
+
autoload :ZipReader, File.join(__dir__, "browser_review_gate/zip_reader")
|
|
31
|
+
autoload :Verify, File.join(__dir__, "browser_review_gate/verify")
|
|
26
32
|
autoload :Config, File.join(__dir__, "browser_review_gate/config")
|
|
27
33
|
autoload :Gate, File.join(__dir__, "browser_review_gate/gate")
|
|
28
34
|
autoload :GitHub, File.join(__dir__, "browser_review_gate/github")
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: browser_review_gate
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Islam Gagiev
|
|
@@ -43,6 +43,8 @@ files:
|
|
|
43
43
|
- lib/browser_review_gate/assessor.rb
|
|
44
44
|
- lib/browser_review_gate/cli.rb
|
|
45
45
|
- lib/browser_review_gate/config.rb
|
|
46
|
+
- lib/browser_review_gate/doctor.rb
|
|
47
|
+
- lib/browser_review_gate/evidence.rb
|
|
46
48
|
- lib/browser_review_gate/gate.rb
|
|
47
49
|
- lib/browser_review_gate/github.rb
|
|
48
50
|
- lib/browser_review_gate/hook.rb
|
|
@@ -53,6 +55,7 @@ files:
|
|
|
53
55
|
- lib/browser_review_gate/prompts.rb
|
|
54
56
|
- lib/browser_review_gate/publisher.rb
|
|
55
57
|
- lib/browser_review_gate/report.rb
|
|
58
|
+
- lib/browser_review_gate/revision_header.rb
|
|
56
59
|
- lib/browser_review_gate/saved_report.rb
|
|
57
60
|
- lib/browser_review_gate/shell.rb
|
|
58
61
|
- lib/browser_review_gate/stats.rb
|
|
@@ -65,7 +68,10 @@ files:
|
|
|
65
68
|
- lib/browser_review_gate/templates/policy.md
|
|
66
69
|
- lib/browser_review_gate/templates/rules.md.erb
|
|
67
70
|
- lib/browser_review_gate/templates/workflow.yml.erb
|
|
71
|
+
- lib/browser_review_gate/trace_check.rb
|
|
72
|
+
- lib/browser_review_gate/verify.rb
|
|
68
73
|
- lib/browser_review_gate/version.rb
|
|
74
|
+
- lib/browser_review_gate/zip_reader.rb
|
|
69
75
|
- lib/generators/browser_review_gate/install/install_generator.rb
|
|
70
76
|
homepage: https://github.com/Halvanhelv/browser_review_gate
|
|
71
77
|
licenses:
|