slop-review

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": { … }
}

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.