browser_review_gate 0.2.2 → 0.3.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.
@@ -3,35 +3,60 @@ require "openssl"
3
3
  require "uri"
4
4
 
5
5
  module BrowserReviewGate
6
- # What an agent needs to know before a browser run: the decision for the PR head and the next step.
6
+ # What an agent needs to know before a browser run: the decision for the PR head, the scenarios still
7
+ # owed with their status, the environment to use, the policy, and the next step.
7
8
  class Status
8
9
  RUN_STEPS = %w[run_and_publish run_and_save].freeze
9
10
 
10
- # `probe` takes the app URL and says whether something answers there.
11
+ # `probe` takes a URL and says whether something answers there.
11
12
  def initialize(github:, config: Config.new, shell: Shell.new, probe: nil, prompts: Prompts.new)
12
13
  @prompts = prompts
13
14
  @github = github
14
15
  @config = config
15
16
  @shell = shell
16
- @probe = probe || method(:answers?)
17
+ @probe = probe || Status.method(:answers?)
17
18
  end
18
19
 
19
- def to_h(number = nil)
20
+ def to_h(number = nil, environment: nil)
20
21
  result = facts(number)
21
22
  result["project_rules"] = Prompts::RULES if @prompts.rules
22
- result["app"] = app
23
+ result["environment"] = environment_details(environment)
24
+ result["policy"] = @config.policy
25
+ result["report"] = report_instructions
23
26
  # The agent never starts the app itself: a person decides what runs on their machine.
24
- result["next"] = "ask_to_start_app" if RUN_STEPS.include?(result["next"]) && result["app"]["running"] == false
27
+ result["next"] = "ask_to_start_app" if RUN_STEPS.include?(result["next"]) && result["environment"]["reachable"] == false
25
28
  result
26
29
  end
27
30
 
31
+ # Any HTTP answer counts, redirects are not followed, and only http(s) is tried. A local
32
+ # certificate is not checked. The URL comes from the project settings.
33
+ def self.answers?(url)
34
+ uri = URI(url)
35
+ return false unless %w[http https].include?(uri.scheme) && uri.host
36
+
37
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
38
+ open_timeout: 2, read_timeout: 3) { |http| http.head(uri.request_uri) }
39
+ true
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ # The URL a health check asks: an absolute `healthcheck`, or its path on the environment URL.
45
+ def self.healthcheck_url(environment)
46
+ check = environment["healthcheck"]
47
+ return environment["url"] if check.nil?
48
+ return check if check.start_with?("http://", "https://")
49
+
50
+ URI.join(environment["url"], check).to_s
51
+ end
52
+
28
53
  private
29
54
 
30
55
  def facts(number)
31
56
  local_sha = @shell.call("git", "rev-parse", "HEAD").strip
32
57
  saved = SavedReport.new(sha: local_sha, shell: @shell).exist?
33
58
  number ||= @github.pull_request_number_for_branch
34
- return { "pull_request" => nil, "local_head" => local_sha, "saved_run" => saved, "next" => saved ? "open_pull_request" : "run_and_save" } unless number
59
+ return { "pull_request" => nil, "local_head" => local_sha, "saved_run" => saved, "scenarios" => [], "next" => saved ? "open_pull_request" : "run_and_save" } unless number
35
60
 
36
61
  pull_request = @github.pull_request(number)
37
62
  comments = @github.trusted_comments(number)
@@ -39,17 +64,25 @@ module BrowserReviewGate
39
64
  assessment = Assessment.latest(comments)
40
65
  assessment = nil unless assessment&.for?(head_sha)
41
66
  labels = pull_request.fetch("labels").map { |label| label["name"] }
42
- report = Report.latest(comments)
43
- verified = labels.include?(@config.label) && (assessment ? assessment.covered_by?(report) : report&.passed? == true)
67
+ report = Report.latest(comments, config: @config)
68
+ commits = @github.pull_request_commits(number)
69
+ reuse = @config.scenarios["reuse_passing"]
70
+ verified = labels.include?(@config.label) && assessment&.covered_by?(report, commits: commits, reuse_passing: reuse) == true
44
71
  waived = labels.include?(@config.waiver_label)
45
72
 
46
73
  { "pull_request" => number, "pr_head" => head_sha, "local_head" => local_sha,
47
74
  "decision" => assessment&.decision, "reason" => assessment&.reason,
48
75
  # What the run must exercise; each id is the `id` of the case that reports it.
49
- "scenarios" => assessment ? assessment.outstanding(report) : [], "verified" => verified, "waived" => waived,
76
+ "scenarios" => assessment ? scenarios(assessment, report, commits, reuse) : [], "verified" => verified, "waived" => waived,
50
77
  "saved_run" => saved, "next" => next_step(assessment, verified || waived, saved, head_sha == local_sha) }
51
78
  end
52
79
 
80
+ def scenarios(assessment, report, commits, reuse)
81
+ assessment.outstanding(report, commits: commits, reuse_passing: reuse).map do |scenario|
82
+ scenario.merge("status" => report ? report.status_of(scenario["id"], commits: commits) : "missing")
83
+ end
84
+ end
85
+
53
86
  def next_step(assessment, cleared, saved, on_pr_head)
54
87
  return "nothing" if cleared || (assessment && !assessment.required?)
55
88
  return "check_out_pr_head" unless on_pr_head
@@ -59,20 +92,18 @@ module BrowserReviewGate
59
92
  "run_and_publish"
60
93
  end
61
94
 
62
- def app
63
- details = @config.app
64
- details["running"] = @probe.call(@config.url) if @config.url
95
+ def environment_details(name)
96
+ environment = @config.environment(name)
97
+ return { "name" => nil, "reachable" => nil, "note" => "No environment is configured in #{Config::PATH}." } unless environment
98
+
99
+ details = environment.slice("name", "url", "healthcheck", "start_command", "sign_in")
100
+ details["reachable"] = environment["url"] ? @probe.call(Status.healthcheck_url(environment)) : nil
65
101
  details
66
102
  end
67
103
 
68
- # Any HTTP answer counts; a local certificate is not checked.
69
- def answers?(url)
70
- uri = URI(url)
71
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
72
- open_timeout: 2, read_timeout: 3) { |http| http.head(uri.path.empty? ? "/" : uri.path) }
73
- true
74
- rescue StandardError
75
- false
104
+ def report_instructions
105
+ { "schema_version" => Report::SCHEMA_VERSION, "results" => Report::RESULTS, "capabilities" => Report::CAPABILITIES,
106
+ "evidence_paths" => "relative to the directory of the report file, outside the repository" }
76
107
  end
77
108
  end
78
109
  end
@@ -1,5 +1,5 @@
1
1
  <!-- browser_review_gate:start -->
2
2
  ## Browser verification before human review
3
3
 
4
- A PR that changes browser-visible behavior needs a browser run before a human reviews it; CI takes back reviewer requests until one is published. When asked to run `<%= command %>` (with or without a PR number), or to verify a branch or PR in the browser, read `<%= playbook_path %>` and follow it exactly. Right after opening a PR with `gh pr create`, run `browser-review-gate publish` if `browser-review-gate status` says `publish_saved`.
4
+ A PR that changes browser-visible behavior needs a browser run before a human reviews it; CI takes back reviewer requests until one is published. When asked to run `<%= command %>` (with or without a PR number), or to verify a branch or PR in the browser, read `<%= playbook_path %>` and follow it exactly. Right after opening a PR with `gh pr create`, run `browser-review-gate verify`: it publishes a run saved for this commit.
5
5
  <!-- browser_review_gate:end -->
@@ -1,10 +1,68 @@
1
1
  # Settings for browser_review_gate. Every key is optional.
2
+ #
3
+ # [enforced] settings are checked by the gem: it refuses a report or keeps review on hold.
4
+ # [agent] settings are instructions the AI coding agent receives; the gem cannot make a browser
5
+ # tool take a screenshot, it can only refuse a run without one when `gate.require_evidence` is on.
2
6
 
3
- # Where the running app answers, and how the person starts it. The agent never starts it itself.
4
- start_command:<%= start_command ? " #{start_command.inspect}" : "" %>
5
- url:<%= url ? " #{url.inspect}" : "" %>
6
- # How to sign in locally, e.g. "use the seed account from db/seeds.rb". No real credentials here.
7
- sign_in:
7
+ # Where the app runs for a browser run. The agent opens `url`; `healthcheck` (a path on `url`, or a
8
+ # full URL) is what `verify`, `status` and `doctor` ask to see whether the app answers. Nobody starts
9
+ # the app automatically: `start_command` is what the person runs. `sign_in` is a hint such as "use the
10
+ # seed account from db/seeds.rb"; never put real credentials here, this file is in the repository.
11
+ # A cloud agent cannot reach localhost: give it an environment with a URL it can reach (staging, a
12
+ # preview deployment) and select it with `--environment`.
13
+ environments:
14
+ local:
15
+ url:<%= url ? " #{url.inspect}" : "" %>
16
+ healthcheck:<%= url ? ' "/up"' : "" %>
17
+ start_command:<%= start_command ? " #{start_command.inspect}" : "" %>
18
+ sign_in:
19
+ # staging:
20
+ # url: "https://staging.example.com"
21
+ # healthcheck: "/up"
22
+
23
+ verification:
24
+ # The environment used when none is named with --environment.
25
+ environment: local
26
+ # [enforced] A case that passes but reports console errors / failed network requests counts as failed.
27
+ fail_on_console_errors: false
28
+ fail_on_network_errors: false
29
+
30
+ evidence:
31
+ # [agent] never, on_failure or always. [enforced] with gate.require_evidence, for tools that can.
32
+ screenshots: on_failure
33
+ trace: never
34
+ # [agent] Record console errors and failed requests per case. [enforced] with gate.require_evidence.
35
+ console_logs: true
36
+ network_logs: true
37
+ # [enforced] Saved runs and kept evidence files in the git directory are removed after this many days.
38
+ retention_days: 14
39
+ # [enforced] Hosts that evidence links may point to, e.g. your artifact bucket. Empty: no links.
40
+ link_hosts: []
41
+
42
+ scenarios:
43
+ # [enforced] The most scenarios the assessing model may ask for (1-30). Mandatory ones come on top.
44
+ max_count: 12
45
+ # [enforced] A passing case from an earlier commit of the PR keeps counting after unrelated commits.
46
+ # false: every scenario must pass on the newest commit.
47
+ reuse_passing: true
48
+ # [enforced] Scenarios added to every assessment that needs a run, whatever the model proposes. With
49
+ # `paths`, only when the change touches them, and then a run is needed whatever the model decides.
50
+ # `matrix: true` repeats the scenario for every combination below (at most 24 cases in total).
51
+ mandatory: []
52
+ # - id: SIGN-IN
53
+ # name: Sign in and land on the dashboard
54
+ # why: Every change to authentication must keep sign-in working.
55
+ # paths: ["app/controllers/sessions_controller.rb", "app/views/sessions/**"]
56
+ # matrix: true
57
+ matrix: {}
58
+ # role: [admin, member]
59
+ # viewport: [desktop, mobile]
60
+
61
+ gate:
62
+ # [enforced] Refuse runs whose passing cases lack the evidence asked for above.
63
+ require_evidence: false
64
+ # [enforced] Any failing case keeps review on hold, not only failing required scenarios.
65
+ block_on_failure: true
8
66
 
9
67
  # Who assesses a PR in CI. Unset: the Claude Code CLI with a CLAUDE_CODE_OAUTH_TOKEN or
10
68
  # ANTHROPIC_API_KEY secret; with only an OPENAI_API_KEY or GEMINI_API_KEY secret, that provider's API.
@@ -1,56 +1,94 @@
1
1
  <!-- browser_review_gate:managed <%= BrowserReviewGate::VERSION %> — re-run `browser-review-gate install` to update. -->
2
2
  # Browser verification before human review
3
3
 
4
- A pull request that changes what a person sees or does in a browser must be exercised in a browser before a human reviews it. CI decides whether a PR needs that and writes the decision in one "Browser verification" comment. Until a passing run is published, reviewer requests on such a PR are taken back.
4
+ A pull request that changes what a person sees or does in a browser must be exercised in a browser before a human reviews it. CI decides whether a PR needs that and writes the decision in one "Browser verification" comment, with the scenarios a run must pass. Until a run covering them is published, reviewer requests on such a PR are taken back.
5
5
 
6
6
  This file is the playbook for the AI coding agent that does the run. The person starts it with `<%= command %> NUMBER` for an open PR, or `<%= command %>` on a branch that has no PR yet.
7
7
 
8
+ **Who does what.** You, the agent, drive the browser with your browser tool (Playwright, Chrome DevTools MCP or similar) and write down what you saw. `browser-review-gate` does the bookkeeping: which scenarios are owed, which environment to use, whether the app answers, and checking and publishing your report. It never drives a browser, and it cannot see what you did: your report is your account, so it must be accurate.
9
+
8
10
  ## Steps
9
11
 
10
- 1. Run `browser-review-gate status` (add `--pr NUMBER` when a number was given). It prints JSON; follow `next`:
11
- - `nothing`: no run is needed, or the PR is already verified. Say that review can be requested.
12
+ 1. Run `browser-review-gate verify --json` (add `--pr NUMBER` when a number was given, `--environment NAME` when the person named one). Follow `outcome`:
13
+ - `nothing`: no run is needed, or the PR is already verified or waived. Say that review can be requested.
12
14
  - `check_out_pr_head`: check out the PR head with a clean worktree, then start again.
13
- - `request_assessment`: run `browser-review-gate request-assessment NUMBER`, wait about a minute, then start again.
14
- - `publish_saved`: a run saved for this commit exists. Run `browser-review-gate publish` and stop; nothing runs again.
15
+ - `assessment_requested`: CI was asked to assess the PR. Wait about a minute, then start again.
16
+ - `published` / `not_verified`: a run saved for this commit was published. Report the result and stop.
15
17
  - `open_pull_request`: a run is saved and waits for the PR. Tell the person to open it.
16
- - `ask_to_start_app`: the app is not running. Do not start it yourself: ask the person to start it (`app.start_command` says how) and stop until they have.
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.
17
19
  - `run_and_publish` or `run_and_save`: continue with step 2.
18
20
  2. Decide what to run.
19
- - `scenarios` in the status output is the list CI wrote for this PR. Run every one of them, and give each case the `id` of its scenario. You may add cases of your own; you may not drop or rename one from the list.
20
- - 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 and which scenarios each area needs. Its rules are mandatory. CI compares your cases with its own list once the PR is open and asks for what is missing.
21
- - Do not re-run behavior an earlier passing case still covers.
22
- 3. Open the running app in a browser tool. `app` in the status output gives the URL and a sign-in hint. Never start, restart or stop the app yourself: what runs on the person's machine is their decision. Use local 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.
23
- 4. Run every scenario end to end. After key actions check the page state, console errors, and failed requests. For each case record the page it was exercised on (`url`) and what you observed there, including console errors and failed requests (`details`). A case without both is refused. A skipped or blocked scenario is not a pass.
24
- 5. Write the report as JSON outside the repository (see the shape below). Set `coverage_complete` to true only when every scenario from step 2 ran. CI checks the report against its list by case `id`: a missing scenario takes the label away again.
25
- 6. Publish or save, from the checkout that was tested, with everything committed and pushed:
26
- - PR exists: `browser-review-gate publish --report PATH` (add `--pr NUMBER` when the branch has several). It posts the cases with their outcomes. Only a complete all-pass report adds the `<%= label %>` label.
27
- - No PR yet: `browser-review-gate save --report PATH`. The run is published when the PR for this commit is opened. A new commit makes it stale.
28
- 7. Check the command result and the PR. A non-zero exit can still mean a report was posted: a failing or incomplete report is visible in the PR and review stays closed.
29
- 8. Request a reviewer only when the person named one and only after the run passed. Reviewers whose requests CI took back earlier are asked again automatically. Otherwise say that review can be requested.
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.
22
+ - 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
+ - Do not re-run a scenario whose earlier case still counts; `verify` lists only what is owed.
24
+ 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.
25
+ 4. Run every scenario end to end. For each case record:
26
+ - `url`: the page it was exercised on; `details`: what you observed;
27
+ - evidence, as `policy.agent` in the `verify` output asks: screenshots (`never`, `on_failure`, `always`), a trace, and the console errors and failed requests you saw (`[]` when there were none). Save files in a directory outside the repository, next to the report.
28
+ - A scenario you could not run is `blocked` (something prevented it) or `skipped` (deliberately not run), with a `reason`. Neither is a pass.
29
+ 5. Write the report as JSON next to the evidence (see the shape below). Set `coverage_complete` to true only when every scenario from step 2 ran.
30
+ 6. Publish, from the checkout that was tested, with everything committed and pushed: `browser-review-gate verify --report PATH` (add `--pr NUMBER` when the branch has several PRs). With a PR it is published; without one it is saved and published when the PR for this commit is opened.
31
+ 7. Check the result. Exit status 0 means review can proceed; 7 means the report was published but review stays on hold (a scenario is missing, failed, or broke a project policy); 1 means the report was refused, with the reason. A refused report is not published: fix it and publish again.
32
+ 8. Request a reviewer only when the person named one and only after the run passed. Reviewers whose requests CI took back earlier are asked again automatically.
33
+
34
+ ## What the gem checks, and what it cannot
35
+
36
+ - It refuses a report with an unknown key, a missing field, an evidence path outside the report directory, a file that is not the image or archive its name says, a link to a host the project did not list, or a commit other than the checked-out one.
37
+ - With `gate.require_evidence`, a passing case without the screenshots, trace or logs the project asks for does not count, unless your tool lacks that capability (say so in `tool.capabilities`).
38
+ - With `verification.fail_on_console_errors` / `fail_on_network_errors`, a passing case that lists such errors counts as failed.
39
+ - Only cases tested on a commit of this PR count.
40
+ - It cannot tell whether a browser really did what you wrote. File digests show that files existed when the run was published, not who made them.
30
41
 
31
42
  ## When a scenario cannot be run
32
43
 
33
- Some scenarios cannot be exercised locally, for example a sign-in through an external provider. Do not mark the run complete and do not drop the scenario silently. Tell the person. They can list it under `excluded` in the report, or someone other than the author can put the `<%= waiver_label %>` label on the PR to let review proceed without a run.
44
+ Some scenarios cannot be exercised locally, for example a sign-in through an external provider. Report it as `blocked` with the reason, or, when the person decided so, list it under `excluded`. Neither counts as a pass, so review stays on hold for that scenario: tell the person. Someone other than the author can put the `<%= waiver_label %>` label on the PR to let review proceed without a run.
34
45
 
35
46
  ## Report shape
36
47
 
37
48
  ```json
38
49
  {
39
- "tested_sha": "<full commit SHA that was tested>",
50
+ "schema_version": 2,
51
+ "tested_sha": "<full 40-character SHA of the commit that was tested>",
52
+ "environment": "local",
53
+ "tool": { "name": "Playwright MCP", "version": "0.0.40", "capabilities": ["screenshots", "trace", "console", "network"] },
54
+ "started_at": "2026-11-11T10:00:00Z",
55
+ "finished_at": "2026-11-11T10:06:30Z",
40
56
  "coverage_complete": true,
41
57
  "cases": [
42
- { "id": "BROWSER-1", "name": "The changed interaction completes", "result": "pass", "url": "/the/page/exercised", "details": "What was observed, with console errors and failed requests if any." }
58
+ {
59
+ "id": "BROWSER-1",
60
+ "name": "Submit the sign-up form and land on the dashboard",
61
+ "result": "pass",
62
+ "url": "/signup",
63
+ "details": "The form submitted; the dashboard showed the new account's name.",
64
+ "evidence": {
65
+ "screenshots": ["artifacts/signup-done.png"],
66
+ "trace": "artifacts/signup-trace.zip",
67
+ "console_errors": [],
68
+ "network_errors": []
69
+ }
70
+ },
71
+ {
72
+ "id": "SIGN-IN.member.mobile",
73
+ "name": "Sign in as a member at phone width",
74
+ "result": "blocked",
75
+ "reason": "The member seed account is missing from the local database.",
76
+ "details": "The sign-in form rejected the seed credentials."
77
+ }
43
78
  ],
44
79
  "excluded": [
45
- { "name": "Sign-in through an external provider", "reason": "Needs a real provider account; covered by integration tests." }
80
+ { "id": "OAUTH-1", "name": "Sign-in through an external provider", "reason": "Needs a real provider account; covered by integration tests." }
46
81
  ]
47
82
  }
48
83
  ```
49
84
 
50
- `result` is `pass` or `fail`. `url` is an http(s) URL or a path starting with `/`. `excluded` is optional: list a scenario there only when the person decided it stays outside the local run. Never exclude a scenario on your own to make the run complete. Keep customer and real company names out of the report.
85
+ - `result`: `pass`, `fail`, `blocked` or `skipped`. `url` is an http(s) URL or a path starting with `/`; required for `pass` and `fail`. `reason` is required for `blocked` and `skipped`.
86
+ - `tool.capabilities`: what your browser tool could record, from `screenshots`, `trace`, `console`, `network`. Claim only what it really can.
87
+ - `evidence` paths are relative to the report file's directory. Accepted: `.png`, `.jpg`, `.jpeg`, `.webp` screenshots and a `.zip` trace, at most 10 files of 20 MB each. `network_errors` entries may be strings or `{ "method", "url", "status" }`; query strings are dropped. `links` may point to evidence kept elsewhere, on hosts listed in `evidence.link_hosts`.
88
+ - Everything you write is scrubbed of values that look like credentials, but do not rely on it: never put cookies, tokens, passwords or customer data into a report, a screenshot or a trace. Keep customer and real company names out of the report.
51
89
 
52
90
  ## Rules
53
91
 
54
- - Run the awkward path in the browser, or mark coverage incomplete and leave review closed. Do not declare a run done from tests and code reading.
55
- - Keep the report file and browser artifacts outside the repository. The publisher refuses a checkout with modified or untracked files and lists them.
56
- - After an unrelated commit, do not run everything again: use the PR comment and the earlier report to pick only what is new.
92
+ - Run the awkward path in the browser, or report it as blocked and leave review on hold. Do not declare a run done from tests and code reading.
93
+ - Keep the report file and evidence outside the repository. The publisher refuses a checkout with modified or untracked files and lists them.
94
+ - After an unrelated commit, do not run everything again: `verify` lists only what is new or no longer counts.
@@ -16,7 +16,10 @@ one is ignored. The built-in rules stay in force; these are added on top and win
16
16
  ## Always check
17
17
 
18
18
  <!-- Example: Run every scenario once as an administrator and once as a regular member.
19
- Example: Repeat the main scenario at a phone-sized window. -->
19
+ Example: Repeat the main scenario at a phone-sized window.
20
+ These are read by the model and the agent. To make a scenario or a role/viewport combination
21
+ impossible to leave out, declare it under `scenarios.mandatory` and `scenarios.matrix` in
22
+ .github/browser-review-gate.yml: those are added by the gem and checked case by case. -->
20
23
 
21
24
  ## Scenarios by area
22
25
 
@@ -0,0 +1,86 @@
1
+ module BrowserReviewGate
2
+ # One entry point for the verification workflow. Ruby does the bookkeeping: which PR, which decision,
3
+ # which scenarios are still owed, which environment, whether the app answers, and publishing what an
4
+ # agent observed. The browser run itself happens in the AI coding agent (Claude Code, Cursor, Codex)
5
+ # with its browser tool; nothing here drives a browser.
6
+ class Verify
7
+ # Exit statuses, so an agent or a script can branch on the outcome.
8
+ EXIT = {
9
+ "nothing" => 0, "published" => 0, "saved" => 0,
10
+ "run_and_publish" => 2, "run_and_save" => 2,
11
+ "ask_to_start_app" => 3,
12
+ "request_assessment" => 4, "assessment_requested" => 4,
13
+ "check_out_pr_head" => 5, "open_pull_request" => 5,
14
+ "publish_saved" => 6, "not_verified" => 7
15
+ }.freeze
16
+
17
+ def initialize(github:, config:, prompts:, shell: Shell.new, probe: nil, publisher_factory: nil)
18
+ @github = github
19
+ @config = config
20
+ @prompts = prompts
21
+ @shell = shell
22
+ @probe = probe
23
+ @publisher_factory = publisher_factory || lambda { |number, path|
24
+ Publisher.new(github: github, number: number, report_path: path, config: config, shell: shell)
25
+ }
26
+ end
27
+
28
+ # Returns [outcome, details]. `report` publishes (or saves, before the PR exists) a finished run.
29
+ # `request` lets a missing assessment be requested from CI.
30
+ def run(number: nil, environment: nil, report: nil, request: true)
31
+ status = Status.new(github: @github, config: @config, shell: @shell, probe: @probe, prompts: @prompts).to_h(number, environment: environment)
32
+ outcome = status["next"]
33
+ return [ submit(status, report), status ] if report && %w[run_and_publish run_and_save publish_saved].include?(outcome)
34
+
35
+ if outcome == "publish_saved"
36
+ outcome = @publisher_factory.call(status["pull_request"], nil).publish ? "published" : "not_verified"
37
+ elsif outcome == "request_assessment" && request
38
+ @github.dispatch_workflow(@config.workflow, status["pull_request"])
39
+ outcome = "assessment_requested"
40
+ end
41
+ [ outcome, status ]
42
+ end
43
+
44
+ def self.exit_status(outcome) = EXIT.fetch(outcome, 1)
45
+
46
+ # What to tell the person or the agent for each outcome.
47
+ def self.describe(outcome, status)
48
+ environment = status["environment"] || {}
49
+ case outcome
50
+ when "nothing" then "Nothing to run: #{status["waived"] ? "verification was waived" : "no browser run is needed or the PR is verified"}. Human review can be requested."
51
+ when "published" then "Browser verification published; human review can be requested."
52
+ when "saved" then "Browser run saved. It is published when the PR for this commit is opened."
53
+ when "not_verified" then "Results published, but required scenarios still need a passing case. Review stays on hold."
54
+ when "assessment_requested" then "CI has been asked to assess PR #{status["pull_request"]}. Run verify again in about a minute."
55
+ when "request_assessment" then "PR #{status["pull_request"]} has no assessment yet. Run verify without --no-request to ask CI for one."
56
+ when "check_out_pr_head" then "Check out the PR head (#{status["pr_head"]}) with a clean worktree, then run verify again."
57
+ when "open_pull_request" then "A run is saved for this commit. Open the pull request; it is published then."
58
+ when "ask_to_start_app" then "The #{environment["name"]} environment does not answer at #{environment["url"]}. Ask the person to start it" \
59
+ "#{environment["start_command"] ? " (#{environment["start_command"]})" : ""}; do not start it yourself."
60
+ else run_instructions(status)
61
+ end
62
+ end
63
+
64
+ def self.run_instructions(status)
65
+ environment = status["environment"] || {}
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}" ]
69
+ [ "A browser run is needed#{status["pull_request"] ? " for PR ##{status["pull_request"]}" : ""}.",
70
+ "Environment: #{environment["name"] || "not configured"}#{environment["url"] ? " at #{environment["url"]}" : ""}.",
71
+ "Scenarios to run in your browser tool, one case per id:", *list,
72
+ "Then: browser-review-gate verify --report PATH (publishes, or saves before the PR exists)." ].join("\n")
73
+ end
74
+
75
+ private
76
+
77
+ def submit(status, report)
78
+ if status["pull_request"]
79
+ @publisher_factory.call(status["pull_request"], report).publish ? "published" : "not_verified"
80
+ else
81
+ @publisher_factory.call(nil, report).save
82
+ "saved"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,3 +1,3 @@
1
1
  module BrowserReviewGate
2
- VERSION = "0.2.2"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -23,6 +23,9 @@ module BrowserReviewGate
23
23
  autoload :Assessment, File.join(__dir__, "browser_review_gate/assessment")
24
24
  autoload :Assessor, File.join(__dir__, "browser_review_gate/assessor")
25
25
  autoload :CLI, File.join(__dir__, "browser_review_gate/cli")
26
+ autoload :Doctor, File.join(__dir__, "browser_review_gate/doctor")
27
+ autoload :Evidence, File.join(__dir__, "browser_review_gate/evidence")
28
+ autoload :Verify, File.join(__dir__, "browser_review_gate/verify")
26
29
  autoload :Config, File.join(__dir__, "browser_review_gate/config")
27
30
  autoload :Gate, File.join(__dir__, "browser_review_gate/gate")
28
31
  autoload :GitHub, File.join(__dir__, "browser_review_gate/github")
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.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Islam Gagiev
@@ -43,6 +43,8 @@ files:
43
43
  - lib/browser_review_gate/assessor.rb
44
44
  - lib/browser_review_gate/cli.rb
45
45
  - lib/browser_review_gate/config.rb
46
+ - lib/browser_review_gate/doctor.rb
47
+ - lib/browser_review_gate/evidence.rb
46
48
  - lib/browser_review_gate/gate.rb
47
49
  - lib/browser_review_gate/github.rb
48
50
  - lib/browser_review_gate/hook.rb
@@ -65,6 +67,7 @@ files:
65
67
  - lib/browser_review_gate/templates/policy.md
66
68
  - lib/browser_review_gate/templates/rules.md.erb
67
69
  - lib/browser_review_gate/templates/workflow.yml.erb
70
+ - lib/browser_review_gate/verify.rb
68
71
  - lib/browser_review_gate/version.rb
69
72
  - lib/generators/browser_review_gate/install/install_generator.rb
70
73
  homepage: https://github.com/Halvanhelv/browser_review_gate