browser_review_gate 0.1.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,81 @@
1
+ require "json"
2
+
3
+ module BrowserReviewGate
4
+ # Everything the gate reads from or writes to a pull request, through the `gh` CLI.
5
+ class GitHub
6
+ def initialize(repository: nil, shell: Shell.new)
7
+ @repository = repository.to_s.empty? ? nil : repository
8
+ @shell = shell
9
+ end
10
+
11
+ def repository
12
+ @repository ||= JSON.parse(@shell.call("gh", "repo", "view", "--json", "nameWithOwner")).fetch("nameWithOwner")
13
+ end
14
+
15
+ def pull_request(number)
16
+ api("repos/#{repository}/pulls/#{number}")
17
+ end
18
+
19
+ # The PR of the current branch, or nil when the branch has none.
20
+ def pull_request_number_for_branch
21
+ JSON.parse(@shell.call("gh", "pr", "view", "--json", "number")).fetch("number")
22
+ rescue Error
23
+ nil
24
+ end
25
+
26
+ def comments(number)
27
+ pages("repos/#{repository}/issues/#{number}/comments")
28
+ end
29
+
30
+ def files(number)
31
+ pages("repos/#{repository}/pulls/#{number}/files")
32
+ end
33
+
34
+ def login
35
+ @shell.call("gh", "api", "user", "--jq", ".login").strip
36
+ end
37
+
38
+ def create_comment(number, body)
39
+ api("repos/#{repository}/issues/#{number}/comments", method: "POST", input: { body: body })
40
+ end
41
+
42
+ def update_comment(comment_id, body)
43
+ api("repos/#{repository}/issues/comments/#{comment_id}", method: "PATCH", input: { body: body })
44
+ end
45
+
46
+ # Adding a label to a PR creates the label in the repository when it does not exist yet.
47
+ def add_label(number, label)
48
+ api("repos/#{repository}/issues/#{number}/labels", method: "POST", input: { labels: [ label ] })
49
+ end
50
+
51
+ def remove_label(number, label)
52
+ @shell.call("gh", "api", "-X", "DELETE", "repos/#{repository}/issues/#{number}/labels/#{label}")
53
+ end
54
+
55
+ def remove_reviewers(number, reviewers:, team_reviewers:)
56
+ api("repos/#{repository}/pulls/#{number}/requested_reviewers", method: "DELETE",
57
+ input: { reviewers: reviewers, team_reviewers: team_reviewers })
58
+ end
59
+
60
+ def dispatch_workflow(workflow, number)
61
+ @shell.call("gh", "workflow", "run", workflow, "-f", "pr_number=#{number}")
62
+ end
63
+
64
+ private
65
+
66
+ def api(path, method: "GET", input: nil)
67
+ argv = [ "gh", "api", "-X", method, path ]
68
+ argv += [ "--input", "-" ] if input
69
+ output = @shell.call(*argv, stdin_data: input && JSON.generate(input))
70
+ output.strip.empty? ? {} : JSON.parse(output)
71
+ rescue JSON::ParserError
72
+ raise Error, "gh returned output that is not JSON for #{path}"
73
+ end
74
+
75
+ def pages(path)
76
+ JSON.parse(@shell.call("gh", "api", "--paginate", "--slurp", "#{path}?per_page=100")).flatten(1)
77
+ rescue JSON::ParserError
78
+ raise Error, "gh returned output that is not JSON for #{path}"
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,36 @@
1
+ require "json"
2
+
3
+ module BrowserReviewGate
4
+ # Runs after an agent's shell command. When the command opened a PR, the browser run saved for this
5
+ # commit is published. Never fails the agent: every outcome is reported as a message.
6
+ class Hook
7
+ TRIGGER = "gh pr create"
8
+
9
+ def initialize(publisher_factory:, shell: Shell.new)
10
+ @publisher_factory = publisher_factory
11
+ @shell = shell
12
+ end
13
+
14
+ # `payload` is the raw hook input. Returns a message for the agent, or nil when nothing was done.
15
+ def call(payload)
16
+ return unless payload.to_s.include?(TRIGGER)
17
+
18
+ sha = @shell.call("git", "rev-parse", "HEAD").strip
19
+ return unless SavedReport.new(sha: sha, shell: @shell).exist?
20
+
21
+ verified = @publisher_factory.call.publish
22
+ verified ? "Saved browser verification published; human review can be requested." :
23
+ "Saved browser results published, but some required cases still need to pass."
24
+ rescue Error => error
25
+ "Saved browser verification was not published: #{error.message}"
26
+ end
27
+
28
+ # Claude Code and Codex read a JSON object; other agents show plain text.
29
+ def self.render(message, agent)
30
+ return if message.nil?
31
+ return message unless %w[claude codex].include?(agent)
32
+
33
+ JSON.generate(hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: message })
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,171 @@
1
+ require "erb"
2
+ require "fileutils"
3
+ require "json"
4
+
5
+ module BrowserReviewGate
6
+ # Sets a project up in one go: the CI workflow, the settings file, the agent playbook, and the
7
+ # entry points for every AI coding agent the project uses. Safe to run again after a gem update.
8
+ class Installer
9
+ AGENTS = %w[claude cursor codex].freeze
10
+ MANAGED = "browser_review_gate:managed"
11
+ PLAYBOOK = "docs/browser-review-gate.md"
12
+ HOOK = "browser-review-gate hook"
13
+ BLOCK = /<!-- browser_review_gate:start -->.*?<!-- browser_review_gate:end -->\n?/m
14
+
15
+ def initialize(root:, log: $stdout, agents: nil, gem_source: nil, workflow: nil, force: false, dry_run: false)
16
+ @root = root
17
+ @log = log
18
+ @agents = agents
19
+ @gem_source = gem_source
20
+ @workflow = workflow
21
+ @force = force
22
+ @dry_run = dry_run
23
+ end
24
+
25
+ def install
26
+ agents = @agents || detect_agents
27
+ unknown = agents - AGENTS
28
+ raise Error, "Unknown agents: #{unknown.join(", ")} (known: #{AGENTS.join(", ")})" if unknown.any?
29
+
30
+ preflight(agents)
31
+ create_only(Config::PATH, render("config.yml"))
32
+ managed(".github/workflows/#{workflow}", render("workflow.yml"))
33
+ managed(PLAYBOOK, render("playbook.md"))
34
+ agents.each { |agent| send("install_#{agent}") }
35
+ @log.puts "", "Agents: #{agents.join(", ")}.", next_steps
36
+ agents
37
+ end
38
+
39
+ # Which AI coding agents the project is set up for. AGENTS.md is read by most of them, so it is
40
+ # the fallback when nothing is found.
41
+ def detect_agents
42
+ found = []
43
+ found << "claude" if exist?(".claude") || exist?("CLAUDE.md")
44
+ found << "cursor" if exist?(".cursor") || exist?(".cursorrules")
45
+ found << "codex" if exist?(".codex") || exist?("AGENTS.md")
46
+ found.empty? ? [ "codex" ] : found
47
+ end
48
+
49
+ private
50
+
51
+ def config = @config ||= Config.load(@root)
52
+ def workflow = @workflow || config.workflow
53
+ def command = config.command
54
+ def label = config.label
55
+ def playbook_path = PLAYBOOK
56
+ def exist?(path) = File.exist?(File.join(@root, path))
57
+
58
+ def start_command
59
+ return "bin/dev" if exist?("bin/dev")
60
+
61
+ "npm run dev" if exist?("package.json")
62
+ end
63
+
64
+ def url = exist?("config/application.rb") ? "http://localhost:3000" : nil
65
+
66
+ # setup-ruby reads the project's own version file when there is one.
67
+ def ruby_version = exist?(".ruby-version") || exist?(".tool-versions") ? nil : "3.4"
68
+
69
+ # How CI gets the gem: from RubyGems, from a git repository ("git:URL" or "git:URL#REF"), or from a
70
+ # copy kept in the project itself ("path:DIR", read from the base branch like the rest of the checkout).
71
+ def install_command
72
+ return "gem install browser_review_gate -v #{VERSION} --no-document" if @gem_source.nil? || @gem_source == "rubygems"
73
+ return %(echo "$GITHUB_WORKSPACE/#{@gem_source.delete_prefix("path:")}/exe" >> "$GITHUB_PATH") if @gem_source.match?(%r{\Apath:[\w./-]+\z})
74
+
75
+ url, ref = @gem_source.delete_prefix("git:").split("#", 2)
76
+ raise Error, "--gem-source must be rubygems, git:URL[#REF] or path:DIR" unless @gem_source.start_with?("git:") && !url.to_s.empty?
77
+
78
+ %(git clone --quiet --depth 1 --branch "#{ref || "v#{VERSION}"}" "#{url}" "$RUNNER_TEMP/browser_review_gate" && ) +
79
+ %(echo "$RUNNER_TEMP/browser_review_gate/exe" >> "$GITHUB_PATH")
80
+ end
81
+
82
+ JSON_FILES = { "claude" => ".claude/settings.json", "cursor" => ".cursor/hooks.json", "codex" => ".codex/hooks.json" }.freeze
83
+
84
+ # Nothing is written when a settings file that must be merged cannot be read.
85
+ def preflight(agents)
86
+ agents.filter_map { |agent| JSON_FILES[agent] }.each do |path|
87
+ JSON.parse(File.read(File.join(@root, path))) if exist?(path)
88
+ rescue JSON::ParserError
89
+ raise Error, "#{path} is not valid JSON; fix it and run install again"
90
+ end
91
+ end
92
+
93
+ def install_claude
94
+ managed(".claude/skills/browser-pr-verification/SKILL.md", render("claude_skill.md"))
95
+ merge_json(".claude/settings.json") do |settings|
96
+ entries = ((settings["hooks"] ||= {})["PostToolUse"] ||= [])
97
+ entries << { "matcher" => "Bash", "hooks" => [ { "type" => "command", "command" => "#{HOOK} claude", "timeout" => 60 } ] }
98
+ end
99
+ end
100
+
101
+ def install_cursor
102
+ managed(".cursor/commands/browser-pr-verification.md", render("cursor_command.md"))
103
+ merge_json(".cursor/hooks.json") do |settings|
104
+ settings["version"] ||= 1
105
+ ((settings["hooks"] ||= {})["afterShellExecution"] ||= []) << { "command" => "#{HOOK} cursor" }
106
+ end
107
+ end
108
+
109
+ # Codex runs a project hook only after the person trusts it in /hooks, so the AGENTS.md section also
110
+ # tells the agent to publish after it opens the PR.
111
+ def install_codex
112
+ merge_json(".codex/hooks.json") do |settings|
113
+ entries = ((settings["hooks"] ||= {})["PostToolUse"] ||= [])
114
+ entries << { "matcher" => "^Bash$", "hooks" => [ { "type" => "command", "command" => "#{HOOK} codex", "timeout" => 60 } ] }
115
+ end
116
+ path = "AGENTS.md"
117
+ current = exist?(path) ? File.read(File.join(@root, path)) : nil
118
+ block = render("agents_block.md")
119
+ updated = current.to_s.match?(BLOCK) ? current.sub(BLOCK) { block } : [ current.to_s.rstrip, block ].reject(&:empty?).join("\n\n")
120
+ write(path, updated.end_with?("\n") ? updated : "#{updated}\n", current)
121
+ end
122
+
123
+ def render(name)
124
+ ERB.new(File.read(File.join(__dir__, "templates", "#{name}.erb")), trim_mode: "-").result(binding)
125
+ end
126
+
127
+ def create_only(path, content)
128
+ return @log.puts(" exists #{path}") if exist?(path)
129
+
130
+ write(path, content, nil)
131
+ end
132
+
133
+ # A file this gem wrote before is updated; a file someone else wrote is left alone without --force.
134
+ def managed(path, content)
135
+ current = exist?(path) ? File.read(File.join(@root, path)) : nil
136
+ return @log.puts(" skipped #{path} (not written by browser_review_gate; use --force to replace)") if current && !current.include?(MANAGED) && !@force
137
+
138
+ write(path, content, current)
139
+ end
140
+
141
+ # Adds the hook entry unless the file already runs the hook.
142
+ def merge_json(path)
143
+ current = exist?(path) ? File.read(File.join(@root, path)) : nil
144
+ return @log.puts(" unchanged #{path}") if current&.include?(HOOK)
145
+
146
+ settings = current ? JSON.parse(current) : {}
147
+ yield settings
148
+ write(path, "#{JSON.pretty_generate(settings)}\n", current)
149
+ end
150
+
151
+ def write(path, content, current)
152
+ return @log.puts(" unchanged #{path}") if current == content
153
+
154
+ @log.puts " #{current ? "updated " : "created "} #{path}"
155
+ return if @dry_run
156
+
157
+ full_path = File.join(@root, path)
158
+ FileUtils.mkdir_p(File.dirname(full_path))
159
+ File.write(full_path, content)
160
+ end
161
+
162
+ def next_steps
163
+ <<~TEXT
164
+ Next:
165
+ 1. Add a CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret to the repository.
166
+ 2. Fill start_command, url and sign_in in #{Config::PATH}.
167
+ 3. Commit and merge to the default branch: the workflow runs from there.
168
+ TEXT
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,8 @@
1
+ module BrowserReviewGate
2
+ module Markdown
3
+ # Comment text comes from a model or a report file, so it must not start markup or ping anyone.
4
+ def self.escape(value)
5
+ value.to_s.gsub(/[\\`*_{}\[\]()<>#+.!|@]/) { |character| "\\#{character}" }.gsub(/[\r\n]+/, " ")
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,70 @@
1
+ require "json"
2
+ require "open3"
3
+ require "tmpdir"
4
+
5
+ module BrowserReviewGate
6
+ # One headless Claude Code call with every tool disabled and the answer bound to a JSON schema, so
7
+ # the model can only answer. The GitHub token is removed from its environment.
8
+ class ModelClient
9
+ NPX = %w[npx --yes @anthropic-ai/claude-code@2.1.288].freeze
10
+ CREDENTIALS = %w[CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY].freeze
11
+
12
+ # `runner` takes the environment, argv and stdin, and returns [stdout, stderr, success?].
13
+ def initialize(command: nil, model: nil, runner: nil)
14
+ @command = command.to_s.empty? ? default_command : command.split
15
+ @model = model
16
+ @runner = runner || method(:run)
17
+ end
18
+
19
+ # Returns the model's answer as JSON text. Raises Error when the call fails or gives no answer.
20
+ def complete(system:, user:, schema:)
21
+ argv = @command + [ "--print", "--tools", "", "--setting-sources", "", "--system-prompt", system,
22
+ "--json-schema", JSON.generate(schema), "--output-format", "json" ]
23
+ argv += [ "--model", @model ] if @model
24
+ stdout, stderr, success = @runner.call(environment, argv, user)
25
+ result = parse(stdout)
26
+ answer = result["structured_output"] if success && result["is_error"] == false
27
+ return JSON.generate(answer) if answer.is_a?(Hash)
28
+
29
+ raise Error, "The model gave no answer: #{failure(result, stdout, stderr)}"
30
+ end
31
+
32
+ private
33
+
34
+ def parse(stdout)
35
+ result = JSON.parse(stdout.to_s)
36
+ result.is_a?(Hash) ? result : {}
37
+ rescue JSON::ParserError
38
+ {}
39
+ end
40
+
41
+ # The CLI explains a failure in `result`; a missing credential is the usual cause in CI.
42
+ def failure(result, stdout, stderr)
43
+ reason = result["result"].to_s.strip
44
+ reason = "#{stderr} #{stdout}".strip[0, 300] if reason.empty?
45
+ missing = CREDENTIALS.all? { |name| ENV[name].to_s.empty? }
46
+ missing ? "#{reason} (neither #{CREDENTIALS.join(" nor ")} is set)" : reason
47
+ end
48
+
49
+ # An installed `claude` is used as is; CI has none and fetches a pinned version on demand.
50
+ def default_command
51
+ installed = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "claude")) }
52
+ installed ? [ "claude" ] : NPX
53
+ end
54
+
55
+ # Empty credentials are unset: the workflow passes both kinds and only one is configured.
56
+ def environment
57
+ unset = %w[GH_TOKEN GITHUB_TOKEN].to_h { |name| [ name, nil ] }
58
+ CREDENTIALS.each { |name| unset[name] = nil if ENV[name].to_s.empty? }
59
+ unset
60
+ end
61
+
62
+ # Runs outside the checkout so the CLI loads no project instructions or settings.
63
+ def run(environment, argv, stdin)
64
+ stdout, stderr, status = Open3.capture3(environment, *argv, stdin_data: stdin, chdir: Dir.tmpdir)
65
+ [ stdout, stderr, status.success? ]
66
+ rescue Errno::ENOENT
67
+ raise Error, "#{argv.first} is not installed"
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,136 @@
1
+ require "json"
2
+
3
+ module BrowserReviewGate
4
+ # Publishes locally observed browser results to the PR, or saves them until the PR exists.
5
+ class Publisher
6
+ # Without `number` the PR of the current branch is used. Without `report_path` the report saved for
7
+ # the current commit is used.
8
+ def initialize(github:, number: nil, report_path: nil, config: Config.new, shell: Shell.new)
9
+ @github = github
10
+ @number = number
11
+ @report_path = report_path
12
+ @config = config
13
+ @shell = shell
14
+ end
15
+
16
+ # Returns true when the PR is verified, false when the report was published but does not pass.
17
+ def publish
18
+ number = @number || @github.pull_request_number_for_branch
19
+ raise Error, "This branch has no pull request yet; save the run with --save instead" unless number
20
+
21
+ pull_request = @github.pull_request(number)
22
+ local_sha = head_sha
23
+ ensure_clean_worktree!
24
+ pr_sha = pull_request.fetch("head").fetch("sha")
25
+ raise Error, "Local HEAD #{local_sha} does not match the PR head #{pr_sha}" unless pr_sha == local_sha
26
+
27
+ saved_report = SavedReport.new(sha: local_sha, shell: @shell)
28
+ data = load_report(saved_report)
29
+ ensure_report_sha!(data, local_sha)
30
+ actor = @github.login
31
+ data["verified_by"] = "AI browser agent (run by #{actor})"
32
+
33
+ comments = @github.comments(number)
34
+ ensure_browser_run_wanted!(number, comments, local_sha)
35
+ report = merged_report(data, comments)
36
+
37
+ complete = report.passed?
38
+ labelled = pull_request.fetch("labels").any? { |label| label["name"] == @config.label }
39
+ @github.remove_label(number, @config.label) if labelled && !complete
40
+ write_comment(number, comments, actor, body(report, complete))
41
+ @github.add_label(number, @config.label) if complete
42
+ saved_report.delete unless @report_path
43
+ complete
44
+ rescue KeyError => error
45
+ raise Error, "Browser verification data is incomplete: #{error.message}"
46
+ rescue JSON::ParserError => error
47
+ raise Error, "Browser verification data is not valid JSON: #{error.message}"
48
+ end
49
+
50
+ # Keeps the results of a run made before the PR exists. Returns the path they were saved to.
51
+ def save
52
+ local_sha = head_sha
53
+ ensure_clean_worktree!
54
+ data = read_report_file
55
+ ensure_report_sha!(data, local_sha)
56
+ report = Report.new(data.merge("verified_by" => "pending"))
57
+ raise Error, report.errors.join("; ") unless report.valid?
58
+
59
+ saved_report = SavedReport.new(sha: local_sha, shell: @shell)
60
+ saved_report.write(data)
61
+ saved_report.path
62
+ rescue JSON::ParserError => error
63
+ raise Error, "Browser verification data is not valid JSON: #{error.message}"
64
+ end
65
+
66
+ private
67
+
68
+ def head_sha = @shell.call("git", "rev-parse", "HEAD").strip
69
+
70
+ # An untracked file may be code the browser run depended on, so it blocks publishing too.
71
+ def ensure_clean_worktree!
72
+ changes = @shell.call("git", "status", "--porcelain").lines.map(&:chomp)
73
+ return if changes.empty?
74
+
75
+ raise Error, "Commit and push changes before publishing browser verification, or remove these files:\n" \
76
+ "#{changes.join("\n")}"
77
+ end
78
+
79
+ def ensure_report_sha!(data, sha)
80
+ return if data.is_a?(Hash) && data["tested_sha"] == sha
81
+
82
+ raise Error, "Report tested_sha must match the current commit #{sha}"
83
+ end
84
+
85
+ def load_report(saved_report)
86
+ return read_report_file if @report_path
87
+ return saved_report.read if saved_report.exist?
88
+
89
+ raise Error, "No saved browser verification report for the current commit"
90
+ end
91
+
92
+ def read_report_file
93
+ raise Error, "A report file is required" unless @report_path
94
+
95
+ JSON.parse(File.read(@report_path))
96
+ rescue SystemCallError => error
97
+ raise Error, "Cannot read report file #{@report_path}: #{error.message}"
98
+ end
99
+
100
+ # A report made before the PR existed may arrive before the gate has assessed the commit.
101
+ def ensure_browser_run_wanted!(number, comments, tested_sha)
102
+ assessment = Assessment.latest(comments)
103
+ return unless assessment&.for?(tested_sha)
104
+
105
+ raise Error, "PR #{number} is not marked as requiring browser testing" unless assessment.required?
106
+ end
107
+
108
+ def merged_report(data, comments)
109
+ submitted = Report.new(data)
110
+ raise Error, submitted.errors.join("; ") unless submitted.valid?
111
+
112
+ prior = Report.latest_comment(comments)
113
+ report = Report.merge(prior ? Report.data_from(prior["body"]) : {}, submitted.to_h)
114
+ raise Error, report.errors.join("; ") unless report.valid?
115
+
116
+ report
117
+ end
118
+
119
+ def body(report, complete)
120
+ return report.markdown if complete
121
+
122
+ [ "**Not verified.** Complete every required browser scenario and pass all reported cases before human review.",
123
+ "", report.markdown ].join("\n")
124
+ end
125
+
126
+ # Only the author's own earlier report comment can be edited in place.
127
+ def write_comment(number, comments, actor, body)
128
+ prior = Report.latest_comment(comments)
129
+ if prior && prior.dig("user", "login") == actor
130
+ @github.update_comment(prior.fetch("id"), body)
131
+ else
132
+ @github.create_comment(number, body)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,154 @@
1
+ require "json"
2
+
3
+ module BrowserReviewGate
4
+ # The browser cases an agent actually ran, with their outcomes. Published as one PR comment.
5
+ class Report
6
+ MARKER = /<!--\s*browser-verification-report:([A-Za-z0-9_-]+)\s*-->/
7
+ SHA_PATTERN = /\A\h{7,40}\z/
8
+ CASE_ID_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\z/
9
+ RESULTS = %w[pass fail].freeze
10
+
11
+ attr_reader :errors
12
+
13
+ def self.latest_comment(comments)
14
+ comments.reverse.find { |comment| comment["body"].to_s.match?(MARKER) }
15
+ end
16
+
17
+ # The report data embedded in a comment body; raises Error when the marker is damaged.
18
+ def self.data_from(body)
19
+ JSON.parse(Marker.decode(body.to_s.match(MARKER)[1]))
20
+ rescue ArgumentError, JSON::ParserError
21
+ raise Error, "Existing browser verification report has a malformed data marker"
22
+ end
23
+
24
+ # The newest passing report on the PR, or nil.
25
+ def self.latest_passing(comments)
26
+ comment = latest_comment(comments)
27
+ report = comment && new(data_from(comment["body"]))
28
+ report if report&.passed?
29
+ rescue Error
30
+ nil
31
+ end
32
+
33
+ # Later results replace earlier ones case by case; cases that were not run again are kept.
34
+ def self.merge(previous_data, latest_data)
35
+ previous = new(previous_data)
36
+ latest = new(latest_data)
37
+ return latest unless previous.valid? && latest.valid?
38
+
39
+ combined = previous.cases.to_h { |test_case| [ test_case["id"], test_case ] }
40
+ latest.cases.each { |test_case| combined[test_case["id"]] = test_case }
41
+ new(latest.to_h.merge("cases" => combined.values))
42
+ end
43
+
44
+ def initialize(data)
45
+ @errors = []
46
+ @data = normalize(data.is_a?(Hash) ? data : {})
47
+ attach_defaults_to_cases
48
+ validate
49
+ end
50
+
51
+ def valid? = errors.empty?
52
+ def tested_sha = @data["tested_sha"]
53
+ def verified_by = @data["verified_by"]
54
+ def cases = @data["cases"].is_a?(Array) ? @data["cases"] : []
55
+ def excluded = @data["excluded"].is_a?(Array) ? @data["excluded"] : []
56
+ def to_h = JSON.parse(JSON.generate(@data))
57
+
58
+ def passed?
59
+ valid? && @data["coverage_complete"] && cases.all? { |test_case| test_case["result"] == "pass" }
60
+ end
61
+
62
+ def markdown
63
+ return "Browser verification report is invalid: #{errors.map { |error| Markdown.escape(error) }.join("; ")}" unless valid?
64
+
65
+ coverage = @data["coverage_complete"] ? "Complete scenario run." : "Scenario run is incomplete."
66
+ lines = [ "## Browser verification", "",
67
+ "Verified by **#{Markdown.escape(verified_by)}** for commit `#{tested_sha}`.", coverage, "" ]
68
+ lines.concat(cases.map { |test_case| markdown_case(test_case) })
69
+ lines.concat([ "", "Not exercised in the browser, by the author's decision:" ] + excluded.map { |entry| markdown_excluded(entry) }) if excluded.any?
70
+ payload = Marker.encode(JSON.generate(to_h))
71
+ lines.concat([ "", "<!-- browser-verification-report:#{payload} -->" ]).join("\n")
72
+ end
73
+
74
+ private
75
+
76
+ def normalize(value)
77
+ case value
78
+ when Hash then value.to_h { |key, nested| [ key.to_s, normalize(nested) ] }
79
+ when Array then value.map { |entry| normalize(entry) }
80
+ else value
81
+ end
82
+ end
83
+
84
+ def attach_defaults_to_cases
85
+ cases.each do |test_case|
86
+ next unless test_case.is_a?(Hash)
87
+
88
+ test_case["tested_sha"] ||= @data["tested_sha"]
89
+ test_case["verified_by"] ||= @data["verified_by"]
90
+ end
91
+ end
92
+
93
+ def validate
94
+ errors << "tested_sha must be a 7–40 character hexadecimal commit SHA" unless sha?(@data["tested_sha"])
95
+ errors << "verified_by must be a non-empty string of at most 100 characters" unless text?(@data["verified_by"], 100)
96
+ errors << "coverage_complete must be true or false" unless [ true, false ].include?(@data["coverage_complete"])
97
+ validate_cases
98
+ validate_excluded
99
+ end
100
+
101
+ def validate_cases
102
+ return errors << "cases must be a non-empty array" unless @data["cases"].is_a?(Array) && cases.any?
103
+
104
+ seen_ids = []
105
+ cases.each_with_index do |test_case, index|
106
+ next errors << "case #{index + 1} must be an object" unless test_case.is_a?(Hash)
107
+
108
+ validate_case(test_case, index + 1, seen_ids)
109
+ end
110
+ end
111
+
112
+ def validate_case(test_case, position, seen_ids)
113
+ id = test_case["id"]
114
+ if !(id.is_a?(String) && CASE_ID_PATTERN.match?(id))
115
+ errors << "case #{position} id must use letters, numbers, dots, underscores, or hyphens"
116
+ elsif seen_ids.include?(id)
117
+ errors << "case id #{id.inspect} appears more than once"
118
+ else
119
+ seen_ids << id
120
+ end
121
+ errors << "case #{position} name must be a non-empty string of at most 200 characters" unless text?(test_case["name"], 200)
122
+ errors << "case #{position} result must be pass or fail" unless RESULTS.include?(test_case["result"])
123
+ errors << "case #{position} tested_sha must be a 7–40 character hexadecimal commit SHA" unless sha?(test_case["tested_sha"])
124
+ errors << "case #{position} verified_by must be a non-empty string of at most 100 characters" unless text?(test_case["verified_by"], 100)
125
+ details = test_case["details"]
126
+ errors << "case #{position} details must be a string of at most 500 characters" unless details.nil? || (details.is_a?(String) && details.length <= 500)
127
+ end
128
+
129
+ # Scenarios the author decided not to run locally, e.g. a sign-in through an external provider.
130
+ def validate_excluded
131
+ return if @data["excluded"].nil?
132
+ return errors << "excluded must be an array" unless @data["excluded"].is_a?(Array)
133
+
134
+ excluded.each_with_index do |entry, index|
135
+ next if entry.is_a?(Hash) && text?(entry["name"], 200) && text?(entry["reason"], 500)
136
+
137
+ errors << "excluded #{index + 1} needs a name and a reason"
138
+ end
139
+ end
140
+
141
+ def sha?(value) = value.is_a?(String) && SHA_PATTERN.match?(value)
142
+ def text?(value, limit) = value.is_a?(String) && !value.strip.empty? && value.length <= limit
143
+
144
+ def markdown_case(test_case)
145
+ detail = test_case["details"].to_s.empty? ? "" : ": #{Markdown.escape(test_case["details"])}"
146
+ "- #{test_case["result"].upcase} — #{Markdown.escape(test_case["name"])} (`#{test_case["id"]}`, tested " \
147
+ "`#{test_case["tested_sha"]}` by #{Markdown.escape(test_case["verified_by"])})#{detail}"
148
+ end
149
+
150
+ def markdown_excluded(entry)
151
+ "- #{Markdown.escape(entry["name"])}: #{Markdown.escape(entry["reason"])}"
152
+ end
153
+ end
154
+ end