Hooks
A hook is a program of yours that slop-review runs at the edges of a review:
when the window is about to open, when the review has been sent, when the
window was closed without one. It is handed one JSON document on its standard
input saying which of those happened and, for a sent review, the review
itself — the same JSON --format json prints — and it does whatever you
wrote it to do: post the remarks to a tracker, append them to a journal, ping
your phone that a review is waiting, mirror the decision to a forge.
The agent is not involved and never sees a hook. Whatever a hook does, the review is printed first, in full, and the exit status is the review's.
Configuring one
Hooks live in the configuration under [hooks.<name>], one table per hook,
in any of the four layers:
[hooks.tracker]
kind = "command"
on = ["submitted"]
command = ["python3", "/home/you/bin/review-to-tracker.py"]
timeout = 30
[hooks.notify]
kind = "command"
on = ["opened"]
command = ["notify-send", "slop-review", "A review is waiting in {repo_root}"]
[hooks.journal]
kind = "command"
command = ["sh", "-c", "cat >> ~/reviews.jsonl"]
Every hook says what kind it is, and there is one kind: command, a
program of yours. The word is required rather than assumed so that the file
says what it does to whoever reads it, and so that a hook of some other kind,
when there is one, is refused by the name of the kind it asked for rather
than by a complaint about a field.
command is an argv, the way a VCS profile's commands are: the
program, then its arguments, one string each. There is no shell, so an
argument with a space in it is one argument, and the only way to get a shell
is to name one, as the third hook does. Five placeholders are substituted
into whichever argument they stand in — {event}, {vcs}, {repo_root},
{revset} and {decision} — for a hook that wants one word without parsing
anything. A placeholder for something that is not there, {decision} on a
cancelled review, say, is an empty argument.
on is which of the three moments the hook runs at, and is ["submitted"]
when left out, since sending is what most hooks are about. timeout is in
seconds, 10 when left out; every hook is waited for, so a slow one holds the
exit — or, for opened, the window — for that long at most.
The hook's working directory is the repository root. Its standard output is discarded: this program's standard output is the review, and the agent is reading it, so nothing a hook prints can land in it. Its standard error is passed through, which is where anything it has to say belongs.
A repository's own configuration file may define hooks only once it has been
trusted, exactly as with [vcs]: a table that says what to
run is a program, and a clone should not be a way to run one. Your own file
under $XDG_CONFIG_HOME needs no permission, and is where a hook that is
about you rather than about a project goes.
What a hook is given
One JSON document on standard input, the same shape for every event, with
the fields that do not apply set to null rather than left out:
{
"event": "submitted",
"vcs": "jj",
"repo_root": "/home/you/projects/slop-review",
"revset": "trunk()..@",
"review": { … }
}
event—"opened","submitted","cancelled", or"check"for a run ofslop-review hooks check(below).vcs— the profile in use,"git"or"jj"or whatever a profile of your own is called.repo_root— the repository root, as an absolute path.revset— forsubmitted, what was reviewed, as the review itself reports it. Foropenedandcancelled, what the command line asked for with-ror-b, andnullwhen the window was left to pick the VCS's default.review— forsubmittedandcheck, the review;nullotherwise. It is byte for byte the document--format jsonprints, so what comes back is its reference:decision,summary, thecommentswith theircommits,locationandbody, andfirst_senton each.
That last field is the one a hook that files remarks somewhere needs to
read. A remark is kept against the commits it was written over and comes back
for as long as they are unchanged, so the second review of a stack the agent
only partly rewrote sends the standing remarks again — deliberately, since a
review with two standing remarks and nothing new must not look like a clean
approval. first_sent is null on a remark going out for the first time and
a UTC timestamp on one the agent has already been given, and a hook that
should act on each remark once filters on it. The rule is spelled out in
what comes back, under Remarks sent before.
Three moments, then:
event |
when | review |
revset |
|---|---|---|---|
opened |
before the window opens | null |
as asked, or null |
submitted |
after the review has been printed or written | the review | the review's |
cancelled |
the window was closed without a decision | null |
as asked, or null |
There is no event for a remark being written or a decision being changed. What happens inside the window stays in the window; a hook sees a review when it is a review.
A hook that posts new remarks somewhere
The whole of a hook is: read standard input, parse it, do something. This one sends every remark the agent has not seen before to an HTTP endpoint, as one request per remark, and needs nothing beyond a Python 3 install:
#!/usr/bin/env python3
"""slop-review hook: post each newly written remark to a tracker."""
import json
import os
import sys
import urllib.request
ENDPOINT = os.environ.get("TRACKER_URL", "http://localhost:8080/remarks")
payload = json.load(sys.stdin)
# `hooks check` runs every hook with a made-up review. Show that it arrived
# and stop there rather than filing remarks nobody wrote.
if payload["event"] == "check":
print(f"tracker hook: would post to {ENDPOINT}", file=sys.stderr)
sys.exit(0)
review = payload["review"]
if review is None:
sys.exit(0) # opened or cancelled: nothing to file
for comment in review["comments"]:
if comment["first_sent"] is not None:
continue # the agent had this one already; so did the tracker
record = {
"repo": payload["repo_root"],
"vcs": payload["vcs"],
"revset": review["revset"],
"decision": review["decision"],
"id": comment["id"],
"text": comment["body"],
"about": comment["location"], # null for a remark on the commits
"commits": [commit["subject"] for commit in comment["commits"]],
}
request = urllib.request.Request(
ENDPOINT,
data=json.dumps(record).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
response.read()
Wired up in your own configuration:
[hooks.tracker]
kind = "command"
command = ["python3", "/home/you/bin/review-to-tracker.py"]
timeout = 30
Anything the script raises reaches you on stderr — the traceback as Python
prints it, then slop-review's own line naming the hook and how it exited; the
review has already gone out. summary is the place a reviewer
writes the most prose, so a hook about the writing rather than the code would
post that too, and first_sent has no bearing on it — a summary is written
fresh each time.
Trying a hook out
slop-review hooks check
slop-review hooks check tracker
runs every hook once — whatever it is on — or only the ones named, in the
order named, with a review made up for the purpose, and reports what each was
given, word for word, and how it came back: its exit status, or that it was
stopped at its timeout, and how long it took. Naming one is for working on it:
the others do not go off every time you try again, and a name no hook has is
an error that lists the names there are, so a check that ran nothing cannot
pass for one that ran. The event in that run is "check" rather than "submitted", and the
review's summary says what it is, so a hook that must not act on a review
nobody wrote can tell and say so, as the script above does. The report also
shows which configuration files were read and whether a repository's hooks
were ignored for want of trust, which is the usual reason a hook that is
plainly in the file does not run.
A hook that has stopped working in daily use is found the same way, or with
--log -: the log names each moment the hooks were told about and repeats
each complaint.
When a hook fails
It does not matter to the review. A hook that could not be started, exited
non-zero, or had not exited when its timeout was up is one line on stderr —
slop-review: hook tracker: exit status: 1 — and nothing else. The review was
printed before any submitted hook ran, the exit status still says whether a
review was filed, and the agent reading the output has nothing to do about
it. Hooks run in the order of their names, each waited for before the next
starts, so a hook that fails does not stop the ones after it either.
The one thing a hook does to a review is hold its exit until it is done. A
hook that talks to something slow should carry a timeout that says so, or
hand the work to something that outlives it and return.