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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf07fc0385a222895293b09a8000db74bf153a3b87731528b3e2d51c20cfd4e0
|
|
4
|
+
data.tar.gz: 5866378bb1acbfa17f03d44d98413c6f6222903858b7ec7dd6f71a9b6ba95788
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de467b5092e7d8d3e9871e789b2c844712808f6af4916173d3ec903fb84407eac61218c5e6ee54e4c20e91e10ccdd3cb4d06010a355e2ddae869118b496c4791
|
|
7
|
+
data.tar.gz: 4ca8044e0cc223d3e269542ff1edb0ab635bdd710c549d017e48802c834eeef9b00fb2ff7ed71f9c9be37bd683185ea2d5450e95256574da7b12cf82723b7979
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- `provenance.trace` (off by default): the gem opens each case's Playwright trace and checks that a
|
|
6
|
+
response carried the tested commit in `revision_header`, that the case's page was requested, and
|
|
7
|
+
that requests fall within the run's time window. `warn` shows the result in the PR; `require`
|
|
8
|
+
counts a passing case only with a verified trace. Traces are read in memory with only zlib, with
|
|
9
|
+
caps on entries, sizes and compression ratio.
|
|
10
|
+
- A HAR network log (`.har`) is accepted as a case's trace too, so the check works with any browser
|
|
11
|
+
tool that saves HAR, not only Playwright.
|
|
12
|
+
- `doctor` warns when `provenance.trace` is on and no Playwright is configured in the project; `verify`
|
|
13
|
+
tells the agent a trace or HAR is needed per case.
|
|
14
|
+
- `BrowserReviewGate::RevisionHeader`: Rack middleware for development that sends `X-App-Revision`
|
|
15
|
+
with the checked-out commit and follows new commits without a restart.
|
|
16
|
+
- `doctor` checks that the app sends the revision header and that it matches the checkout.
|
|
17
|
+
- The installer's next steps name the 0.3 settings.
|
|
18
|
+
|
|
19
|
+
## 0.3.0
|
|
20
|
+
|
|
21
|
+
Breaking: the report format and the settings change, and nothing from 0.2 is read. Move `url`,
|
|
22
|
+
`start_command` and `sign_in` under `environments.local`, re-run `browser-review-gate install`, and
|
|
23
|
+
publish a new run on PRs verified with a 0.2 report.
|
|
24
|
+
|
|
25
|
+
- Reports (schema 2) name the environment, the browser tool and its capabilities, and the run's start
|
|
26
|
+
and finish times. Cases are `pass`, `fail`, `blocked` or `skipped`; the last two need a reason.
|
|
27
|
+
Validation is strict: unknown keys and malformed values make a report invalid.
|
|
28
|
+
- Evidence per case: screenshots and traces are read from the report directory, confined to it,
|
|
29
|
+
checked by content, hashed and kept in the git directory; console errors and failed requests are
|
|
30
|
+
scrubbed of credential-like values and query strings; links only to hosts in `evidence.link_hosts`.
|
|
31
|
+
- Named environments with health checks. `--environment` selects one. Nothing starts the app.
|
|
32
|
+
- Only cases tested on a commit of the PR count; a case from elsewhere shows as stale.
|
|
33
|
+
- Settings sections `verification`, `evidence`, `scenarios` and `gate`: fail a case on console or
|
|
34
|
+
network errors, require evidence (only what the tool can produce), keep earlier passing cases or
|
|
35
|
+
not, limit the scenario count, block on any failure or only on required scenarios.
|
|
36
|
+
- Mandatory scenarios and a role/viewport matrix in the settings, added by the gem after the model
|
|
37
|
+
answers. A mandatory scenario scoped to paths makes a run required whatever the model decides.
|
|
38
|
+
- Scenarios carry a priority, a category and why they are needed; ids are derived from behavior so
|
|
39
|
+
they stay stable across commits.
|
|
40
|
+
- `verify`: one entry point that says what to run, checks the environment, requests a missing
|
|
41
|
+
assessment, and publishes or saves a finished run, with meaningful exit statuses and `--json`.
|
|
42
|
+
- `doctor` (and `--json`): checks the setup end to end, with SKIP where something cannot be seen.
|
|
43
|
+
- `stats` adds published and failing runs, failing scenarios, the median time to a verified run, and
|
|
44
|
+
token usage and cost as the provider reported them; `--json`.
|
|
45
|
+
- PR comments show each scenario as passed, failed, blocked, skipped, missing or stale, and whether
|
|
46
|
+
human review can proceed. The report comment is a table with the evidence of every case.
|
|
47
|
+
- Local commands read the settings from `origin/HEAD` when it has them, so a PR branch cannot point
|
|
48
|
+
the agent elsewhere or loosen the policy for its own run.
|
|
49
|
+
- Removed: the 0.1.1 `hook --before` no-op, and the pre-scenario assessment fallback.
|
|
50
|
+
|
|
3
51
|
## 0.2.2
|
|
4
52
|
|
|
5
53
|
- Requires json 2.7 or newer. Ruby 3.2 ships json 2.6, which ignores `script_safe`, so PR text in the
|
data/README.md
CHANGED
|
@@ -2,115 +2,212 @@
|
|
|
2
2
|
|
|
3
3
|
Keeps human review of a pull request behind a browser run of its browser-visible changes.
|
|
4
4
|
|
|
5
|
-
- CI reads each PR and decides whether it changes what a person sees or does in a browser. The decision goes into one PR comment, together with the scenarios a browser run must pass.
|
|
5
|
+
- CI reads each PR and decides whether it changes what a person sees or does in a browser. The decision goes into one PR comment, together with the scenarios a browser run must pass and why each one is needed.
|
|
6
6
|
- When a run is needed and none is published, reviewer requests are taken back and the PR says which command to run.
|
|
7
|
-
- An AI coding agent (Claude Code, Cursor, Codex) runs the scenarios in a real browser and publishes what it observed
|
|
8
|
-
-
|
|
7
|
+
- An AI coding agent (Claude Code, Cursor, Codex) runs the scenarios in a real browser with its own browser tool and publishes what it observed, with evidence: screenshots, traces, console errors and failed requests.
|
|
8
|
+
- The gem checks the report strictly, compares its cases with CI's list, and opens review only when every scenario has a case that counts.
|
|
9
|
+
|
|
10
|
+
**What it is not.** The gem is an orchestration and enforcement layer. It does not drive a browser and ships no browser runtime: the agent does the run with Playwright, Chrome DevTools MCP or whatever browser tool it has. The gem decides what must be run, checks what the agent reports, and keeps review on hold until the report covers the change.
|
|
9
11
|
|
|
10
12
|
## Install
|
|
11
13
|
|
|
12
14
|
```sh
|
|
13
15
|
bundle add browser_review_gate --group development
|
|
14
16
|
bundle exec browser-review-gate install
|
|
17
|
+
bundle exec browser-review-gate doctor
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
In a Rails app the
|
|
20
|
+
In a Rails app the install step is also `bin/rails generate browser_review_gate:install`.
|
|
18
21
|
|
|
19
|
-
One run sets up everything and can be repeated after a gem update:
|
|
22
|
+
One run sets up everything and can be repeated after a gem update (it is idempotent and leaves files you wrote alone unless you pass `--force`):
|
|
20
23
|
|
|
21
24
|
| File | Purpose |
|
|
22
25
|
|---|---|
|
|
23
26
|
| `.github/workflows/browser-review-gate.yml` | Assesses PRs and takes back premature reviewer requests |
|
|
24
|
-
| `.github/browser-review-gate.yml` | Settings
|
|
25
|
-
| `.github/browser-review-gate/rules.md` | Your application's rules
|
|
27
|
+
| `.github/browser-review-gate.yml` | Settings (see below) |
|
|
28
|
+
| `.github/browser-review-gate/rules.md` | Your application's rules in plain language, read by the model and the agent |
|
|
26
29
|
| `docs/browser-review-gate.md` | The playbook every agent follows |
|
|
27
30
|
| `.claude/skills/browser-pr-verification/SKILL.md`, hook in `.claude/settings.json` | Claude Code |
|
|
28
31
|
| `.cursor/commands/browser-pr-verification.md`, hook in `.cursor/hooks.json` | Cursor |
|
|
29
|
-
| Section in `AGENTS.md`, hook in `.codex/hooks.json` | Codex
|
|
32
|
+
| Section in `AGENTS.md`, hook in `.codex/hooks.json` | Codex and other agents that read `AGENTS.md` |
|
|
30
33
|
|
|
31
|
-
The installer detects which agents the project uses
|
|
34
|
+
The installer detects which agents the project uses and sets up all of them; choose with `--agents claude,cursor,codex`, preview with `--dry-run`.
|
|
32
35
|
|
|
33
36
|
Then:
|
|
34
37
|
|
|
35
|
-
1. Add one secret to the repository: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` or `GEMINI_API_KEY
|
|
36
|
-
2.
|
|
38
|
+
1. Add one secret to the repository: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` or `GEMINI_API_KEY`.
|
|
39
|
+
2. Describe your environments in `.github/browser-review-gate.yml`.
|
|
37
40
|
3. Merge to the default branch. The workflow uses `pull_request_target`, so it runs from there.
|
|
41
|
+
4. Optionally make the `browser-verification` status a required check so the gate also blocks merging.
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
`browser-review-gate doctor` checks all of it and says what to fix.
|
|
40
44
|
|
|
41
|
-
##
|
|
45
|
+
## How a PR goes through
|
|
42
46
|
|
|
43
|
-
The
|
|
47
|
+
1. The author runs `/browser-pr-verification` (the agent command) on the branch. The agent runs `browser-review-gate verify --json`, which says what to run, in which environment, and whether the app answers.
|
|
48
|
+
2. The agent runs the scenarios in its browser tool, writes a report with evidence next to it, and runs `browser-review-gate verify --report PATH`. Before the PR exists the run is saved for that commit; afterwards it is published.
|
|
49
|
+
3. When the PR is opened, the agent's post-command hook publishes the saved run.
|
|
50
|
+
4. CI assesses the PR, adds the project's mandatory scenarios, and matches the published cases against the list. When every scenario has a counting case, reviewer requests stay; reviewers taken back earlier are asked again automatically.
|
|
51
|
+
|
|
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
|
+
|
|
54
|
+
## Settings
|
|
55
|
+
|
|
56
|
+
`.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
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
environments:
|
|
60
|
+
local:
|
|
61
|
+
url: "http://localhost:3000" # what the agent opens
|
|
62
|
+
healthcheck: "/up" # path on url, or a full URL; what verify/doctor ask
|
|
63
|
+
start_command: "bin/dev" # what the person runs; nothing starts the app automatically
|
|
64
|
+
sign_in: "Use the seed accounts from db/seeds.rb"
|
|
65
|
+
staging:
|
|
66
|
+
url: "https://staging.example.com"
|
|
67
|
+
healthcheck: "/up"
|
|
68
|
+
|
|
69
|
+
verification:
|
|
70
|
+
environment: local # default; override with --environment
|
|
71
|
+
fail_on_console_errors: true # enforced
|
|
72
|
+
fail_on_network_errors: false # enforced
|
|
73
|
+
|
|
74
|
+
evidence:
|
|
75
|
+
screenshots: on_failure # agent: never | on_failure | always; enforced with gate.require_evidence
|
|
76
|
+
trace: never # agent: never | on_failure | always; enforced with gate.require_evidence
|
|
77
|
+
console_logs: true # agent; enforced with gate.require_evidence
|
|
78
|
+
network_logs: true # agent; enforced with gate.require_evidence
|
|
79
|
+
retention_days: 14 # enforced: saved runs and kept evidence in .git are pruned
|
|
80
|
+
link_hosts: ["artifacts.example.com"] # enforced: hosts evidence links may point to
|
|
81
|
+
|
|
82
|
+
scenarios:
|
|
83
|
+
max_count: 12 # enforced: most scenarios the model may ask for (1-30)
|
|
84
|
+
reuse_passing: true # enforced: earlier passing cases on PR commits keep counting
|
|
85
|
+
mandatory: # enforced: added by the gem, not proposed by the model
|
|
86
|
+
- id: SIGN-IN
|
|
87
|
+
name: Sign in and land on the dashboard
|
|
88
|
+
why: Authentication changes must keep sign-in working.
|
|
89
|
+
paths: ["app/controllers/sessions_controller.rb", "app/views/sessions/**"]
|
|
90
|
+
matrix: true
|
|
91
|
+
matrix: # enforced: at most 24 mandatory cases after expansion
|
|
92
|
+
role: [admin, member]
|
|
93
|
+
viewport: [desktop, mobile]
|
|
94
|
+
|
|
95
|
+
gate:
|
|
96
|
+
require_evidence: false # enforced: passing cases need the evidence asked for above
|
|
97
|
+
block_on_failure: true # enforced: any failing case keeps review on hold
|
|
98
|
+
|
|
99
|
+
provenance:
|
|
100
|
+
trace: "off" # enforced: off | warn | require (see "Checking that a run happened")
|
|
101
|
+
revision_header: "X-App-Revision"
|
|
102
|
+
check_pages: true
|
|
103
|
+
clock_skew_seconds: 300
|
|
104
|
+
```
|
|
44
105
|
|
|
45
|
-
|
|
46
|
-
|---|---|
|
|
47
|
-
| `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` | The Claude Code CLI, as before |
|
|
48
|
-
| only `OPENAI_API_KEY` | The OpenAI API; set `model` in the settings file |
|
|
49
|
-
| only `GEMINI_API_KEY` | The Gemini API; set `model` in the settings file |
|
|
106
|
+
Other keys: `provider` and `model` (who assesses, see below), `workflow`, `label`, `waiver_label`, `waiver_by_author`, `status_context`, `command`, `skip_authors`, `skip_branch_prefixes`, `ignore_paths`.
|
|
50
107
|
|
|
51
|
-
|
|
108
|
+
**Environments.** A local agent uses `local`. A cloud agent cannot reach `localhost`: give it an environment it can reach (staging, a preview deployment) and run `verify --environment staging`. `url` is what the agent opens; `healthcheck` may point elsewhere, for example a full health URL behind the same host. Put no credentials in this file: it is in the repository, and its values appear in `verify` output.
|
|
52
109
|
|
|
53
|
-
|
|
110
|
+
**Mandatory scenarios and matrices.** A mandatory entry with `paths` makes a run required whenever the change touches those paths, whatever the model decides. Without `paths` it is added to every run that is required. `matrix: true` repeats it for every combination of the matrix, as ids such as `SIGN-IN.admin.mobile`, so the gate can tell exactly which combination is missing.
|
|
54
111
|
|
|
55
|
-
|
|
112
|
+
**Not configurable on purpose.** Run timeouts and retries are not settings: the browser run happens in the agent, and the gem cannot enforce them. Tell the agent in `rules.md` if you have expectations.
|
|
56
113
|
|
|
57
|
-
|
|
114
|
+
## Checking that a run happened
|
|
58
115
|
|
|
59
|
-
|
|
60
|
-
- what every run always checks, such as roles or window sizes;
|
|
61
|
-
- which scenarios each area needs;
|
|
62
|
-
- which accounts and data to use.
|
|
116
|
+
Off by default. A report is the agent's account; `provenance.trace` lets the gem check it against a Playwright trace instead of taking it on trust.
|
|
63
117
|
|
|
64
|
-
|
|
118
|
+
1. Make the app name the commit it serves, in development only:
|
|
65
119
|
|
|
66
|
-
|
|
120
|
+
```ruby
|
|
121
|
+
# config/environments/development.rb
|
|
122
|
+
config.middleware.use BrowserReviewGate::RevisionHeader
|
|
123
|
+
```
|
|
67
124
|
|
|
68
|
-
|
|
125
|
+
It adds `X-App-Revision: <HEAD SHA>` to every response and follows new commits without a restart. Any other way of sending the header works too; name it in `provenance.revision_header`.
|
|
126
|
+
2. Set `provenance.trace` to `warn` (results shown in the PR) or `require` (a passing case counts only with a verified trace). `doctor` checks that the app sends the header and that it matches the checkout.
|
|
127
|
+
3. The agent records a Playwright trace (`.zip`) or a HAR network log (`.har`) per case and lists it as `evidence.trace`. HAR is a standard format that Playwright, Chrome DevTools ("Save all as HAR") and many other tools write, so the check does not need Playwright itself. A browser tool that can save neither cannot satisfy `require`; `doctor` and `verify` warn about it before a run.
|
|
69
128
|
|
|
70
|
-
|
|
129
|
+
When the run is published, the gem opens each trace (a zip, or HAR JSON; read in memory with size, entry and compression-ratio caps, no extra dependency) and checks its network log: a response carried the revision header with the tested commit, the case's page was requested, and the request times fall within `started_at`..`finished_at` (± `clock_skew_seconds`). The PR shows "trace verified" or what was wrong.
|
|
71
130
|
|
|
72
|
-
|
|
73
|
-
2. The PR is opened. In Claude Code, Cursor and Codex a hook publishes the saved results right after `gh pr create` (Codex asks you to trust the hook once in `/hooks`). Anywhere else, the command from the PR comment publishes them without a new run.
|
|
74
|
-
3. CI writes its own list of scenarios and matches the published cases against it. When every scenario has a passing case, reviewer requests stay in place. Otherwise the label goes and the comment ticks what passed and lists what is left.
|
|
131
|
+
What it catches: an agent that reports a run it did not do, ran against another commit or a stale server, or ran a different page. What it does not catch: someone who builds a trace by hand. It is a check on agents, not an attestation.
|
|
75
132
|
|
|
76
|
-
|
|
133
|
+
## Reports
|
|
77
134
|
|
|
78
|
-
A
|
|
135
|
+
A report is JSON in the shape the playbook describes (`schema_version: 2`): the tested commit (full SHA), the environment, the browser tool and its capabilities, start and finish times, and one case per scenario with `result` (`pass`, `fail`, `blocked`, `skipped`), the page, what was observed, and evidence.
|
|
79
136
|
|
|
80
|
-
|
|
137
|
+
- Screenshots (`.png`, `.jpg`, `.webp`) and traces (`.zip` Playwright traces or `.har` network logs) are read from the report's directory. Paths that leave it, links, wrong file types (checked by content, not just name) and files over 20 MB are refused. Accepted files are hashed and kept in the git directory; the PR shows their names, sizes and SHA-256 digests, not the files.
|
|
138
|
+
- Console errors and failed requests are trimmed, query strings are dropped, and values that look like credentials are masked.
|
|
139
|
+
- Evidence links are accepted only on hosts listed in `evidence.link_hosts`; use them to point reviewers at screenshots you uploaded to your own artifact store.
|
|
140
|
+
- A case counts when it passes, breaks no enforced policy, and was tested on a commit of the PR.
|
|
81
141
|
|
|
82
|
-
|
|
142
|
+
The PR comment shows every scenario as passed, failed, blocked, skipped, missing or stale, the environment and tool, the tested commit of each case, and the evidence. It says plainly that observations are the agent's account.
|
|
83
143
|
|
|
84
144
|
## Commands
|
|
85
145
|
|
|
86
146
|
```
|
|
87
|
-
browser-review-gate install
|
|
147
|
+
browser-review-gate install [--agents LIST] [--gem-source SOURCE] [--workflow NAME] [--force] [--dry-run]
|
|
88
148
|
browser-review-gate eject policy|playbook copy a built-in text into the project to rewrite it
|
|
89
|
-
browser-review-gate
|
|
149
|
+
browser-review-gate doctor [--json] check the setup; PASS, WARN, FAIL or SKIP with a fix
|
|
150
|
+
browser-review-gate verify [--pr N] [--environment NAME] [--report PATH] [--no-request] [--json]
|
|
151
|
+
browser-review-gate status [--pr N] [--environment NAME] the raw facts verify works from, as JSON
|
|
90
152
|
browser-review-gate publish [--pr N] [--report PATH]
|
|
91
|
-
browser-review-gate save --report PATH
|
|
92
|
-
browser-review-gate request-assessment N
|
|
93
|
-
browser-review-gate hook
|
|
153
|
+
browser-review-gate save --report PATH
|
|
154
|
+
browser-review-gate request-assessment N
|
|
155
|
+
browser-review-gate hook claude|cursor|codex post-command hook
|
|
94
156
|
browser-review-gate ci N what the workflow runs
|
|
95
|
-
browser-review-gate stats [--days N]
|
|
157
|
+
browser-review-gate stats [--days N] [--json] what the gate did on recent PRs
|
|
96
158
|
```
|
|
97
159
|
|
|
160
|
+
`verify` exit statuses: 0 nothing to do or published and verified; 2 a run is needed; 3 the environment does not answer; 4 an assessment was requested or is missing; 5 check out the PR head, or open the PR; 6 a saved run waits; 7 published but not verified; 1 error.
|
|
161
|
+
|
|
162
|
+
`doctor` checks Ruby, the executable, the repository, the settings, `gh` and its sign-in, your push access, each environment's health check, agent files and hooks, a browser tool configured in the project, the workflow and its version, whether the status is a required check, and whether a model secret exists (by name). What it cannot see from your machine is SKIP, never PASS.
|
|
163
|
+
|
|
164
|
+
`stats` counts PRs assessed, needing a run, verified, waived and still owing one; published runs and runs with a failure; scenarios that are failing; the median time from opening a PR to its verified run; and the token usage and cost of the latest assessment of each PR, only as the provider reported them. The Claude Code CLI reports cost; the APIs report tokens only, so no cost is shown for them.
|
|
165
|
+
|
|
98
166
|
Local commands need the `gh` CLI, signed in.
|
|
99
167
|
|
|
100
|
-
##
|
|
168
|
+
## Who assesses a PR
|
|
169
|
+
|
|
170
|
+
The assessment is one model call without tools. Which model makes it follows from the secret you added:
|
|
171
|
+
|
|
172
|
+
| Secret | What runs |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` | The Claude Code CLI |
|
|
175
|
+
| only `OPENAI_API_KEY` | The OpenAI API; set `model` |
|
|
176
|
+
| only `GEMINI_API_KEY` | The Gemini API; set `model` |
|
|
177
|
+
|
|
178
|
+
Set `provider` to `claude-cli`, `anthropic`, `openai` or `gemini` to choose explicitly. When the call fails, review stays on hold.
|
|
179
|
+
|
|
180
|
+
## Fitting it to your application
|
|
181
|
+
|
|
182
|
+
**Add rules.** Write under the headings of `.github/browser-review-gate/rules.md` in plain language: when a run is needed, what every run checks, which scenarios each area needs, which accounts and data to use. The model reads them when it assesses; the agent reads them when it picks scenarios. Use `scenarios.mandatory` and `scenarios.matrix` for anything that must not depend on a model following instructions.
|
|
183
|
+
|
|
184
|
+
**Replace the built-in text.** `eject policy` copies the decision policy to `.github/browser-review-gate/policy.md`; `eject playbook` hands `docs/browser-review-gate.md` over to the project.
|
|
185
|
+
|
|
186
|
+
## Security
|
|
187
|
+
|
|
188
|
+
- The workflow runs on `pull_request_target` from the base branch with `contents: read`, `issues: write`, `pull-requests: write` and `statuses: write`. PR code is never checked out or executed; the diff is read through the API as data. Only the PR number reaches the shell.
|
|
189
|
+
- Settings, rules and the policy are read from the base branch in CI, so a PR cannot change the rules of its own assessment. Local commands read the settings from `origin/HEAD` when it has them, so a PR branch cannot point the agent at another URL or loosen the policy for its own run (`settings_source` in `verify --json` says which was used).
|
|
190
|
+
- The assessing model gets the PR as escaped data with no tools and answers in a fixed JSON schema; the gem validates the answer and does every write. An invalid answer or a failed call keeps review on hold.
|
|
191
|
+
- The decision comment counts only from `github-actions[bot]`; a report counts only from someone with push access. Anyone else's comments are ignored as evidence.
|
|
192
|
+
- Reports are validated strictly; evidence paths are confined to the report directory, symbolic links are refused, file types are checked by content, and logged URLs and messages are scrubbed.
|
|
193
|
+
- Health checks go only to http(s) URLs from the settings, with a HEAD request, short timeouts and no redirects.
|
|
194
|
+
|
|
195
|
+
Residual risks, stated plainly:
|
|
196
|
+
|
|
197
|
+
- **A report is the agent's account, not an attestation.** Someone with push access can publish a passing report for a run that did not happen. With `provenance.trace: require` an agent cannot do so by accident or by guessing, but a hand-built trace still passes. Human review remains the control.
|
|
198
|
+
- **Evidence files stay local.** Reviewers see digests, not screenshots, unless you upload them and link them through `evidence.link_hosts`.
|
|
199
|
+
- **Scrubbing is pattern-based.** It catches common credential shapes, not everything. Agents are told not to record secrets; screenshots and traces are never inspected.
|
|
200
|
+
- **A taken-back review request may still notify the reviewer**, because GitHub has no way to refuse a request.
|
|
201
|
+
- **GitHub lists at most 250 commits of a PR**; on longer PRs, cases from older commits stop counting.
|
|
202
|
+
- **The gate does not block merging by itself.** Make the status a required check for that.
|
|
101
203
|
|
|
102
|
-
|
|
103
|
-
- **A waiver for what cannot be run.** Someone other than the author adds the `browser-verification-waived` label and review proceeds without a run.
|
|
104
|
-
- **A status next to the checks.** `browser-verification` is pending while a run is owed and green otherwise. Make it a required check if you want it to block merging.
|
|
105
|
-
- **Nothing is forced.** Opening a PR never requires a run. The PR comment says when one is needed and how to do it; it only becomes necessary when the author wants a reviewer.
|
|
106
|
-
- **The agent does not start your app.** It uses the one that is running and asks you to start it otherwise.
|
|
204
|
+
## Upgrading from 0.2
|
|
107
205
|
|
|
108
|
-
|
|
206
|
+
0.3 changes the report format and the settings; nothing from 0.2 is read.
|
|
109
207
|
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
- The model that assesses a PR sees the diff as data, has no tools, and answers in a fixed JSON shape. The gem validates the answer and does every write. PR code is never checked out or run in CI.
|
|
208
|
+
- Move `url`, `start_command` and `sign_in` under `environments.local`.
|
|
209
|
+
- Re-run `browser-review-gate install` to update the workflow, the playbook and the agent files.
|
|
210
|
+
- Reports published by 0.2 do not count any more: PRs that were verified with one need a new run, or a waiver.
|
|
114
211
|
|
|
115
212
|
## Development
|
|
116
213
|
|
|
@@ -3,24 +3,41 @@ require "json"
|
|
|
3
3
|
module BrowserReviewGate
|
|
4
4
|
# The decision on whether a PR needs a browser run, and the scenarios that run must exercise. Kept in
|
|
5
5
|
# one PR comment that the gate and the report publisher read back.
|
|
6
|
+
#
|
|
7
|
+
# Scenarios come from two places: the model proposes them from the diff, and the project settings add
|
|
8
|
+
# mandatory ones (`scenarios.mandatory`). Mandatory scenarios are added by this code after the model
|
|
9
|
+
# answered, so neither the model nor the PR can leave them out.
|
|
6
10
|
class Assessment
|
|
7
11
|
MARKER = /<!--\s*browser-verification-assessment:([A-Za-z0-9_-]+)\s*-->/
|
|
8
12
|
# The gate workflow writes the comment with GITHUB_TOKEN; a marker from anyone else is ignored.
|
|
9
13
|
AUTHOR = "github-actions[bot]"
|
|
10
14
|
DECISIONS = %w[required not-required].freeze
|
|
15
|
+
PRIORITIES = %w[critical normal].freeze
|
|
11
16
|
SHA_PATTERN = /\A\h{40}\z/
|
|
12
17
|
MAX_REASON = 300
|
|
13
|
-
MAX_SCENARIOS = 12
|
|
14
18
|
MAX_NAME = 200
|
|
19
|
+
MAX_WHY = 200
|
|
20
|
+
CATEGORY_PATTERN = /\A[a-z][a-z0-9-]{0,31}\z/
|
|
21
|
+
STATUS_MARKS = { "passed" => "x", "failed" => " ", "skipped" => " ", "blocked" => " ", "missing" => " ", "stale" => " " }.freeze
|
|
15
22
|
|
|
16
|
-
attr_reader :assessed_sha, :decision, :reason, :scenarios, :report_seen, :errors
|
|
23
|
+
attr_reader :assessed_sha, :decision, :reason, :scenarios, :report_seen, :usage, :errors
|
|
17
24
|
|
|
18
25
|
# Builds an assessment from the model's JSON answer, which is untrusted. `report_seen` is the
|
|
19
26
|
# fingerprint of the report the model was shown, so the same report is not judged twice.
|
|
20
|
-
|
|
27
|
+
# `mandatory` are the scenarios the settings require for this change.
|
|
28
|
+
def self.parse(text, assessed_sha:, report_seen: nil, mandatory: [], max_count: 12, usage: nil)
|
|
21
29
|
data = JSON.parse(text.to_s)
|
|
22
30
|
data = {} unless data.is_a?(Hash)
|
|
23
|
-
|
|
31
|
+
proposed = data["scenarios"]
|
|
32
|
+
if proposed.is_a?(Array) && proposed.size > max_count
|
|
33
|
+
return new({ "assessed_sha" => assessed_sha }).tap { |assessment| assessment.errors << "scenarios must have at most #{max_count} entries" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if data["decision"] == "required" && proposed.is_a?(Array)
|
|
37
|
+
taken = proposed.map { |entry| entry.is_a?(Hash) ? entry["id"] : nil }
|
|
38
|
+
data["scenarios"] = proposed + mandatory.reject { |scenario| taken.include?(scenario["id"]) }
|
|
39
|
+
end
|
|
40
|
+
assessment = new(data.slice("decision", "reason", "scenarios").merge("assessed_sha" => assessed_sha, "report_seen" => report_seen, "usage" => usage))
|
|
24
41
|
assessment.errors << "a required browser run needs at least one scenario" if assessment.required? && assessment.scenarios.empty?
|
|
25
42
|
assessment
|
|
26
43
|
rescue JSON::ParserError
|
|
@@ -53,9 +70,8 @@ module BrowserReviewGate
|
|
|
53
70
|
@assessed_sha = data["assessed_sha"]
|
|
54
71
|
@decision = data["decision"]
|
|
55
72
|
@reason = data["reason"].is_a?(String) ? data["reason"].split.join(" ")[0, MAX_REASON] : nil
|
|
56
|
-
# Comments written before scenarios existed carry the model's own verdict on coverage instead.
|
|
57
|
-
@covered_by_report = data["covered_by_report"] == true
|
|
58
73
|
@report_seen = data["report_seen"].is_a?(String) ? data["report_seen"] : nil
|
|
74
|
+
@usage = read_usage(data["usage"])
|
|
59
75
|
@errors = []
|
|
60
76
|
@scenarios = required? ? read_scenarios(data["scenarios"]) : []
|
|
61
77
|
validate
|
|
@@ -65,62 +81,66 @@ module BrowserReviewGate
|
|
|
65
81
|
def required? = decision == "required"
|
|
66
82
|
def for?(sha) = assessed_sha == sha
|
|
67
83
|
|
|
68
|
-
# The scenarios no
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
# The scenarios no counting case of the report exercises yet. With `commits`, only cases tested on
|
|
85
|
+
# those commits count; with `reuse_passing` false, only cases tested on the assessed commit.
|
|
86
|
+
def outstanding(report, commits: nil, reuse_passing: true)
|
|
87
|
+
allowed = reuse_passing ? commits : [ assessed_sha ]
|
|
88
|
+
passed = report ? report.passed_ids(commits: allowed) : []
|
|
71
89
|
scenarios.reject { |scenario| passed.include?(scenario["id"]) }
|
|
72
90
|
end
|
|
73
91
|
|
|
74
|
-
# A
|
|
75
|
-
|
|
92
|
+
# A report covers the change when it is complete, nothing failed (unless failures do not block),
|
|
93
|
+
# and every scenario has a counting case.
|
|
94
|
+
def covered_by?(report, commits: nil, reuse_passing: true)
|
|
76
95
|
return false unless required? && report&.passed?
|
|
77
|
-
return @covered_by_report || report.tested_sha == assessed_sha if scenarios.empty?
|
|
78
96
|
|
|
79
|
-
outstanding(report).empty?
|
|
97
|
+
outstanding(report, commits: commits, reuse_passing: reuse_passing).empty?
|
|
80
98
|
end
|
|
81
99
|
|
|
82
100
|
# Whether this decision already accounts for the report, so the model need not be asked again.
|
|
83
101
|
def settles?(report)
|
|
84
|
-
!required? || report.nil? ||
|
|
102
|
+
!required? || report.nil? || outstanding(report).empty? || report_seen == report.fingerprint
|
|
85
103
|
end
|
|
86
104
|
|
|
87
105
|
def to_h
|
|
88
106
|
{ "assessed_sha" => assessed_sha, "decision" => decision, "reason" => reason, "scenarios" => scenarios,
|
|
89
|
-
"report_seen" => report_seen, "
|
|
107
|
+
"report_seen" => report_seen, "usage" => usage }.compact
|
|
90
108
|
end
|
|
91
109
|
|
|
92
|
-
# `verified` is true when a
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
def markdown(verified:, command:, author: nil, report: nil)
|
|
110
|
+
# `verified` is true when a report covers the browser behavior of this commit. `command` is what the
|
|
111
|
+
# author runs in an AI coding agent. `author` is who gets mentioned, and only when the comment asks
|
|
112
|
+
# them to do something. `report` and `commits` give each scenario its status.
|
|
113
|
+
def markdown(verified:, command:, author: nil, report: nil, commits: nil)
|
|
97
114
|
payload = Marker.encode(JSON.generate(to_h))
|
|
98
|
-
[ "## Browser verification", "", summary(verified, command, author, report), "",
|
|
115
|
+
[ "## Browser verification", "", summary(verified, command, author, report, commits), "",
|
|
99
116
|
"Assessed commit `#{assessed_sha}`.", "", "<!-- browser-verification-assessment:#{payload} -->" ].join("\n")
|
|
100
117
|
end
|
|
101
118
|
|
|
102
119
|
private
|
|
103
120
|
|
|
104
|
-
def summary(verified, command, author, report)
|
|
121
|
+
def summary(verified, command, author, report, commits)
|
|
105
122
|
why = Markdown.escape(reason)
|
|
106
123
|
if !required?
|
|
107
124
|
"Browser testing is not needed for this change: #{why} Human review can be requested."
|
|
108
125
|
elsif verified
|
|
109
|
-
"Browser testing is required for this change, and the published browser report covers it: #{why}
|
|
110
|
-
"
|
|
126
|
+
[ "**Human review can proceed.** Browser testing is required for this change, and the published browser report covers it: #{why}",
|
|
127
|
+
"", *checklist(report, commits) ].join("\n").rstrip
|
|
111
128
|
else
|
|
112
|
-
[ "#{Markdown.mention(author)}Browser testing is needed before requesting human review. #{why}", "",
|
|
113
|
-
*checklist(report),
|
|
129
|
+
[ "#{Markdown.mention(author)}**Human review is on hold.** Browser testing is needed before requesting human review. #{why}", "",
|
|
130
|
+
*checklist(report, commits),
|
|
114
131
|
"To be able to request a reviewer, run this command in your AI coding agent from the PR branch:", "",
|
|
115
132
|
"```", command, "```" ].join("\n")
|
|
116
133
|
end
|
|
117
134
|
end
|
|
118
135
|
|
|
119
|
-
def checklist(report)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
def checklist(report, commits)
|
|
137
|
+
lines = scenarios.map do |scenario|
|
|
138
|
+
status = report ? report.status_of(scenario["id"], commits: commits) : "missing"
|
|
139
|
+
flags = [ scenario["priority"] == "critical" ? "critical" : nil, scenario["category"] ].compact.join(", ")
|
|
140
|
+
why = scenario["why"] ? " — #{Markdown.escape(scenario["why"])}" : ""
|
|
141
|
+
"- [#{STATUS_MARKS.fetch(status)}] **#{status}** `#{scenario["id"]}` #{Markdown.escape(scenario["name"])}" \
|
|
142
|
+
"#{flags.empty? ? "" : " (#{Markdown.escape(flags)})"}#{why}"
|
|
143
|
+
end
|
|
124
144
|
[ "Scenarios the run must pass:", "", *lines, "" ]
|
|
125
145
|
end
|
|
126
146
|
|
|
@@ -133,15 +153,36 @@ module BrowserReviewGate
|
|
|
133
153
|
return []
|
|
134
154
|
end
|
|
135
155
|
|
|
136
|
-
scenarios = value.map { |entry|
|
|
156
|
+
scenarios = value.map { |entry| read_scenario(entry) }
|
|
137
157
|
ids = scenarios.map { |scenario| scenario["id"] }
|
|
138
|
-
errors << "scenarios must have at most #{MAX_SCENARIOS} entries" if scenarios.size > MAX_SCENARIOS
|
|
158
|
+
errors << "scenarios must have at most #{Config::MAX_SCENARIOS + Config::MAX_MATRIX_CASES} entries" if scenarios.size > Config::MAX_SCENARIOS + Config::MAX_MATRIX_CASES
|
|
139
159
|
errors << "scenario ids must use letters, numbers, dots, underscores, or hyphens" unless ids.all? { |id| id.is_a?(String) && Report::CASE_ID_PATTERN.match?(id) }
|
|
140
160
|
errors << "scenario ids must be unique" unless ids.uniq.size == ids.size
|
|
141
161
|
errors << "scenario names must not be empty" if scenarios.any? { |scenario| scenario["name"].empty? }
|
|
162
|
+
errors << "scenario priority must be critical or normal" unless scenarios.all? { |scenario| PRIORITIES.include?(scenario["priority"]) }
|
|
163
|
+
errors << "scenario category must be a short lowercase word" unless scenarios.all? { |scenario| scenario["category"].nil? || CATEGORY_PATTERN.match?(scenario["category"]) }
|
|
142
164
|
scenarios
|
|
143
165
|
end
|
|
144
166
|
|
|
167
|
+
def read_scenario(entry)
|
|
168
|
+
scenario = { "id" => entry["id"], "name" => entry["name"].split.join(" ")[0, MAX_NAME],
|
|
169
|
+
"priority" => entry["priority"] || "normal",
|
|
170
|
+
"category" => entry["category"].is_a?(String) && !entry["category"].empty? ? entry["category"].downcase : nil,
|
|
171
|
+
"why" => entry["why"].is_a?(String) && !entry["why"].strip.empty? ? entry["why"].split.join(" ")[0, MAX_WHY] : nil }
|
|
172
|
+
scenario["matrix"] = entry["matrix"] if entry["matrix"].is_a?(Hash)
|
|
173
|
+
scenario.compact
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Token counts and cost as the provider reported them; nothing is estimated here.
|
|
177
|
+
def read_usage(value)
|
|
178
|
+
return unless value.is_a?(Hash)
|
|
179
|
+
|
|
180
|
+
usage = value.slice("provider", "model", "input_tokens", "output_tokens", "cost_usd").select do |key, entry|
|
|
181
|
+
%w[provider model].include?(key) ? entry.is_a?(String) && entry.length <= 80 : entry.is_a?(Numeric) && entry >= 0
|
|
182
|
+
end
|
|
183
|
+
usage.empty? ? nil : usage
|
|
184
|
+
end
|
|
185
|
+
|
|
145
186
|
def validate
|
|
146
187
|
errors << "assessed_sha must be a full commit SHA" unless assessed_sha.is_a?(String) && SHA_PATTERN.match?(assessed_sha)
|
|
147
188
|
errors << "decision must be required or not-required" unless DECISIONS.include?(decision)
|