DEV Community

Muhammet ŞAFAK
Muhammet ŞAFAK

Posted on

A self-hosted PR reviewer: you own the trigger, not a GitHub App

A GitHub App reviews every pull request the moment it opens, whether you wanted it to or not. commitbrief remote pr <id> reviews the one you point it at, when you run it — driven by your own gh auth, posting from your own account, with no server in between. This is the last integration in the series, and it's the one where the positioning is the architecture: who owns the trigger.

A hosted reviewer fires on a webhook. It runs on someone else's infrastructure, holds an installation token for your repo, and decides on its own schedule. The CLI inverts all three: it runs on your machine, borrows the GitHub auth you already have, and fires exactly when you decide a PR is worth a second pass. Same review engine as the terminal and the agent paths — different trigger, and the trigger is the whole point.

TL;DR

  • commitbrief remote pr 42 fetches a PR's diff through gh, reviews it, and posts findings as inline comments — from your account, on your command.
  • It performs no HTTPS calls of its own. Every GitHub round-trip goes through your gh CLI, which already holds your auth.
  • The request-changes verdict is opt-in (--request-changes-on); by default it only ever comments or approves.
  • A head-OID race check reruns the review once if the PR moves underneath it, then bails rather than post stale findings.
  • The limit. It's not a hosted GitHub App and not a policy gate. It needs your gh auth and a paid API provider, and it's still the zeroth reviewer — not the last word.

It drives your gh, it isn't a service

The remote package makes no network calls. It shells out to the gh binary and lets that handle auth, host resolution, and the REST round-trips:

// Run shells out to `gh` with the given args, surfacing stderr in the
// error so the caller can log a meaningful message.
func (execRunner) Run(ctx context.Context, args ...string) ([]byte, error) {
    out, err := exec.CommandContext(ctx, "gh", args...).Output()
    // ...
}

// EnsureGH reports ErrGHMissing when the `gh` binary is not on PATH.
func EnsureGH() error {
    if _, err := exec.LookPath("gh"); err != nil {
        return ErrGHMissing
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Four gh invocations make up a posting review, and they're exactly the ones you'd run by hand:

// FetchPRMeta runs `gh pr view <id> --json number,author,url,headRepository,commits`.
err := runJSON(ctx, r, &m, repoArgs(repo, "pr", "view", id, "--json", prViewFields)...)

// Whoami returns the authenticated GitHub login (`gh api user -q .login`).
out, err := r.Run(ctx, "api", "user", "-q", ".login")

// FetchDiff returns the PR's unified diff (`gh pr diff <id>`).
out, err := r.Run(ctx, repoArgs(repo, "pr", "diff", id)...)
Enter fullscreen mode Exit fullscreen mode

The fourth is the verdict submission, below. The PR ID accepts the gh-native forms — 42, owner/repo#42, or a full URL — and --repo owner/repo overrides the repository inferred from your working directory, so you can review a PR in a repo you're not standing in.

Whose PR, and is it still the same PR?

Two guards bracket the review. The first is the gh api user call above: it resolves your login and refuses to review your own PR — a self-review posted from your account is noise, so it's blocked before the provider is ever called.

The second handles a PR that moves while you're reviewing it. The diff is fetched at one head commit; by the time the model responds and the comments are ready to post, a teammate may have pushed. So the review re-reads the head OID and reruns once if it changed, rather than anchoring comments to lines that no longer exist:

for attempt := 0; ; attempt++ {
    res, err := reviewOnePRDiff(ctx, runner, prID, f, app, prov, model, loaded, prog)
    // ...
    newOID, err := remote.FetchLastOID(ctx, runner, prID, f.repo)
    // ...
    if newOID == lastOID {
        return res, lastOID, nil
    }
    if attempt >= 1 {
        return prReviewResult{}, "", errors.New(app.Catalog.T("remote.too_volatile"))
    }
    // Head moved: note the retry before looping.
    lastOID = newOID
}
Enter fullscreen mode Exit fullscreen mode

FetchLastOID re-reads only the commits (gh pr view <id> --json commits), so the race check is cheap. One retry, then too_volatile — it would rather post nothing than post findings about code that's already gone.

Bot mode: no human at the terminal

A terminal review can stop and ask you something. A PR review can't — there's nobody watching the process. So the same pipeline runs with three changes (ADR-0016 §3). The interactive .commitbrief/** guard and the cost preflight are skipped. The secret scanner still runs, but it warns instead of aborting — you can't fix a credential in someone else's PR by halting your own review, so the right move is to flag it loudly and continue:

if app.Config.Guard.SecretScan && !global.allowSecrets {
    if hits := guard.ScanForSecrets(diffText); len(hits) > 0 {
        prog.Info(app.Catalog.T("remote.secret_warn", len(hits)))
    }
}
Enter fullscreen mode Exit fullscreen mode

And because a posted review has to carry structured findings, the posting path requires an API provider — a plain-text CLI provider is rejected up front (if _, plain := prov.(provider.PlainTextEmitter); plain { ... }), and a review that degrades to Markdown aborts rather than posting prose where line-anchored findings belong.

Anchoring a finding to the right line

Inline comments post through the REST API, and GitHub needs to know which side of the diff a comment belongs to — RIGHT for the new file, LEFT for the old one:

func PostComment(ctx context.Context, r Runner, c CommentRequest) error {
    side := c.Side
    if side == "" {
        side = "RIGHT"
    }
    endpoint := fmt.Sprintf("/repos/%s/pulls/%d/comments", c.RepoSlug, c.PRNumber)
    _, err := r.Run(ctx,
        "api", "--method", "POST",
        // ...
        "-f", "path="+c.Path,
        "-F", "line="+strconv.Itoa(c.Line),
        "-f", "side="+side,
    )
    return err
}
Enter fullscreen mode Exit fullscreen mode

A finding about new code goes on RIGHT, the default. A finding about deleted code needs LEFT, and the side is inferred from the finding's own snippet:

// Heuristic: the snippet carries at least one removed ("-") line and no
// added ("+") line. With no snippet we keep the RIGHT-first default.
func preferLeftSide(f render.Finding) bool {
    if f.Snippet == "" {
        return false
    }
    minus, plus := 0, 0
    for _, ln := range strings.Split(f.Snippet, "\n") {
        switch {
        case strings.HasPrefix(ln, "-"):
            minus++
        case strings.HasPrefix(ln, "+"):
            plus++
        }
    }
    return minus > 0 && plus == 0
}
Enter fullscreen mode Exit fullscreen mode

A finding whose line falls outside the diff — the model referenced a line it shouldn't have, or the POST is rejected — isn't dropped. It's appended to the review summary under a "could not be attached to a specific line" heading, so the signal survives even when the anchor doesn't.

The verdict is opt-in

By default, this reviewer never blocks. The review-level verdict maps to one of gh pr review's three flags:

func (v Verdict) ghFlag() string {
    switch v {
    case VerdictApprove:
        return "--approve"
    case VerdictRequestChanges:
        return "--request-changes"
    default:
        return "--comment"
    }
}
Enter fullscreen mode Exit fullscreen mode

But request-changes is gated behind --request-changes-on <severity>. Leave it unset and the verdict can only be approve (no findings, or info-only) or comment — never request-changes, no matter how severe a finding is:

enabled := threshold != ""
// ...
if enabled && severityRank[fnd.Severity] <= tr {
    reached = true
}
Enter fullscreen mode Exit fullscreen mode

The inline comments are independent of that verdict: disabling request-changes changes whether the review blocks, not which findings get posted. You decide whether this thing can demand changes on your behalf, and the default answer is no — it advises, you adjudicate.

If you'd rather not post at all, --no-post runs the exact same fetch-and-review against the PR diff but prints locally — and there it behaves like a normal terminal review, re-enabling --json, --markdown, --output, --copy, and --cli (including local CLI providers, which the posting path forbids).

commitbrief remote pr 42                              # comment-only review, posted
commitbrief remote pr 42 --request-changes-on high   # opt in to blocking on high+
commitbrief remote pr owner/repo#42 --no-post --json # review locally, post nothing
Enter fullscreen mode Exit fullscreen mode

What it is not

It's not a hosted GitHub App, and that's deliberate, not a gap. There's no installation token, no webhook, no always-on listener — which means it also won't review a PR you forgot about, and it can't enforce a team policy that every PR be reviewed before merge. CommitBrief doesn't do mandatory-review gating (that's an explicit non-goal); per-developer triggering and an opt-in verdict are the integration shape it supports. It needs your gh auth and a paid API provider to post, and like every other path in this series it's the zeroth reviewer — the fast pass that catches the obvious before a human looks, not a substitute for the human.

What you get in exchange for giving up the always-on webhook is the thing the webhook costs you: control of the trigger. The review fires from your account, on a PR you chose, when you ran the command — and nothing about your code or your repo lives on anyone's server but GitHub's.

Repo: github.com/CommitBrief/commitbrief.


Part 9 of **Building CommitBrief* — the finale. Six internals, three integrations: terminal, agent, and pull request, one engine.*

Top comments (0)