browser_review_gate 0.4.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 613338c89efb766c0c87cb095303d2be780955ddcd2fa42e3cc3458e9eb8166c
4
- data.tar.gz: 51eaa23740b23a5180932d71023b6009f8f9d805436f867efd0356f8c842fa75
3
+ metadata.gz: 19c8d16f74728aea319355c2ded2d74dc7f6941341bf9d4155a6de6361f56726
4
+ data.tar.gz: 13175563dbfd614a22e25fb716831568364e43d0bf64cf03fb6e591ab06fa9e3
5
5
  SHA512:
6
- metadata.gz: d0923b47163f93927cc0c5eee3467de838ee41730b1d8ca9b5d5c62e44479333ac7fb4b69462e2ebe9234efaee9d0f93748c32d7003239abc86f0c5f69820678
7
- data.tar.gz: 5af1651dc4f58fe782fdcc598d2ddf58831d66c6a546b2f65754199c80170e28caa970c65b9f29f5e1edbf32c2c4cbfb6a4a449dd787fc5103bbbe3a7191feab
6
+ metadata.gz: 8db14bc0c02cc0069a2ccfb3a69cf4c9ef21cfa1a19f8697b3b5ecd8d4b1702d2d5b7559558f02ae4150a8c2531064fccb959dd186eb1e3c08d0c88aa5993f53
7
+ data.tar.gz: 3fdcf995079d75a897ab81cb152d931c3f4f3996ac4299b54ea57b148e1258001983b65d409d49745e86686c6f3c70114521af67bd35dadb91d470fe57737b3e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## 0.4.2
4
13
 
5
14
  - Evidence files are kept in the git directory only once a run is accepted; a refused run leaves
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. Add one secret to the repository: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` or `GEMINI_API_KEY`.
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
- def covered_by?(report, commits: nil, reuse_passing: true)
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
- outstanding(report, commits: commits, reuse_passing: reuse_passing).empty?
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 = assessed_before && existing.settles?(report) ? existing : propose(number, pull_request, head_sha, report)
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
- covered = assessment.covered_by?(report, commits: commits, reuse_passing: @config.scenarios["reuse_passing"])
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?(Report.latest(comments, config: @config), commits: @github.pull_request_commits(number),
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
@@ -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
- return { "pull_request" => nil, "local_head" => local_sha, "saved_run" => saved, "scenarios" => [], "next" => saved ? "open_pull_request" : "run_and_save" } unless number
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
- verified = labels.include?(@config.label) && assessment&.covered_by?(report, commits: commits, reuse_passing: reuse) == true
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 assesses a PR in CI. Unset: the Claude Code CLI with a CLAUDE_CODE_OAUTH_TOKEN or
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
- - `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.
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.
@@ -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.any? ? scenarios.map { |scenario| " - #{scenario["id"]} [#{scenario["status"]}] #{scenario["name"]}" } :
68
- [ " - none listed by CI yet: choose them from the diff and #{Prompts::RULES}" ]
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,
@@ -1,3 +1,3 @@
1
1
  module BrowserReviewGate
2
- VERSION = "0.4.2"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -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.2
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