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,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
@@ -1,3 +1,3 @@
1
1
  module BrowserReviewGate
2
- VERSION = "0.2.2"
2
+ VERSION = "0.4.0"
3
3
  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
@@ -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.2.2
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: