browser_review_gate 0.4.1 → 0.5.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 +15 -0
- data/README.md +16 -1
- data/lib/browser_review_gate/assessment.rb +27 -3
- data/lib/browser_review_gate/assessor.rb +19 -2
- data/lib/browser_review_gate/cli.rb +1 -1
- data/lib/browser_review_gate/config.rb +6 -0
- data/lib/browser_review_gate/doctor.rb +2 -0
- data/lib/browser_review_gate/freshness.rb +21 -0
- data/lib/browser_review_gate/gate.rb +3 -2
- data/lib/browser_review_gate/github.rb +7 -0
- data/lib/browser_review_gate/publisher.rb +10 -5
- data/lib/browser_review_gate/report.rb +19 -3
- data/lib/browser_review_gate/status.rb +12 -2
- data/lib/browser_review_gate/templates/config.yml.erb +10 -1
- data/lib/browser_review_gate/templates/playbook.md.erb +3 -2
- data/lib/browser_review_gate/trace_check.rb +1 -1
- data/lib/browser_review_gate/verify.rb +7 -2
- data/lib/browser_review_gate/version.rb +1 -1
- data/lib/browser_review_gate.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19c8d16f74728aea319355c2ded2d74dc7f6941341bf9d4155a6de6361f56726
|
|
4
|
+
data.tar.gz: 13175563dbfd614a22e25fb716831568364e43d0bf64cf03fb6e591ab06fa9e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8db14bc0c02cc0069a2ccfb3a69cf4c9ef21cfa1a19f8697b3b5ecd8d4b1702d2d5b7559558f02ae4150a8c2531064fccb959dd186eb1e3c08d0c88aa5993f53
|
|
7
|
+
data.tar.gz: 3fdcf995079d75a897ab81cb152d931c3f4f3996ac4299b54ea57b148e1258001983b65d409d49745e86686c6f3c70114521af67bd35dadb91d470fe57737b3e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- `assessment: local`: CI calls no model and needs no secret. Any PR touching more than
|
|
6
|
+
`ignore_paths` owes a run; the author's agent chooses the scenarios (the settings' mandatory ones
|
|
7
|
+
included) or publishes `"not_required": { "reason": ... }`, which the PR shows. A run counts for the
|
|
8
|
+
head commit, or an earlier one when only ignored paths changed since. `ci` stays the default.
|
|
9
|
+
- `status` and `verify` say which mode is in force; `verify` tells a local-mode agent to choose the
|
|
10
|
+
scenarios. `doctor` does not ask for a model secret in local mode.
|
|
11
|
+
|
|
12
|
+
## 0.4.2
|
|
13
|
+
|
|
14
|
+
- Evidence files are kept in the git directory only once a run is accepted; a refused run leaves
|
|
15
|
+
nothing behind.
|
|
16
|
+
- A trace without network requests says how to record one: with snapshots on.
|
|
17
|
+
|
|
3
18
|
## 0.4.1
|
|
4
19
|
|
|
5
20
|
- The `.network` log Playwright MCP writes with `browser_start_tracing`/`browser_stop_tracing` is
|
data/README.md
CHANGED
|
@@ -35,7 +35,7 @@ The installer detects which agents the project uses and sets up all of them; cho
|
|
|
35
35
|
|
|
36
36
|
Then:
|
|
37
37
|
|
|
38
|
-
1.
|
|
38
|
+
1. With `assessment: ci` (the default), add one secret to the repository: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` or `GEMINI_API_KEY`. With `assessment: local`, CI needs none.
|
|
39
39
|
2. Describe your environments in `.github/browser-review-gate.yml`.
|
|
40
40
|
3. Merge to the default branch. The workflow uses `pull_request_target`, so it runs from there.
|
|
41
41
|
4. Optionally make the `browser-verification` status a required check so the gate also blocks merging.
|
|
@@ -51,11 +51,26 @@ Then:
|
|
|
51
51
|
|
|
52
52
|
Later commits keep the verification unless they add a scenario no counting case covers; then only the new scenarios need to run (`scenarios.reuse_passing`).
|
|
53
53
|
|
|
54
|
+
## Two modes
|
|
55
|
+
|
|
56
|
+
`assessment` chooses who decides what a browser run must cover.
|
|
57
|
+
|
|
58
|
+
| | `ci` (default) | `local` |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| Is a run needed, which scenarios | a model in CI reads each PR and names them | the author's agent decides |
|
|
61
|
+
| CI needs a model secret, costs per PR | yes | no |
|
|
62
|
+
| Catches an agent that tests the easy path and skips the important one | yes: CI compares against its own list | only through `scenarios.mandatory` and the reviewer |
|
|
63
|
+
| A run counts after later commits | when they add no scenario it lacks | when only `ignore_paths` changed since |
|
|
64
|
+
|
|
65
|
+
In `local` mode CI still holds review: any PR that touches more than `ignore_paths` owes a run. The agent picks scenarios from the diff and the project rules, runs them, and publishes; the settings' mandatory scenarios must be among them. When nothing the change does reaches the browser, the agent may publish `"not_required": { "reason": "..." }` with no cases instead; the reason appears in the PR for the reviewer to weigh. Start with `local` when the project has no AI key in CI or wants no per-PR cost; move to `ci` when an independent list of scenarios is worth it.
|
|
66
|
+
|
|
54
67
|
## Settings
|
|
55
68
|
|
|
56
69
|
`.github/browser-review-gate.yml`. Every key is optional; an unknown key or a malformed value is an error, never silently ignored. **Enforced** settings are checked by the gem; **agent** settings are instructions the agent receives through `verify` and `status`.
|
|
57
70
|
|
|
58
71
|
```yaml
|
|
72
|
+
assessment: ci # ci | local, see "Two modes"
|
|
73
|
+
|
|
59
74
|
environments:
|
|
60
75
|
local:
|
|
61
76
|
url: "http://localhost:3000" # what the agent opens
|
|
@@ -20,6 +20,8 @@ module BrowserReviewGate
|
|
|
20
20
|
CATEGORY_PATTERN = /\A[a-z][a-z0-9-]{0,31}\z/
|
|
21
21
|
STATUS_MARKS = { "passed" => "x", "failed" => " ", "skipped" => " ", "blocked" => " ", "missing" => " ", "stale" => " " }.freeze
|
|
22
22
|
|
|
23
|
+
LOCAL_REASON = "This change can affect the browser, and the author's agent decides what to run."
|
|
24
|
+
|
|
23
25
|
attr_reader :assessed_sha, :decision, :reason, :scenarios, :report_seen, :usage, :errors
|
|
24
26
|
|
|
25
27
|
# Builds an assessment from the model's JSON answer, which is untrusted. `report_seen` is the
|
|
@@ -48,6 +50,12 @@ module BrowserReviewGate
|
|
|
48
50
|
new("assessed_sha" => assessed_sha, "decision" => "not-required", "reason" => reason)
|
|
49
51
|
end
|
|
50
52
|
|
|
53
|
+
# `assessment: local`: no model. A run is owed whenever the change touches more than ignored paths;
|
|
54
|
+
# the author's agent picks the scenarios, and the settings' mandatory ones are listed here.
|
|
55
|
+
def self.local(assessed_sha:, mandatory:)
|
|
56
|
+
new("assessed_sha" => assessed_sha, "decision" => "required", "reason" => LOCAL_REASON, "scenarios" => mandatory, "mode" => "local")
|
|
57
|
+
end
|
|
58
|
+
|
|
51
59
|
# The newest valid assessment the gate workflow wrote on the PR, or nil.
|
|
52
60
|
def self.latest(comments)
|
|
53
61
|
comment = latest_comment(comments)
|
|
@@ -72,6 +80,7 @@ module BrowserReviewGate
|
|
|
72
80
|
@reason = data["reason"].is_a?(String) ? data["reason"].split.join(" ")[0, MAX_REASON] : nil
|
|
73
81
|
@report_seen = data["report_seen"].is_a?(String) ? data["report_seen"] : nil
|
|
74
82
|
@usage = read_usage(data["usage"])
|
|
83
|
+
@mode = data["mode"] == "local" ? "local" : "ci"
|
|
75
84
|
@errors = []
|
|
76
85
|
@scenarios = required? ? read_scenarios(data["scenarios"]) : []
|
|
77
86
|
validate
|
|
@@ -80,6 +89,7 @@ module BrowserReviewGate
|
|
|
80
89
|
def valid? = errors.empty?
|
|
81
90
|
def required? = decision == "required"
|
|
82
91
|
def for?(sha) = assessed_sha == sha
|
|
92
|
+
def local? = @mode == "local"
|
|
83
93
|
|
|
84
94
|
# The scenarios no counting case of the report exercises yet. With `commits`, only cases tested on
|
|
85
95
|
# those commits count; with `reuse_passing` false, only cases tested on the assessed commit.
|
|
@@ -91,10 +101,16 @@ module BrowserReviewGate
|
|
|
91
101
|
|
|
92
102
|
# A report covers the change when it is complete, nothing failed (unless failures do not block),
|
|
93
103
|
# and every scenario has a counting case.
|
|
94
|
-
|
|
104
|
+
#
|
|
105
|
+
# In local mode nobody but the agent names the scenarios, so the report must also be fresh: run on
|
|
106
|
+
# this commit, or on an earlier one with only ignored paths changed since (`fresh`, worked out by
|
|
107
|
+
# the caller from the PR). It must pass a case of its own or say why no run is needed.
|
|
108
|
+
def covered_by?(report, commits: nil, reuse_passing: true, fresh: true)
|
|
95
109
|
return false unless required? && report&.passed?
|
|
110
|
+
return false unless outstanding(report, commits: commits, reuse_passing: reuse_passing).empty?
|
|
111
|
+
return true unless local?
|
|
96
112
|
|
|
97
|
-
|
|
113
|
+
fresh && (report.not_required? || report.passed_ids(commits: commits).any?)
|
|
98
114
|
end
|
|
99
115
|
|
|
100
116
|
# Whether this decision already accounts for the report, so the model need not be asked again.
|
|
@@ -104,7 +120,7 @@ module BrowserReviewGate
|
|
|
104
120
|
|
|
105
121
|
def to_h
|
|
106
122
|
{ "assessed_sha" => assessed_sha, "decision" => decision, "reason" => reason, "scenarios" => scenarios,
|
|
107
|
-
"report_seen" => report_seen, "usage" => usage }.compact
|
|
123
|
+
"report_seen" => report_seen, "usage" => usage, "mode" => local? ? "local" : nil }.compact
|
|
108
124
|
end
|
|
109
125
|
|
|
110
126
|
# `verified` is true when a report covers the browser behavior of this commit. `command` is what the
|
|
@@ -134,6 +150,8 @@ module BrowserReviewGate
|
|
|
134
150
|
end
|
|
135
151
|
|
|
136
152
|
def checklist(report, commits)
|
|
153
|
+
return local_checklist(report) if local? && scenarios.empty?
|
|
154
|
+
|
|
137
155
|
lines = scenarios.map do |scenario|
|
|
138
156
|
status = report ? report.status_of(scenario["id"], commits: commits) : "missing"
|
|
139
157
|
flags = [ scenario["priority"] == "critical" ? "critical" : nil, scenario["category"] ].compact.join(", ")
|
|
@@ -144,6 +162,12 @@ module BrowserReviewGate
|
|
|
144
162
|
[ "Scenarios the run must pass:", "", *lines, "" ]
|
|
145
163
|
end
|
|
146
164
|
|
|
165
|
+
def local_checklist(report)
|
|
166
|
+
done = report&.not_required? ? "the author's agent judged that no run is needed" :
|
|
167
|
+
report ? "#{report.passed_ids.size} passing cases published" : "no run published yet"
|
|
168
|
+
[ "The author's AI agent chooses the scenarios from the diff and the project rules (#{done}).", "" ]
|
|
169
|
+
end
|
|
170
|
+
|
|
147
171
|
# Scenario ids become report case ids, so they follow the same pattern.
|
|
148
172
|
def read_scenarios(value)
|
|
149
173
|
return [] if value.nil?
|
|
@@ -50,13 +50,29 @@ module BrowserReviewGate
|
|
|
50
50
|
|
|
51
51
|
existing = Assessment.latest(comments)
|
|
52
52
|
assessed_before = existing&.for?(head_sha)
|
|
53
|
-
assessment =
|
|
53
|
+
assessment = if @config.local?
|
|
54
|
+
propose_locally(number, head_sha)
|
|
55
|
+
else
|
|
56
|
+
assessed_before && existing.settles?(report) ? existing : propose(number, pull_request, head_sha, report)
|
|
57
|
+
end
|
|
54
58
|
publish(number, assessment, pull_request, comments, report, commits, new_commit: !assessed_before)
|
|
55
59
|
assessment
|
|
56
60
|
rescue KeyError => error
|
|
57
61
|
raise Error, "Browser assessment data is incomplete: #{error.message}"
|
|
58
62
|
end
|
|
59
63
|
|
|
64
|
+
# `assessment: local`: the decision without a model. Cheap, so it is made on every event, and by
|
|
65
|
+
# local commands when CI has not written it yet.
|
|
66
|
+
def propose_locally(number, head_sha)
|
|
67
|
+
paths = @github.files(number).map { |file| file["filename"] }
|
|
68
|
+
mandatory = @config.mandatory_scenarios(paths)
|
|
69
|
+
if mandatory.none? { |scenario| scoped?(scenario) } && paths.all? { |path| @config.ignored?(path) }
|
|
70
|
+
return Assessment.not_required(IGNORED_REASON, assessed_sha: head_sha)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Assessment.local(assessed_sha: head_sha, mandatory: mandatory)
|
|
74
|
+
end
|
|
75
|
+
|
|
60
76
|
private
|
|
61
77
|
|
|
62
78
|
# Mandatory scenarios scoped to paths make a run required whatever the model says; the rest are
|
|
@@ -95,7 +111,8 @@ module BrowserReviewGate
|
|
|
95
111
|
# Ordinary commits keep the label; it goes only when the report does not cover the commit.
|
|
96
112
|
def publish(number, assessment, pull_request, comments, report, commits, new_commit:)
|
|
97
113
|
labelled = pull_request.fetch("labels").any? { |label| label["name"] == @config.label }
|
|
98
|
-
|
|
114
|
+
fresh = assessment.local? && Freshness.new(github: @github, config: @config).fresh?(report, assessment.assessed_sha)
|
|
115
|
+
covered = assessment.covered_by?(report, commits: commits, reuse_passing: @config.scenarios["reuse_passing"], fresh: fresh)
|
|
99
116
|
run_owed = assessment.required? && !(labelled && covered)
|
|
100
117
|
@github.remove_label(number, @config.label) if run_owed && labelled
|
|
101
118
|
|
|
@@ -192,7 +192,7 @@ module BrowserReviewGate
|
|
|
192
192
|
|
|
193
193
|
failed = false
|
|
194
194
|
begin
|
|
195
|
-
model = @model || ModelClient.for(config)
|
|
195
|
+
model = @model || (config.local? ? nil : ModelClient.for(config))
|
|
196
196
|
Assessor.new(github: github, model: model, config: config, prompts: Prompts.new(root), log: @stdout).assess(number, pull_request: pull_request)
|
|
197
197
|
rescue Error => error
|
|
198
198
|
failed = true
|
|
@@ -21,6 +21,9 @@ module BrowserReviewGate
|
|
|
21
21
|
"status_context" => "browser-verification",
|
|
22
22
|
"command" => "/browser-pr-verification",
|
|
23
23
|
"workflow" => "browser-review-gate.yml",
|
|
24
|
+
# Who decides what a browser run must cover: "ci" (a model in CI reads the diff and names the
|
|
25
|
+
# scenarios) or "local" (the author's agent chooses them; CI calls no model and needs no secret).
|
|
26
|
+
"assessment" => "ci",
|
|
24
27
|
# Who assesses a PR: claude-cli, anthropic, openai or gemini. Unset: chosen from the credentials.
|
|
25
28
|
"provider" => nil,
|
|
26
29
|
"model" => nil,
|
|
@@ -48,6 +51,7 @@ module BrowserReviewGate
|
|
|
48
51
|
"provenance" => { "trace" => "off", "revision_header" => "X-App-Revision", "check_pages" => true, "clock_skew_seconds" => 300 }
|
|
49
52
|
}.freeze
|
|
50
53
|
TRACE_MODES = %w[off warn require].freeze
|
|
54
|
+
ASSESSMENTS = %w[ci local].freeze
|
|
51
55
|
|
|
52
56
|
ENVIRONMENT_KEYS = %w[url healthcheck start_command sign_in].freeze
|
|
53
57
|
MANDATORY_KEYS = %w[id name why paths matrix].freeze
|
|
@@ -97,6 +101,7 @@ module BrowserReviewGate
|
|
|
97
101
|
def scenarios = @sections["scenarios"]
|
|
98
102
|
def gate = @sections["gate"]
|
|
99
103
|
def provenance = @sections["provenance"]
|
|
104
|
+
def local? = assessment == "local"
|
|
100
105
|
|
|
101
106
|
# The environment a run uses: the one named, the configured default, or the only one there is.
|
|
102
107
|
def environment(name = nil)
|
|
@@ -187,6 +192,7 @@ module BrowserReviewGate
|
|
|
187
192
|
raise Error, "#{PATH}: environments.#{name}.healthcheck must be a path starting with / or an http(s) URL"
|
|
188
193
|
end
|
|
189
194
|
end
|
|
195
|
+
raise Error, "#{PATH}: assessment must be ci or local" unless ASSESSMENTS.include?(assessment)
|
|
190
196
|
environment # the configured default must exist
|
|
191
197
|
validate_flags
|
|
192
198
|
validate_evidence
|
|
@@ -237,6 +237,8 @@ module BrowserReviewGate
|
|
|
237
237
|
end
|
|
238
238
|
|
|
239
239
|
def provider_secrets(config)
|
|
240
|
+
return add("model credentials", "SKIP", "not needed: assessment is local, CI calls no model") if config.local?
|
|
241
|
+
|
|
240
242
|
names = run("gh", "secret", "list", "--json", "name", "--jq", ".[].name")
|
|
241
243
|
return add("model credentials", "SKIP", "repository secrets could not be listed (needs admin access)") unless names
|
|
242
244
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module BrowserReviewGate
|
|
2
|
+
# Whether a report still speaks for the PR head in `assessment: local`, where no model re-reads the
|
|
3
|
+
# diff: it was run on the head, or every file changed since the run is one that cannot reach the
|
|
4
|
+
# browser (`ignore_paths`). Anything unknown counts as stale.
|
|
5
|
+
class Freshness
|
|
6
|
+
def initialize(github:, config:)
|
|
7
|
+
@github = github
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def fresh?(report, head_sha)
|
|
12
|
+
return false unless report
|
|
13
|
+
return true if report.tested_sha == head_sha
|
|
14
|
+
|
|
15
|
+
files = @github.changed_files_between(report.tested_sha, head_sha)
|
|
16
|
+
!files.nil? && files.all? { |path| @config.ignored?(path) }
|
|
17
|
+
rescue Error
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -50,9 +50,10 @@ module BrowserReviewGate
|
|
|
50
50
|
|
|
51
51
|
# Only cases tested on a commit of this PR count: a report about other code proves nothing here.
|
|
52
52
|
def verified?(number, pull_request, comments, assessment)
|
|
53
|
+
report = Report.latest(comments, config: @config)
|
|
54
|
+
fresh = assessment.local? && Freshness.new(github: @github, config: @config).fresh?(report, assessment.assessed_sha)
|
|
53
55
|
labelled?(pull_request, @config.label) &&
|
|
54
|
-
assessment.covered_by?(
|
|
55
|
-
reuse_passing: @config.scenarios["reuse_passing"])
|
|
56
|
+
assessment.covered_by?(report, commits: @github.pull_request_commits(number), reuse_passing: @config.scenarios["reuse_passing"], fresh: fresh)
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
# A waiver counts only from someone other than the author, unless the project allows otherwise.
|
|
@@ -58,6 +58,13 @@ module BrowserReviewGate
|
|
|
58
58
|
raise Error, "gh returned commits without a sha for PR #{number}"
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Paths changed from `base` to `head`, or nil when GitHub cuts the list (it returns at most 300).
|
|
62
|
+
def changed_files_between(base, head)
|
|
63
|
+
result = api("repos/#{repository}/compare/#{base}...#{head}")
|
|
64
|
+
files = Array(result["files"]).map { |file| file.fetch("filename") }
|
|
65
|
+
files.size >= 300 ? nil : files
|
|
66
|
+
end
|
|
67
|
+
|
|
61
68
|
def files(number)
|
|
62
69
|
pages("repos/#{repository}/pulls/#{number}/files")
|
|
63
70
|
end
|
|
@@ -34,6 +34,7 @@ module BrowserReviewGate
|
|
|
34
34
|
comments = @github.trusted_comments(number)
|
|
35
35
|
ensure_browser_run_wanted!(number, comments, local_sha)
|
|
36
36
|
report = merged_report(data, comments)
|
|
37
|
+
keep_evidence
|
|
37
38
|
|
|
38
39
|
complete = report.passed?
|
|
39
40
|
labelled = pull_request.fetch("labels").any? { |label| label["name"] == @config.label }
|
|
@@ -58,6 +59,7 @@ module BrowserReviewGate
|
|
|
58
59
|
ensure_environment!(data)
|
|
59
60
|
report = Report.new(data.merge("verified_by" => "pending"), config: @config)
|
|
60
61
|
ensure_acceptable!(report)
|
|
62
|
+
keep_evidence
|
|
61
63
|
|
|
62
64
|
saved_report = SavedReport.new(sha: local_sha, shell: @shell)
|
|
63
65
|
saved_report.write(data)
|
|
@@ -93,19 +95,22 @@ module BrowserReviewGate
|
|
|
93
95
|
raise Error, "No saved browser verification report for the current commit"
|
|
94
96
|
end
|
|
95
97
|
|
|
96
|
-
# Evidence files are read here, next to the report
|
|
98
|
+
# Evidence files are read here, next to the report. They are kept only once the run is accepted.
|
|
97
99
|
def read_report_file
|
|
98
100
|
raise Error, "A report file is required" unless @report_path
|
|
99
101
|
|
|
100
102
|
data = JSON.parse(File.read(@report_path))
|
|
101
|
-
evidence = Evidence.new(base_dir: File.dirname(File.expand_path(@report_path)), config: @config)
|
|
102
|
-
|
|
103
|
-
evidence.keep(SavedReport.evidence_dir(shell: @shell))
|
|
104
|
-
processed
|
|
103
|
+
@evidence = Evidence.new(base_dir: File.dirname(File.expand_path(@report_path)), config: @config)
|
|
104
|
+
@evidence.process(data)
|
|
105
105
|
rescue SystemCallError => error
|
|
106
106
|
raise Error, "Cannot read report file #{@report_path}: #{error.message}"
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
+
# Files of an accepted run are kept in the git directory by digest; a refused run leaves nothing.
|
|
110
|
+
def keep_evidence
|
|
111
|
+
@evidence&.keep(SavedReport.evidence_dir(shell: @shell))
|
|
112
|
+
end
|
|
113
|
+
|
|
109
114
|
# A run names the environment it used; with environments configured it must be one of them.
|
|
110
115
|
def ensure_environment!(data)
|
|
111
116
|
name = data["environment"]
|
|
@@ -17,7 +17,7 @@ module BrowserReviewGate
|
|
|
17
17
|
CAPABILITIES = %w[screenshots trace console network].freeze
|
|
18
18
|
URL_PATTERN = %r{\A(?:https?://|/)\S*\z}
|
|
19
19
|
TOP_KEYS = %w[schema_version tested_sha verified_by environment tool started_at finished_at coverage_complete
|
|
20
|
-
cases excluded runs failed_runs].freeze
|
|
20
|
+
cases excluded runs failed_runs not_required].freeze
|
|
21
21
|
CASE_KEYS = %w[id name result url details reason evidence tested_sha verified_by environment tool].freeze
|
|
22
22
|
EVIDENCE_KEYS = %w[files console_errors network_errors links trace_check].freeze
|
|
23
23
|
FILE_KEYS = %w[kind name media_type bytes sha256].freeze
|
|
@@ -75,6 +75,11 @@ module BrowserReviewGate
|
|
|
75
75
|
def runs = @data["runs"].is_a?(Integer) ? @data["runs"] : 0
|
|
76
76
|
def failed_runs = @data["failed_runs"].is_a?(Integer) ? @data["failed_runs"] : 0
|
|
77
77
|
def coverage_complete? = @data["coverage_complete"] == true
|
|
78
|
+
|
|
79
|
+
# With `assessment: local`, the author's agent may judge that the change needs no browser run. The
|
|
80
|
+
# reason is shown in the PR for the reviewer to weigh.
|
|
81
|
+
def not_required_reason = @data["not_required"].is_a?(Hash) ? @data["not_required"]["reason"] : nil
|
|
82
|
+
def not_required? = !not_required_reason.nil?
|
|
78
83
|
def to_h = JSON.parse(JSON.generate(@data))
|
|
79
84
|
|
|
80
85
|
# Seconds the run took, when the agent gave both times.
|
|
@@ -119,7 +124,7 @@ module BrowserReviewGate
|
|
|
119
124
|
|
|
120
125
|
# Complete, and nothing failed when failures block review.
|
|
121
126
|
def passed?
|
|
122
|
-
valid? && coverage_complete? && (!@config.gate["block_on_failure"] || !failed?)
|
|
127
|
+
valid? && (coverage_complete? || not_required?) && (!@config.gate["block_on_failure"] || !failed?)
|
|
123
128
|
end
|
|
124
129
|
|
|
125
130
|
# Changes whenever a case is added, re-run or changes its outcome.
|
|
@@ -154,8 +159,9 @@ module BrowserReviewGate
|
|
|
154
159
|
lines = [ "## Browser verification", "",
|
|
155
160
|
"Verified by **#{Markdown.escape(verified_by)}** for commit `#{tested_sha}` in the `#{environment}` environment " \
|
|
156
161
|
"with #{tool_name}#{timing}. #{coverage}", "",
|
|
157
|
-
"| Case | Result | Page | Commit | Evidence |", "|---|---|---|---|---|" ]
|
|
162
|
+
*(cases.any? ? [ "| Case | Result | Page | Commit | Evidence |", "|---|---|---|---|---|" ] : []) ]
|
|
158
163
|
lines.concat(cases.map { |test_case| markdown_case(test_case, commits) })
|
|
164
|
+
lines.concat([ "", "**The author's agent judged that no browser run is needed:** #{Markdown.escape(not_required_reason)}" ]) if not_required?
|
|
159
165
|
details = cases.select { |test_case| test_case["details"] || test_case["reason"] }
|
|
160
166
|
lines.concat([ "", "<details><summary>What the agent observed</summary>", "" ] +
|
|
161
167
|
details.map { |test_case| "- `#{test_case["id"]}`: #{Markdown.escape([ test_case["details"], test_case["reason"] ].compact.join(" "))}" } +
|
|
@@ -240,6 +246,7 @@ module BrowserReviewGate
|
|
|
240
246
|
validate_tool
|
|
241
247
|
validate_times
|
|
242
248
|
validate_cases
|
|
249
|
+
validate_not_required if @data["cases"] != []
|
|
243
250
|
validate_excluded
|
|
244
251
|
%w[runs failed_runs].each do |key|
|
|
245
252
|
errors << "#{key} must be a whole number" unless @data[key].nil? || (@data[key].is_a?(Integer) && @data[key] >= 0)
|
|
@@ -272,7 +279,16 @@ module BrowserReviewGate
|
|
|
272
279
|
errors << "finished_at must not be before started_at" if times["finished_at"] < times["started_at"]
|
|
273
280
|
end
|
|
274
281
|
|
|
282
|
+
def validate_not_required
|
|
283
|
+
value = @data["not_required"]
|
|
284
|
+
return if value.nil?
|
|
285
|
+
return if value.is_a?(Hash) && value.keys == [ "reason" ] && text?(value["reason"], 300)
|
|
286
|
+
|
|
287
|
+
errors << "not_required must be { \"reason\": \"why no browser run is needed\" }"
|
|
288
|
+
end
|
|
289
|
+
|
|
275
290
|
def validate_cases
|
|
291
|
+
return validate_not_required if @data["not_required"] && @data["cases"] == []
|
|
276
292
|
return errors << "cases must be a non-empty array" unless @data["cases"].is_a?(Array) && cases.any?
|
|
277
293
|
return errors << "cases must have at most 60 entries" if cases.size > 60
|
|
278
294
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "openssl"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "stringio"
|
|
4
5
|
|
|
5
6
|
module BrowserReviewGate
|
|
6
7
|
# What an agent needs to know before a browser run: the decision for the PR head, the scenarios still
|
|
@@ -19,6 +20,9 @@ module BrowserReviewGate
|
|
|
19
20
|
|
|
20
21
|
def to_h(number = nil, environment: nil)
|
|
21
22
|
result = facts(number)
|
|
23
|
+
# "local": the agent chooses the scenarios (the list holds only the mandatory ones) and may report
|
|
24
|
+
# that no run is needed, with a reason. "ci": the list is complete; run exactly that.
|
|
25
|
+
result["assessment"] = @config.assessment
|
|
22
26
|
result["project_rules"] = Prompts::RULES if @prompts.rules
|
|
23
27
|
result["environment"] = environment_details(environment)
|
|
24
28
|
result["policy"] = @config.policy
|
|
@@ -67,18 +71,24 @@ module BrowserReviewGate
|
|
|
67
71
|
local_sha = @shell.call("git", "rev-parse", "HEAD").strip
|
|
68
72
|
saved = SavedReport.new(sha: local_sha, shell: @shell).exist?
|
|
69
73
|
number ||= @github.pull_request_number_for_branch
|
|
70
|
-
|
|
74
|
+
unless number
|
|
75
|
+
return { "pull_request" => nil, "local_head" => local_sha, "saved_run" => saved, "scenarios" => [],
|
|
76
|
+
"next" => saved ? "open_pull_request" : "run_and_save" }
|
|
77
|
+
end
|
|
71
78
|
|
|
72
79
|
pull_request = @github.pull_request(number)
|
|
73
80
|
comments = @github.trusted_comments(number)
|
|
74
81
|
head_sha = pull_request.fetch("head").fetch("sha")
|
|
75
82
|
assessment = Assessment.latest(comments)
|
|
76
83
|
assessment = nil unless assessment&.for?(head_sha)
|
|
84
|
+
# In local mode the decision needs no model, so it is made here when CI has not written it yet.
|
|
85
|
+
assessment ||= Assessor.new(github: @github, model: nil, config: @config, log: StringIO.new).propose_locally(number, head_sha) if @config.local?
|
|
77
86
|
labels = pull_request.fetch("labels").map { |label| label["name"] }
|
|
78
87
|
report = Report.latest(comments, config: @config)
|
|
79
88
|
commits = @github.pull_request_commits(number)
|
|
80
89
|
reuse = @config.scenarios["reuse_passing"]
|
|
81
|
-
|
|
90
|
+
fresh = assessment&.local? && Freshness.new(github: @github, config: @config).fresh?(report, head_sha)
|
|
91
|
+
verified = labels.include?(@config.label) && assessment&.covered_by?(report, commits: commits, reuse_passing: reuse, fresh: fresh) == true
|
|
82
92
|
waived = labels.include?(@config.waiver_label)
|
|
83
93
|
|
|
84
94
|
{ "pull_request" => number, "pr_head" => head_sha, "local_head" => local_sha,
|
|
@@ -75,7 +75,16 @@ provenance:
|
|
|
75
75
|
check_pages: true
|
|
76
76
|
clock_skew_seconds: 300
|
|
77
77
|
|
|
78
|
-
# Who
|
|
78
|
+
# Who decides what a browser run must cover.
|
|
79
|
+
# ci: a model in CI reads each PR, decides whether a run is needed and names the scenarios; the gate
|
|
80
|
+
# checks the published run against that list. Needs one of the secrets below.
|
|
81
|
+
# local: no model in CI and no secret. A run is owed whenever a PR touches more than ignore_paths; the
|
|
82
|
+
# author's AI agent chooses the scenarios (plus scenarios.mandatory) and may report that no run is
|
|
83
|
+
# needed, with a reason shown in the PR. A run counts only for the head commit, or for an earlier
|
|
84
|
+
# one when only ignore_paths changed since. Weaker than ci: nobody checks the agent's choice.
|
|
85
|
+
assessment: ci
|
|
86
|
+
|
|
87
|
+
# Who assesses a PR in CI (assessment: ci). Unset: the Claude Code CLI with a CLAUDE_CODE_OAUTH_TOKEN or
|
|
79
88
|
# ANTHROPIC_API_KEY secret; with only an OPENAI_API_KEY or GEMINI_API_KEY secret, that provider's API.
|
|
80
89
|
# Set `provider` to claude-cli, anthropic, openai or gemini to choose; openai and gemini need `model`.
|
|
81
90
|
provider:
|
|
@@ -17,8 +17,9 @@ This file is the playbook for the AI coding agent that does the run. The person
|
|
|
17
17
|
- `open_pull_request`: a run is saved and waits for the PR. Tell the person to open it.
|
|
18
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.
|
|
19
19
|
- `run_and_publish` or `run_and_save`: continue with step 2.
|
|
20
|
-
2. Decide what to run.
|
|
21
|
-
- `
|
|
20
|
+
2. Decide what to run. `assessment` in the output says who chooses.
|
|
21
|
+
- `local`: you choose. Read the diff against the base branch and `<%= rules_path %>`, and pick every scenario a person would need to exercise the change; `scenarios` holds only the mandatory ones from the settings, and they must be included. If nothing the change does can be seen or done in a browser (for example a background job whose result no page shows), you may publish a report with `"not_required": { "reason": "..." }` and `"cases": []` instead; the reason is shown in the PR, so make it specific. Every new commit that touches more than documentation, tests or CI needs a fresh run.
|
|
22
|
+
- `ci`: `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
23
|
- 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
24
|
- Do not re-run a scenario whose earlier case still counts; `verify` lists only what is owed.
|
|
24
25
|
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.
|
|
@@ -31,7 +31,7 @@ module BrowserReviewGate
|
|
|
31
31
|
end
|
|
32
32
|
findings << "the case's page #{url} was not requested" if @settings["check_pages"] && url && !page_requested?(requests, url)
|
|
33
33
|
findings.concat(time_findings(requests, started_at, finished_at))
|
|
34
|
-
findings << "the trace has no network requests" if requests.empty?
|
|
34
|
+
findings << "the trace has no network requests; record it with snapshots on (Playwright: tracing.start({ snapshots: true }))" if requests.empty?
|
|
35
35
|
|
|
36
36
|
{ "status" => findings.empty? ? "verified" : "failed", "revision" => matched && tested_sha[0, 12],
|
|
37
37
|
"requests" => requests.size, "findings" => findings.first(5) }.compact
|
|
@@ -64,8 +64,13 @@ module BrowserReviewGate
|
|
|
64
64
|
def self.run_instructions(status)
|
|
65
65
|
environment = status["environment"] || {}
|
|
66
66
|
scenarios = status["scenarios"]
|
|
67
|
-
list = scenarios.
|
|
68
|
-
|
|
67
|
+
list = scenarios.map { |scenario| " - #{scenario["id"]} [#{scenario["status"]}] #{scenario["name"]}" }
|
|
68
|
+
if status["assessment"] == "local"
|
|
69
|
+
list << " - and the scenarios you choose from the diff and #{Prompts::RULES} (assessment is local)"
|
|
70
|
+
list << " If nothing the change does can be seen in a browser, publish a report with \"not_required\": {\"reason\": ...} and no cases."
|
|
71
|
+
elsif list.empty?
|
|
72
|
+
list << " - none listed by CI yet: choose them from the diff and #{Prompts::RULES}"
|
|
73
|
+
end
|
|
69
74
|
[ "A browser run is needed#{status["pull_request"] ? " for PR ##{status["pull_request"]}" : ""}.",
|
|
70
75
|
"Environment: #{environment["name"] || "not configured"}#{environment["url"] ? " at #{environment["url"]}" : ""}.",
|
|
71
76
|
"Scenarios to run in your browser tool, one case per id:", *list,
|
data/lib/browser_review_gate.rb
CHANGED
|
@@ -30,6 +30,7 @@ module BrowserReviewGate
|
|
|
30
30
|
autoload :ZipReader, File.join(__dir__, "browser_review_gate/zip_reader")
|
|
31
31
|
autoload :Verify, File.join(__dir__, "browser_review_gate/verify")
|
|
32
32
|
autoload :Config, File.join(__dir__, "browser_review_gate/config")
|
|
33
|
+
autoload :Freshness, File.join(__dir__, "browser_review_gate/freshness")
|
|
33
34
|
autoload :Gate, File.join(__dir__, "browser_review_gate/gate")
|
|
34
35
|
autoload :GitHub, File.join(__dir__, "browser_review_gate/github")
|
|
35
36
|
autoload :Hook, File.join(__dir__, "browser_review_gate/hook")
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Islam Gagiev
|
|
@@ -45,6 +45,7 @@ files:
|
|
|
45
45
|
- lib/browser_review_gate/config.rb
|
|
46
46
|
- lib/browser_review_gate/doctor.rb
|
|
47
47
|
- lib/browser_review_gate/evidence.rb
|
|
48
|
+
- lib/browser_review_gate/freshness.rb
|
|
48
49
|
- lib/browser_review_gate/gate.rb
|
|
49
50
|
- lib/browser_review_gate/github.rb
|
|
50
51
|
- lib/browser_review_gate/hook.rb
|