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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +152 -55
- data/lib/browser_review_gate/assessment.rb +74 -33
- data/lib/browser_review_gate/assessor.rb +38 -10
- data/lib/browser_review_gate/cli.rb +44 -19
- data/lib/browser_review_gate/config.rb +241 -10
- data/lib/browser_review_gate/doctor.rb +252 -0
- data/lib/browser_review_gate/evidence.rb +183 -0
- data/lib/browser_review_gate/gate.rb +8 -5
- data/lib/browser_review_gate/github.rb +7 -0
- data/lib/browser_review_gate/installer.rb +2 -1
- data/lib/browser_review_gate/model_api.rb +16 -0
- data/lib/browser_review_gate/model_client.rb +10 -0
- data/lib/browser_review_gate/prompts.rb +10 -5
- data/lib/browser_review_gate/publisher.rb +32 -9
- data/lib/browser_review_gate/report.rb +247 -47
- data/lib/browser_review_gate/revision_header.rb +72 -0
- data/lib/browser_review_gate/saved_report.rb +15 -1
- data/lib/browser_review_gate/stats.rb +90 -6
- data/lib/browser_review_gate/status.rb +63 -21
- data/lib/browser_review_gate/templates/agents_block.md.erb +1 -1
- data/lib/browser_review_gate/templates/config.yml.erb +74 -5
- data/lib/browser_review_gate/templates/playbook.md.erb +65 -25
- data/lib/browser_review_gate/templates/rules.md.erb +4 -1
- data/lib/browser_review_gate/trace_check.rb +119 -0
- data/lib/browser_review_gate/verify.rb +95 -0
- data/lib/browser_review_gate/version.rb +1 -1
- data/lib/browser_review_gate/zip_reader.rb +103 -0
- data/lib/browser_review_gate.rb +6 -0
- metadata +7 -1
|
@@ -16,8 +16,12 @@ module BrowserReviewGate
|
|
|
16
16
|
type: "array",
|
|
17
17
|
items: {
|
|
18
18
|
type: "object",
|
|
19
|
-
properties: {
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
@stdout.puts
|
|
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
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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
|
-
|
|
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.
|
|
89
|
+
(DEFAULTS.keys - %w[environments verification evidence scenarios gate provenance]).each do |key|
|
|
90
|
+
define_method(key) { @data[key] }
|
|
91
|
+
end
|
|
45
92
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|