Pull Request Reviewer preset¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
Reviews a GitHub pull request or a GitLab merge request: security, quality, performance, tests, documentation impact, and (since this slice) whether the PR actually does what the issue it references asked for.
The preset ships as backend/presets/002-pull-request-reviewer.yaml
(slug pull-request-reviewer).
Operators set blocking review policy on the flow form's Review instructions
field when the repository cannot hold .preloop/review-policy.md.
The review is stateful. One summary comment carries HTML markers
(<!-- preloop-review:flow-id:pr-reviewer -->,
<!-- preloop-review:reviewed-sha:SHA -->) and is rewritten in place on each
push; inline comments are resolved when their finding is fixed; checkboxes a
user ticks are never re-raised.
Issue coverage¶
A diff review answers "is this code good". It does not answer "does this do what was asked", which is the question the author's team actually has. So the review reads the referenced issue and publishes one Issue Coverage section in the same summary comment.
1. Finding the reference¶
Neither platform hands the reviewer a linked-issue relation on a PR read: the
GitHub mapping in backend/preloop/sync/trackers/github.py and the GitLab
mapping in backend/preloop/api/endpoints/mcp.py both return a fixed field
list with no closes_issues. Preloop therefore parses the reference off the
PR itself, in backend/preloop/services/issue_references.py, and exposes it
to the prompt as {{trigger_event.payload.object_attributes.referenced_issues}}:
| Kind | Recognized from | Examples |
|---|---|---|
closes |
a closing keyword in the PR body or title | Closes #12, Fixes org/repo#12, Resolves PROJ-7, Implements #12 |
reference |
a plain mention or an issue URL in the body | #12, Related to #12, https://gitlab.example.com/grp/proj/-/issues/12 |
branch |
the branch name | 123-slug, fix/issue-123, gh-123-slug, feature/PROJ-7-slug |
Cross-repo (other-org/other#7) and GitLab subgroup paths
(grp/sub/proj#7) resolve; the PR's own number is dropped (squash-merge
titles routinely carry (#45)); a same-issue hit from two sources keeps the
strongest kind; the list is capped at 5 entries, strongest first. When
nothing matches, the field reads none detected and the review omits the
Issue Coverage section entirely rather than speculating.
The parser is a hint, not the verdict: the prompt tells the reviewer to confirm it against the PR description it already fetched and to add anything stated only in prose.
2. Reading the issue¶
The preset's tool allowlist gained exactly one entry, the read-only
get_issue:
allowed_mcp_tools:
- name: get_issue # new: issue coverage
- name: get_pull_request
- name: update_pull_request
- name: add_comment
- name: update_comment
get_issue accepts the identifiers the parser produces (an issue URL, an
org/repo#123 key, or a Jira key) and reads Preloop's synced copy of the
tracker. Budget: at most 2 issues read and at most 3 calls per run.
Graceful fallback. The issue may live in a repository the account does
not sync, or sync may not have caught up. get_issue then returns "not
found", and the review reports verdict UNCLEAR naming that reason. It is
explicitly forbidden from reconstructing the issue's asks out of the PR
description: the author's summary of the issue is the thing under review.
No create_issue or update_issue is granted, so follow-ups are proposed
for a human to file, never written to the tracker.
3. The verdict¶
Criteria come from the issue's own text (an "Acceptance criteria" or
"Definition of done" section, numbered asks, otherwise the concrete asks in
prose), quoted where possible, at most 8, and never invented: tests,
docs, and telemetry are ordinary review findings unless the issue asks for
them. Each criterion is mapped to a hunk (file.ext:line) or recorded as
unmet.
| Verdict | Meaning |
|---|---|
FULL |
every stated criterion is satisfied by this PR |
PARTIAL |
some are, at least one is not |
NOT ADDRESSED |
none are (normal when a PR merely mentions a related issue) |
UNCLEAR |
the issue could not be read, or its asks cannot be mapped to code |
Published shape, inside the one summary comment:
### 🎯 Issue Coverage
<!-- preloop-review:issue-coverage -->
**[`org/repo#123`](https://github.com/org/repo/issues/123): Widget picker loses the last selection** (verdict: **PARTIAL**)
Restores the selection but ships no test and no doc update.
Acceptance criteria as this review reads them (quoted from the issue):
- [x] The picker restores the last selection after a reload - `src/widget-picker.ts:88`
- [ ] The restore is covered by a test
- [ ] The behaviour is documented in the widget guide
Gaps:
- No test covers the restore path - `src/widget-picker.test.ts`
- The widget guide still describes the old behaviour
Follow-ups (ready to file as issues):
- **Cover widget picker restore with a test**: the reload path is untested.
- **Document the widget picker restore**: the guide predates it.
Coverage never blocks a merge. A PARTIAL verdict creates no findings
and does not change the review action (approve / comment / request_changes)
decided from finding severity. Authors split work across PRs on purpose and
a later PR may close the issue. The review reports, it does not police.
4. Across pushes¶
The section is stateful like the rest of the review. On the next push the
reviewer finds it by its <!-- preloop-review:issue-coverage --> marker,
reuses the recorded criteria verbatim (rewording them would look like the
review changed its mind), re-checks the unchecked ones against the current
code, ticks off the ones later commits satisfy, and drops their gap and
follow-up lines. A criterion a previous review checked is never unchecked
unless the code that satisfied it left the branch, and never silently
dropped. The verdict is recomputed from the checkbox tally, never restated.
In INCREMENTAL scope only criteria whose file appears in the delta are
re-checked; the rest keep their checkbox untouched.
result.json carries the machine form:
{
"status": "success",
"review_posted": true,
"risk_level": "medium",
"findings_count": 3,
"review_action": "comment",
"issue_coverage": [
{"issue": "org/repo#123", "verdict": "partial",
"criteria_total": 3, "criteria_met": 1, "gaps": 2}
]
}
issue_coverage is [] when the PR references no issue.
Stale reviews stop on their own¶
When a pull request (GitHub, Bitbucket) or merge request (GitLab) is merged or closed, Preloop stops every execution still bound to it, in any flow of the account, unless that flow itself triggers on the merge or close. The execution shows the reason, for example "Stopped because pull request example/repo#12 was merged". Nothing happens if no run is bound.
With webhook_config.supersede_on_update: true, a new head commit also
stops the older run of the same flow on the same pull request before the
new head is reviewed. The preset sets it; flows created from the preset
before this change keep the old behaviour (the new head waits for the older
run) until the flag is set on them. It applies only when the flow triggers
on pull_request_updated (merge_request_updated on GitLab).
Repository review policy¶
Agent instruction files (AGENTS.md, CLAUDE.md, .cursorrules,
.clinerules) are project context. The reviewer reads them in full,
including on the fast path. They are not, by themselves, a blocking
compatibility contract: a sentence in AGENTS.md is guidance, and
CONTRIBUTING.md is only the first 150 lines (and is skipped on the fast
path). To make "this tree must keep running on runtime X" a blocking
finding, commit a policy file or set the flow field below.
Where to put the rules¶
| Source | When to use it | Force |
|---|---|---|
.preloop/review-policy.md at the repository root |
The repository can carry a file. Read in full on every review, including the fast path. | Blocking. A violation is HIGH, category Compatibility, and the review requests changes. |
Flow review_instructions |
The repository cannot commit that file. Same markdown. Injected as {{flow.review_instructions}} (16 KiB cap). Set it on the flow in the console (Review instructions) or the API. It is not part of the prompt template, so a later preset update does not wipe it. |
Same as the file. |
An empty field and a missing file are normal. The reviewer does not invent a policy. In clone-less mode the file is visible only when the diff includes it; the review says so instead of assuming there is no policy.
File shape¶
Markdown. The first fenced yaml block is the compatibility config. Prose
around it is also blocking when the diff breaks a rule it states. Versions
are quoted strings: an unquoted 5.10 is the number 5.1 in YAML.
compatibility:
- language: perl
minimum_version: "5.10"
paths:
- "daemons/**"
extensions:
- ".pl"
- ".pm"
- ".t"
version_linter: "perlver --blame"
allowed:
- "say"
- "state"
- "defined-or (//)"
forbidden:
- "postfix dereference (->@*, ->%*, ->$*)"
- "subroutine signatures"
- "__SUB__"
- "fc"
paths defaults to every file. **/ matches zero or more directories,
so src/**/*.pl covers src/x.pl and **/*.pl covers a file at the
repository root. * does not cross /. extensions defaults from the
language. Perl's default is .pl, .pm, and .t. allowed is syntax the minimum
already includes, and must not be flagged. forbidden is a violation even
when a linter is silent. Other languages use the same keys and name their
own version_linter. There is no default command except Perl's.
A version_linter value is a program name (a basename, not a path) plus
plain arguments. The reviewer appends each matching path as one argument.
Shell operators and : are not run, so a URL cannot be an argument. A
basename that already exists in the sandbox can still run: the command is
taken from the policy already on the target branch, and that author can
already change CI. If the pull request edits the policy file, the reviewer
uses the target branch copy and does not execute a command the pull request
introduced. A policy file the pull request itself adds has no force on that
review. The reviewer says the policy is newly proposed and applies it only
after it merges.
Version linters¶
When a compatibility entry matches changed files and names version_linter,
the reviewer runs that command and quotes the output. For Perl, omitting
the command means perlver --blame <file> (from Perl::MinimumVersion).
If that script is missing, the reviewer tries:
perl -MPerl::MinimumVersion -e 'my $pmv = Perl::MinimumVersion->new(shift); print $pmv->minimum_version, "\n"' <file>
A reported version newer than minimum_version is a HIGH finding. An equal
version is not. Dotted numbers compare numerically: 5.10 is newer than 5.9
and older than 5.16.
The default reviewer sandbox is ghcr.io/openai/codex-universal (see
backend/preloop/agents/images.py). That image is not built from this
repository and does not guarantee Perl. When perl or
Perl::MinimumVersion is absent, the review says "version linter
unavailable in this sandbox" and judges the diff from the policy. That is
not a pass.
The environment image built from environments/preloop/Dockerfile ships
perlver. A private runner gets it with cpanm Perl::MinimumVersion.
For a Codex sandbox with the toolchain, build the opt-in image in
environments/perl
and select it with CODEX_IMAGE (hosted) or agent_config.image (private
runner). Its README lists the build, the offline smoke and the evidence to
keep.
Perl 5.10 example¶
A tree of daemons that must stay on Perl 5.10 commits the yaml above plus
one line of prose: "Perl under daemons/ must run on Perl 5.10." say,
state, and defined-or (//) are part of 5.10 and are listed under
allowed, so a review must not flag them. Postfix dereference (->@*),
subroutine signatures, __SUB__, and fc need a newer Perl. They are
forbidden, and a pull request that adds one is a blocking finding.
The same markdown can be pasted into the flow's Review instructions when
the repository cannot carry .preloop/review-policy.md.
Running tests¶
Reading the tests is the main check; a run confirms it. Step 2.4 runs tests only on the PR branch from this repository, never on a fork's code, and never installs packages to do it. When nothing could run, the summary says "tests not run in this sandbox" and names the CI jobs to confirm.
In the environment image built from environments/preloop/Dockerfile
(see Execution environments),
the reviewer runs the backend test files the diff touches with
preloop-pytest -q -m "not integration" <files>: the backend lock is
preinstalled and the runner starts its own disposable database, with no
network. For frontend test files it runs preloop-frontend-deps, then
cd frontend && npx --no-install web-test-runner <files> (--no-install
so a missing tree fails instead of fetching a package). It never runs the
whole suite (CI shards it) and keeps runs under about 5 minutes. A failing
test on the reviewed head is a finding. The default codex-universal image has
neither runner, so there the reviewer only uses a test command whose
dependencies are already installed.
Browser tests default to one session at a time in flow containers. Operators
can set PRELOOP_TEST_CONCURRENCY to a positive integer to override it; see
test concurrency
for configuration and resource guidance.
Not in this slice¶
- No tracker-side relation read (GitLab's
/merge_requests/:iid/closes_issuesendpoint is not wired into the tracker client, so the body and branch are the only sources). - No cross-tracker resolution: an issue in a repository this account does not
sync reads as
UNCLEAR. - No follow-up issue creation. The section is written so its follow-up lines can be pasted into a new issue by a human.