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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +74 -0
- data/exe/browser-review-gate +5 -0
- data/lib/browser_review_gate/assessment.rb +97 -0
- data/lib/browser_review_gate/assessor.rb +141 -0
- data/lib/browser_review_gate/cli.rb +144 -0
- data/lib/browser_review_gate/config.rb +57 -0
- data/lib/browser_review_gate/gate.rb +62 -0
- data/lib/browser_review_gate/github.rb +81 -0
- data/lib/browser_review_gate/hook.rb +36 -0
- data/lib/browser_review_gate/installer.rb +171 -0
- data/lib/browser_review_gate/markdown.rb +8 -0
- data/lib/browser_review_gate/model_client.rb +70 -0
- data/lib/browser_review_gate/publisher.rb +136 -0
- data/lib/browser_review_gate/report.rb +154 -0
- data/lib/browser_review_gate/saved_report.rb +26 -0
- data/lib/browser_review_gate/shell.rb +15 -0
- data/lib/browser_review_gate/status.rb +40 -0
- data/lib/browser_review_gate/templates/agents_block.md.erb +5 -0
- data/lib/browser_review_gate/templates/claude_skill.md.erb +10 -0
- data/lib/browser_review_gate/templates/config.yml.erb +24 -0
- data/lib/browser_review_gate/templates/cursor_command.md.erb +4 -0
- data/lib/browser_review_gate/templates/playbook.md.erb +48 -0
- data/lib/browser_review_gate/templates/workflow.yml.erb +64 -0
- data/lib/browser_review_gate/version.rb +3 -0
- data/lib/browser_review_gate.rb +38 -0
- data/lib/generators/browser_review_gate/install/install_generator.rb +22 -0
- metadata +70 -0
|
@@ -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
|