browser_review_gate 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,8 +16,12 @@ module BrowserReviewGate
16
16
  type: "array",
17
17
  items: {
18
18
  type: "object",
19
- properties: { id: { type: "string" }, name: { type: "string" } },
20
- required: %w[id name],
19
+ properties: {
20
+ id: { type: "string" }, name: { type: "string" },
21
+ priority: { type: "string", enum: Assessment::PRIORITIES },
22
+ category: { type: "string" }, why: { type: "string" }
23
+ },
24
+ required: %w[id name priority category why],
21
25
  additionalProperties: false
22
26
  }
23
27
  }
@@ -27,6 +31,7 @@ module BrowserReviewGate
27
31
  }.freeze
28
32
 
29
33
  IGNORED_REASON = "Only files that cannot change browser behavior were touched."
34
+ MANDATORY_REASON = "The project settings require browser scenarios for the files this change touches."
30
35
 
31
36
  def initialize(github:, model:, config: Config.new, prompts: Prompts.new, log: $stdout)
32
37
  @github = github
@@ -40,12 +45,13 @@ module BrowserReviewGate
40
45
  # when a report it has not judged lacks some of its scenarios; the comment is refreshed either way.
41
46
  def assess(number, pull_request: @github.pull_request(number), comments: @github.trusted_comments(number))
42
47
  head_sha = pull_request.fetch("head").fetch("sha")
43
- report = Report.latest(comments)
48
+ report = Report.latest(comments, config: @config)
49
+ commits = @github.pull_request_commits(number)
44
50
 
45
51
  existing = Assessment.latest(comments)
46
52
  assessed_before = existing&.for?(head_sha)
47
53
  assessment = assessed_before && existing.settles?(report) ? existing : propose(number, pull_request, head_sha, report)
48
- publish(number, assessment, pull_request, comments, report, new_commit: !assessed_before)
54
+ publish(number, assessment, pull_request, comments, report, commits, new_commit: !assessed_before)
49
55
  assessment
50
56
  rescue KeyError => error
51
57
  raise Error, "Browser assessment data is incomplete: #{error.message}"
@@ -53,27 +59,49 @@ module BrowserReviewGate
53
59
 
54
60
  private
55
61
 
62
+ # Mandatory scenarios scoped to paths make a run required whatever the model says; the rest are
63
+ # added whenever a run is required.
56
64
  def propose(number, pull_request, head_sha, report)
57
65
  files = @github.files(number)
58
- return Assessment.not_required(IGNORED_REASON, assessed_sha: head_sha) if files.all? { |file| @config.ignored?(file["filename"]) }
66
+ paths = files.map { |file| file["filename"] }
67
+ mandatory = @config.mandatory_scenarios(paths)
68
+ forced = mandatory.any? { |scenario| scoped?(scenario) }
69
+ return Assessment.not_required(IGNORED_REASON, assessed_sha: head_sha) if !forced && paths.all? { |path| @config.ignored?(path) }
59
70
 
60
- text = @model.complete(system: @prompts.assessment, user: user_message(pull_request, files, report), schema: SCHEMA)
61
- assessment = Assessment.parse(text, assessed_sha: head_sha, report_seen: report&.fingerprint)
71
+ text = @model.complete(system: @prompts.assessment(max_count: @config.scenarios["max_count"]), user: user_message(pull_request, files, report), schema: SCHEMA)
72
+ text = force_required(text) if forced
73
+ usage = @model.respond_to?(:last_usage) ? @model.last_usage : nil
74
+ assessment = Assessment.parse(text, assessed_sha: head_sha, report_seen: report&.fingerprint, mandatory: mandatory,
75
+ max_count: @config.scenarios["max_count"], usage: usage)
62
76
  raise Error, "Model returned an invalid browser assessment: #{assessment.errors.join("; ")}" unless assessment.valid?
63
77
 
64
78
  assessment
65
79
  end
66
80
 
81
+ def scoped?(scenario)
82
+ entry = @config.scenarios["mandatory"].find { |item| scenario["id"] == item["id"] || scenario["id"].start_with?("#{item["id"]}.") }
83
+ entry && entry["paths"]
84
+ end
85
+
86
+ def force_required(text)
87
+ data = JSON.parse(text.to_s)
88
+ return text unless data.is_a?(Hash) && data["decision"] == "not-required"
89
+
90
+ JSON.generate(data.merge("decision" => "required", "reason" => MANDATORY_REASON, "scenarios" => []))
91
+ rescue JSON::ParserError
92
+ text
93
+ end
94
+
67
95
  # Ordinary commits keep the label; it goes only when the report does not cover the commit.
68
- def publish(number, assessment, pull_request, comments, report, new_commit:)
96
+ def publish(number, assessment, pull_request, comments, report, commits, new_commit:)
69
97
  labelled = pull_request.fetch("labels").any? { |label| label["name"] == @config.label }
70
- covered = assessment.covered_by?(report)
98
+ covered = assessment.covered_by?(report, commits: commits, reuse_passing: @config.scenarios["reuse_passing"])
71
99
  run_owed = assessment.required? && !(labelled && covered)
72
100
  @github.remove_label(number, @config.label) if run_owed && labelled
73
101
 
74
102
  comment = Assessment.latest_comment(comments)
75
103
  body = assessment.markdown(verified: labelled && covered, command: "#{@config.command} #{number}",
76
- author: run_owed ? people_to_tell(pull_request, assessment.assessed_sha) : nil, report: report)
104
+ author: run_owed ? people_to_tell(pull_request, assessment.assessed_sha) : nil, report: report, commits: commits)
77
105
  if comment.nil?
78
106
  @github.create_comment(number, body)
79
107
  elsif comment["body"] == body
@@ -11,7 +11,9 @@ module BrowserReviewGate
11
11
  eject policy|playbook copy a built-in text into the project to rewrite it there
12
12
 
13
13
  For an AI coding agent, from the checkout that was tested
14
- status [--pr NUMBER] what the PR needs and what to do next, as JSON
14
+ verify [--pr NUMBER] [--environment NAME] [--report PATH] [--no-request] [--json]
15
+ what to run, and with --report publish (or save) the finished run
16
+ status [--pr NUMBER] [--environment NAME] what the PR needs and what to do next, as JSON
15
17
  publish [--pr NUMBER] [--report PATH] publish observed results (default: the saved run)
16
18
  save --report PATH keep results until the PR for this commit is opened
17
19
  request-assessment NUMBER ask CI to assess a PR that has no decision yet
@@ -21,11 +23,13 @@ module BrowserReviewGate
21
23
  ci NUMBER assess the PR and take back premature reviewer requests
22
24
 
23
25
  For the team
24
- stats [--days N] what the gate did on PRs of the last N days (default 30)
26
+ doctor [--json] check the setup and say what to fix
27
+ stats [--days N] [--json] what the gate did on PRs of the last N days (default 30)
25
28
  TEXT
26
29
 
27
- # `github`, `config` and `model` are replaced in tests.
28
- def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, github: nil, config: nil, model: nil)
30
+ # `github`, `config`, `model` and `shell` are replaced in tests.
31
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, github: nil, config: nil, model: nil, shell: nil)
32
+ @shell = shell
29
33
  @argv = argv.dup
30
34
  @github = github
31
35
  @config = config
@@ -42,6 +46,8 @@ module BrowserReviewGate
42
46
  when "install" then install
43
47
  when "eject" then eject
44
48
  when "status" then status
49
+ when "verify" then verify
50
+ when "doctor" then doctor
45
51
  when "publish" then publish
46
52
  when "save" then save
47
53
  when "request-assessment" then request_assessment
@@ -58,7 +64,10 @@ module BrowserReviewGate
58
64
 
59
65
  private
60
66
 
61
- def config = @config ||= Config.load(root)
67
+ # Local commands read the settings from the default branch when it has them; see Config.load_trusted.
68
+ def config = @config ||= trusted_config.first
69
+ def settings_source = @config ? trusted_config.last : nil
70
+ def trusted_config = @trusted_config ||= (@config ? [ @config, "given" ] : Config.load_trusted(root))
62
71
 
63
72
  # Settings and rules live at the top of the repository, wherever the command is run from.
64
73
  def root
@@ -74,6 +83,9 @@ module BrowserReviewGate
74
83
  parser.on("--pr NUMBER", Integer) { |value| parsed[:pr] = value } if flags.include?(:pr)
75
84
  parser.on("--report PATH") { |value| parsed[:report] = value } if flags.include?(:report)
76
85
  parser.on("--days N", Integer) { |value| parsed[:days] = value } if flags.include?(:days)
86
+ parser.on("--environment NAME") { |value| parsed[:environment] = value } if flags.include?(:environment)
87
+ parser.on("--json") { parsed[:json] = true } if flags.include?(:json)
88
+ parser.on("--no-request") { parsed[:no_request] = true } if flags.include?(:no_request)
77
89
  parser.on("--agents LIST", Array) { |value| parsed[:agents] = value } if flags.include?(:install)
78
90
  parser.on("--gem-source SOURCE") { |value| parsed[:gem_source] = value } if flags.include?(:install)
79
91
  parser.on("--workflow NAME") { |value| parsed[:workflow] = value } if flags.include?(:install)
@@ -101,10 +113,32 @@ module BrowserReviewGate
101
113
  end
102
114
 
103
115
  def status
104
- @stdout.puts JSON.pretty_generate(Status.new(github: github, config: config, prompts: Prompts.new(root)).to_h(options(:pr)[:pr]))
116
+ parsed = options(:pr, :environment)
117
+ result = Status.new(github: github, config: config, prompts: Prompts.new(root)).to_h(parsed[:pr], environment: parsed[:environment])
118
+ @stdout.puts JSON.pretty_generate(result.merge("settings_source" => trusted_config.last))
105
119
  0
106
120
  end
107
121
 
122
+ def verify
123
+ parsed = options(:pr, :environment, :report, :json, :no_request)
124
+ outcome, result = Verify.new(github: github, config: config, prompts: Prompts.new(root), shell: @shell || Shell.new)
125
+ .run(number: parsed[:pr], environment: parsed[:environment], report: parsed[:report], request: !parsed[:no_request])
126
+ message = Verify.describe(outcome, result)
127
+ if parsed[:json]
128
+ @stdout.puts JSON.pretty_generate(result.merge("outcome" => outcome, "message" => message, "settings_source" => trusted_config.last))
129
+ else
130
+ @stdout.puts message
131
+ end
132
+ Verify.exit_status(outcome)
133
+ end
134
+
135
+ def doctor
136
+ json = options(:json)[:json]
137
+ checks = Doctor.new(root: root, github: @github).checks
138
+ @stdout.puts(json ? Doctor.json(checks) : Doctor.text(checks))
139
+ checks.any? { |check| check.status == "FAIL" } ? 1 : 0
140
+ end
141
+
108
142
  def publish
109
143
  parsed = options(:pr, :report)
110
144
  if Publisher.new(github: github, number: parsed[:pr], report_path: parsed[:report], config: config).publish
@@ -130,25 +164,16 @@ module BrowserReviewGate
130
164
  end
131
165
 
132
166
  def stats
133
- days = options(:days).fetch(:days, 30)
167
+ parsed = options(:days, :json)
168
+ days = parsed.fetch(:days, 30)
134
169
  raise Error, "--days must be a positive number" unless days.positive?
135
170
 
136
- counts = Stats.new(github: github, config: config).to_h(since: (Time.now.utc - days * 86_400).strftime("%Y-%m-%d"))
137
- @stdout.puts "Pull requests touched in the last #{days} days: #{counts["pull_requests"]}",
138
- " assessed by the gate: #{counts["assessed"]}",
139
- " needed a browser run: #{counts["required"]}",
140
- " verified by a published run: #{counts["verified"]}",
141
- " waived: #{counts["waived"]}",
142
- " open and still owing a run: #{counts["owed"]}",
143
- " a run found a failing case: #{counts["found_a_failure"]}"
171
+ result = Stats.new(github: github, config: config).to_h(since: (Time.now.utc - days * 86_400).strftime("%Y-%m-%d"))
172
+ @stdout.puts(parsed[:json] ? JSON.pretty_generate(result) : Stats.text(result, days))
144
173
  0
145
174
  end
146
175
 
147
- # Version 0.1.1 installed a `--before` hook that held commands back. It is gone; a project that
148
- # still has the entry gets a silent no-op until the installer removes it.
149
176
  def hook
150
- return 0 if @argv.delete("--before")
151
-
152
177
  agent = @argv.shift
153
178
  message = Hook.new(publisher_factory: -> { Publisher.new(github: github, config: config) }).after(@stdin.read)
154
179
  output = Hook.render(message, agent)
@@ -1,9 +1,18 @@
1
+ require "uri"
1
2
  require "yaml"
2
3
 
3
4
  module BrowserReviewGate
4
5
  # Project settings from .github/browser-review-gate.yml. Every key is optional.
6
+ #
7
+ # Some settings are enforced by this gem (it refuses a report or keeps review closed); others are
8
+ # instructions the agent receives through `status` and `verify`. `ENFORCED` and `AGENT` say which.
5
9
  class Config
6
10
  PATH = ".github/browser-review-gate.yml"
11
+ NAME_PATTERN = /\A[a-z][a-z0-9_-]{0,31}\z/
12
+ EVIDENCE_MODES = %w[never on_failure always].freeze
13
+ MAX_SCENARIOS = 30
14
+ MAX_MATRIX_CASES = 24
15
+
7
16
  DEFAULTS = {
8
17
  "label" => "browser-verified",
9
18
  # A person other than the author adds this label to let review proceed without a browser run.
@@ -16,19 +25,50 @@ module BrowserReviewGate
16
25
  "provider" => nil,
17
26
  "model" => nil,
18
27
  "claude_command" => nil,
19
- # How an agent starts and reaches the app for a browser run.
20
- "start_command" => nil,
21
- "url" => nil,
22
- "sign_in" => nil,
28
+ "environments" => nil,
29
+ "verification" => nil,
30
+ "evidence" => nil,
31
+ "scenarios" => nil,
32
+ "gate" => nil,
33
+ "provenance" => nil,
23
34
  "skip_authors" => [ "dependabot[bot]", "github-actions[bot]" ],
24
35
  "skip_branch_prefixes" => [],
25
36
  # A PR that only touches these paths cannot change browser behavior; no model call is made.
26
37
  "ignore_paths" => [ "docs/**", "test/**", "spec/**", "**/*.md", ".github/**" ]
27
38
  }.freeze
28
39
 
40
+ SECTIONS = {
41
+ "verification" => { "environment" => nil, "fail_on_console_errors" => false, "fail_on_network_errors" => false },
42
+ "evidence" => { "screenshots" => "never", "trace" => "never", "console_logs" => false, "network_logs" => false,
43
+ "retention_days" => 14, "link_hosts" => [] },
44
+ "scenarios" => { "max_count" => 12, "reuse_passing" => true, "mandatory" => [], "matrix" => {} },
45
+ "gate" => { "require_evidence" => false, "block_on_failure" => true },
46
+ # Checks a case's Playwright trace against the tested commit: off, warn (shown in the PR) or
47
+ # require (a passing case counts only with a verified trace).
48
+ "provenance" => { "trace" => "off", "revision_header" => "X-App-Revision", "check_pages" => true, "clock_skew_seconds" => 300 }
49
+ }.freeze
50
+ TRACE_MODES = %w[off warn require].freeze
51
+
52
+ ENVIRONMENT_KEYS = %w[url healthcheck start_command sign_in].freeze
53
+ MANDATORY_KEYS = %w[id name why paths matrix].freeze
54
+
29
55
  def self.load(root = Dir.pwd)
30
56
  path = File.join(root, PATH)
31
- new(File.exist?(path) ? YAML.safe_load_file(path) : {})
57
+ parse(File.exist?(path) ? File.read(path) : "")
58
+ end
59
+
60
+ # Settings as the default branch has them, for local commands run on a PR branch: a pull request
61
+ # must not point the agent at another URL or loosen the policy for its own run. Falls back to the
62
+ # checkout when the default branch has no settings yet. Returns [config, source].
63
+ def self.load_trusted(root = Dir.pwd, shell: Shell.new)
64
+ text = shell.call("git", "-C", root, "show", "origin/HEAD:#{PATH}")
65
+ [ parse(text), "origin/HEAD" ]
66
+ rescue Error
67
+ [ load(root), "working tree" ]
68
+ end
69
+
70
+ def self.parse(text)
71
+ new(text.to_s.strip.empty? ? {} : YAML.safe_load(text))
32
72
  rescue Psych::Exception => error
33
73
  raise Error, "#{PATH} is not valid YAML: #{error.message}"
34
74
  end
@@ -36,15 +76,35 @@ module BrowserReviewGate
36
76
  def initialize(data = {})
37
77
  data = {} unless data.is_a?(Hash)
38
78
  unknown = data.keys - DEFAULTS.keys
79
+ moved = unknown & %w[url start_command sign_in]
80
+ raise Error, "#{PATH}: #{moved.join(", ")} moved under environments.local in 0.3.0" if moved.any?
39
81
  raise Error, "#{PATH} has unknown keys: #{unknown.join(", ")}" if unknown.any?
40
82
 
41
83
  @data = DEFAULTS.merge(data.compact)
84
+ @sections = SECTIONS.to_h { |name, defaults| [ name, section(name, defaults) ] }
85
+ @environments = read_environments
86
+ validate
42
87
  end
43
88
 
44
- DEFAULTS.each_key { |key| define_method(key) { @data[key] } }
89
+ (DEFAULTS.keys - %w[environments verification evidence scenarios gate provenance]).each do |key|
90
+ define_method(key) { @data[key] }
91
+ end
45
92
 
46
- def app
47
- { "start_command" => start_command, "url" => url, "sign_in" => sign_in }.compact
93
+ attr_reader :environments
94
+
95
+ def verification = @sections["verification"]
96
+ def evidence = @sections["evidence"]
97
+ def scenarios = @sections["scenarios"]
98
+ def gate = @sections["gate"]
99
+ def provenance = @sections["provenance"]
100
+
101
+ # The environment a run uses: the one named, the configured default, or the only one there is.
102
+ def environment(name = nil)
103
+ name ||= verification["environment"] || (environments.size == 1 ? environments.keys.first : nil)
104
+ return if name.nil?
105
+ raise Error, "Unknown environment #{name.inspect}; #{PATH} defines: #{environments.keys.join(", ")}" unless environments.key?(name)
106
+
107
+ environments.fetch(name).merge("name" => name)
48
108
  end
49
109
 
50
110
  def skip?(author:, branch:)
@@ -52,12 +112,183 @@ module BrowserReviewGate
52
112
  end
53
113
 
54
114
  # "dir/**" means everything under dir; other patterns are globs where "**/" spans directories.
55
- def ignored?(path)
56
- ignore_paths.any? do |pattern|
115
+ def ignored?(path) = Config.match?(ignore_paths, path)
116
+
117
+ def self.match?(patterns, path)
118
+ patterns.any? do |pattern|
57
119
  next path.start_with?(pattern.delete_suffix("**")) if pattern.end_with?("/**")
58
120
 
59
121
  File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
60
122
  end
61
123
  end
124
+
125
+ # Mandatory scenarios that apply to a change touching `paths`, expanded over the matrix where asked.
126
+ # They are added by this code, not proposed by the model, so a PR cannot talk its way out of them.
127
+ def mandatory_scenarios(paths)
128
+ scenarios["mandatory"].select { |entry| entry["paths"].nil? || paths.any? { |path| Config.match?(entry["paths"], path) } }
129
+ .flat_map { |entry| expand(entry) }
130
+ end
131
+
132
+ # What the settings ask of a run, split by who makes sure it happens.
133
+ def policy
134
+ {
135
+ "enforced" => {
136
+ "fail_on_console_errors" => verification["fail_on_console_errors"],
137
+ "fail_on_network_errors" => verification["fail_on_network_errors"],
138
+ "require_evidence" => gate["require_evidence"],
139
+ "block_on_failure" => gate["block_on_failure"],
140
+ "reuse_passing" => scenarios["reuse_passing"],
141
+ "max_scenarios" => scenarios["max_count"],
142
+ "trace_check" => provenance["trace"]
143
+ },
144
+ "agent" => {
145
+ "screenshots" => evidence["screenshots"],
146
+ "trace" => provenance["trace"] == "off" ? evidence["trace"] : "always",
147
+ "console_logs" => evidence["console_logs"], "network_logs" => evidence["network_logs"]
148
+ }
149
+ }
150
+ end
151
+
152
+ private
153
+
154
+ def section(name, defaults)
155
+ value = @data[name] || {}
156
+ raise Error, "#{PATH}: #{name} must be a mapping" unless value.is_a?(Hash)
157
+
158
+ unknown = value.keys - defaults.keys
159
+ raise Error, "#{PATH}: #{name} has unknown keys: #{unknown.join(", ")}" if unknown.any?
160
+
161
+ defaults.merge(value.compact)
162
+ end
163
+
164
+ def read_environments
165
+ listed = @data["environments"]
166
+ return {} unless listed
167
+ raise Error, "#{PATH}: environments must be a mapping of names to settings" unless listed.is_a?(Hash)
168
+
169
+ listed.to_h do |name, settings|
170
+ raise Error, "#{PATH}: environment name #{name.inspect} must be lowercase letters, digits, - or _" unless name.is_a?(String) && NAME_PATTERN.match?(name)
171
+ raise Error, "#{PATH}: environments.#{name} must be a mapping" unless settings.is_a?(Hash)
172
+
173
+ unknown = settings.keys - ENVIRONMENT_KEYS
174
+ raise Error, "#{PATH}: environments.#{name} has unknown keys: #{unknown.join(", ")}" if unknown.any?
175
+
176
+ [ name, settings.compact ]
177
+ end
178
+ end
179
+
180
+ def validate
181
+ environments.each do |name, settings|
182
+ url = settings["url"]
183
+ raise Error, "#{PATH}: environments.#{name}.url must be an http(s) URL" unless url.nil? || Config.http_url?(url)
184
+
185
+ check = settings["healthcheck"]
186
+ unless check.nil? || (check.is_a?(String) && (check.start_with?("/") || Config.http_url?(check)))
187
+ raise Error, "#{PATH}: environments.#{name}.healthcheck must be a path starting with / or an http(s) URL"
188
+ end
189
+ end
190
+ environment # the configured default must exist
191
+ validate_flags
192
+ validate_evidence
193
+ validate_provenance
194
+ validate_scenarios
195
+ end
196
+
197
+ def validate_flags
198
+ { "verification" => %w[fail_on_console_errors fail_on_network_errors], "evidence" => %w[console_logs network_logs],
199
+ "scenarios" => %w[reuse_passing], "gate" => %w[require_evidence block_on_failure] }.each do |name, keys|
200
+ keys.each do |key|
201
+ raise Error, "#{PATH}: #{name}.#{key} must be true or false" unless [ true, false ].include?(@sections[name][key])
202
+ end
203
+ end
204
+ end
205
+
206
+ def validate_evidence
207
+ %w[screenshots trace].each do |key|
208
+ next if EVIDENCE_MODES.include?(evidence[key])
209
+
210
+ raise Error, "#{PATH}: evidence.#{key} must be one of #{EVIDENCE_MODES.join(", ")}"
211
+ end
212
+ days = evidence["retention_days"]
213
+ raise Error, "#{PATH}: evidence.retention_days must be a whole number from 1 to 365" unless days.is_a?(Integer) && days.between?(1, 365)
214
+
215
+ hosts = evidence["link_hosts"]
216
+ return if hosts.is_a?(Array) && hosts.all? { |host| host.is_a?(String) && host.match?(/\A[a-z0-9.-]+\z/) }
217
+
218
+ raise Error, "#{PATH}: evidence.link_hosts must be a list of host names"
219
+ end
220
+
221
+ def validate_provenance
222
+ raise Error, "#{PATH}: provenance.trace must be one of #{TRACE_MODES.join(", ")}" unless TRACE_MODES.include?(provenance["trace"])
223
+ raise Error, "#{PATH}: provenance.revision_header must be an HTTP header name" unless provenance["revision_header"].to_s.match?(/\A[A-Za-z0-9-]{1,64}\z/)
224
+ raise Error, "#{PATH}: provenance.check_pages must be true or false" unless [ true, false ].include?(provenance["check_pages"])
225
+
226
+ skew = provenance["clock_skew_seconds"]
227
+ raise Error, "#{PATH}: provenance.clock_skew_seconds must be a whole number from 0 to 3600" unless skew.is_a?(Integer) && skew.between?(0, 3600)
228
+ end
229
+
230
+ def validate_scenarios
231
+ count = scenarios["max_count"]
232
+ raise Error, "#{PATH}: scenarios.max_count must be a whole number from 1 to #{MAX_SCENARIOS}" unless count.is_a?(Integer) && count.between?(1, MAX_SCENARIOS)
233
+
234
+ validate_matrix
235
+ mandatory = scenarios["mandatory"]
236
+ raise Error, "#{PATH}: scenarios.mandatory must be a list" unless mandatory.is_a?(Array)
237
+
238
+ mandatory.each_with_index { |entry, index| validate_mandatory(entry, index + 1) }
239
+ ids = mandatory_scenarios_all.map { |scenario| scenario["id"] }
240
+ raise Error, "#{PATH}: scenarios.mandatory ids must be unique after the matrix is applied" unless ids.uniq.size == ids.size
241
+
242
+ expanded = mandatory.count { |entry| entry["matrix"] } * matrix_combinations.size
243
+ raise Error, "#{PATH}: the matrix makes #{expanded} mandatory cases; keep it at #{MAX_MATRIX_CASES} or fewer" if expanded > MAX_MATRIX_CASES
244
+ end
245
+
246
+ def validate_matrix
247
+ matrix = scenarios["matrix"]
248
+ valid = matrix.is_a?(Hash) && matrix.all? do |name, values|
249
+ NAME_PATTERN.match?(name.to_s) && values.is_a?(Array) && values.any? && values.all? { |value| NAME_PATTERN.match?(value.to_s) }
250
+ end
251
+ raise Error, "#{PATH}: scenarios.matrix maps a dimension (e.g. role) to a list of short lowercase values" unless valid
252
+ end
253
+
254
+ def validate_mandatory(entry, position)
255
+ raise Error, "#{PATH}: scenarios.mandatory #{position} must be a mapping" unless entry.is_a?(Hash)
256
+
257
+ unknown = entry.keys - MANDATORY_KEYS
258
+ raise Error, "#{PATH}: scenarios.mandatory #{position} has unknown keys: #{unknown.join(", ")}" if unknown.any?
259
+ raise Error, "#{PATH}: scenarios.mandatory #{position} id must use letters, digits, dots, underscores or hyphens" unless entry["id"].is_a?(String) && Report::CASE_ID_PATTERN.match?(entry["id"])
260
+ raise Error, "#{PATH}: scenarios.mandatory #{position} needs a name" unless entry["name"].is_a?(String) && !entry["name"].strip.empty?
261
+ raise Error, "#{PATH}: scenarios.mandatory #{position} paths must be a list of globs" unless entry["paths"].nil? || (entry["paths"].is_a?(Array) && entry["paths"].all?(String))
262
+ raise Error, "#{PATH}: scenarios.mandatory #{position} matrix must be true or false" unless [ nil, true, false ].include?(entry["matrix"])
263
+ end
264
+
265
+ def mandatory_scenarios_all = scenarios["mandatory"].flat_map { |entry| expand(entry) }
266
+
267
+ # Every combination of the matrix dimensions, e.g. [{"role"=>"admin","viewport"=>"mobile"}, ...].
268
+ def matrix_combinations
269
+ matrix = scenarios["matrix"]
270
+ return [ {} ] if matrix.empty?
271
+
272
+ matrix.values.first.product(*matrix.values.drop(1)).map { |values| matrix.keys.zip(values).to_h }
273
+ end
274
+
275
+ def expand(entry)
276
+ base = { "id" => entry["id"], "name" => entry["name"].split.join(" "), "priority" => "critical", "category" => "mandatory",
277
+ "why" => entry["why"] || "Required by the project settings for the areas this change touches." }
278
+ return [ base ] unless entry["matrix"]
279
+
280
+ matrix_combinations.map do |combination|
281
+ suffix = combination.values.join(".")
282
+ details = combination.map { |dimension, value| "#{dimension} #{value}" }.join(", ")
283
+ base.merge("id" => "#{entry["id"]}.#{suffix}", "name" => "#{base["name"]} (#{details})", "matrix" => combination)
284
+ end
285
+ end
286
+
287
+ def self.http_url?(value)
288
+ uri = URI.parse(value.to_s)
289
+ %w[http https].include?(uri.scheme) && !uri.host.to_s.empty? && uri.userinfo.nil?
290
+ rescue URI::InvalidURIError
291
+ false
292
+ end
62
293
  end
63
294
  end