lgtm

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: Apache-2.0

README

LGTM

CI License

Score pull requests with System One decision models.

LGTM asks a decision model such as TypeSafe's Jev (typesafe/jev-1.13) a set of typed questions about a PR: is the diff aligned with its description, scoped, free of malice and security mistakes, tested? Each answer is a 0–1 value. LGTM combines them into one verdict, approve, deny, or needs-human, which you can gate a merge on. A typical PR costs a fraction of a cent.

LGTM doesn't write review comments, approve PRs, or merge. It's a mistake detector, not a security boundary: run it alongside required human reviewers, never instead of them.

Status: pre-release. See design/PRD.md.

Set up

You need an OpenRouter API key. Create a dedicated key with a spend limit.

go install github.com/Temikus/lgtm/cmd/lgtm@latest
lgtm init                       # writes two workflows and .github/lgtm.toml
gh secret set OPENROUTER_API_KEY

Commit the files to your default branch. LGTM then runs on every PR, in shadow mode: it posts a check run and one comment that it updates in place, and gates nothing.

  • lgtm.yml scores PRs on pull_request_target. It has no checkout step, and none may be added: that job has secrets and a write token while the PR is untrusted. LGTM reads the diff through the API and its config from the base branch, so a PR can't change its own rubric or thresholds.
  • lgtm-review.yml lets a human approval pass the check on fork PRs, whose review events get a read-only token. It never calls the model. For a first-time contributor, GitHub may hold the review event's run until you re-run it from the Actions tab.

Verdicts

Each question gives a 0–1 value. The default aggregate is the minimum over gating questions:

  • at or above approve_at (default 0.9): approve;
  • at or below deny_at (default 0.1): deny;
  • in between: needs-human.

A veto question denies on its own: malicious below veto_at (default 0.4) denies whatever the aggregate says. It asks about deliberate harm, such as exfiltration, backdoors, hidden payloads, and weakened CI safeguards. veto_at is global, not fitted per repo; it is set from real attacks and hard legitimate PRs (docs/calibration.md).

Any of these holds an approve at needs-human: a cap question is below its cap_at (injection at the global default 0.3, hygiene at 0.8, breaks at 0.7), LGTM couldn't see the whole diff, a lockfile pulls a package from outside the usual registries (npm, PyPI, crates.io, RubyGems, pub.dev; lockfile_registries adds your own), the diff changes files that run commands on their own on developers' machines (VS Code tasks, dev containers, git hooks, or Claude, Gemini, or Cursor agent settings), a required check failed on the commit (the base branch's ruleset checks plus ci_required; checks still running don't count, so this mostly catches re-scores; GitHub still blocks the merge on a failed required check, and a re-check after CI is #17), the diff changes a sensitive path (CI workflows, actions, and CODEOWNERS by default; sensitive_paths), the diff is over max_changed_lines (default 1,000), the PR is from a fork by an author without write access, the replies name a different model version than the one the thresholds were calibrated on, or a borderline score changed when choice options were reordered. Provider errors, timeouts, and oversized input also give needs-human. Nothing defaults to approve.

In enforce mode, the check run fails on deny and needs-human, and the job exits non-zero. A needs-human check passes once someone with write access approves the scored commit, and stays passed if that commit is scored again, such as after an edit to the description. A new push needs a new approval. Enforce mode needs a [calibration] table from lgtm replay --write, because raw model probabilities aren't calibrated. Use it alongside required reviewers or CODEOWNERS, never instead of them.

Configuration

.github/lgtm.toml is read at the PR's base commit. lgtm init writes a commented copy, and lgtm config validate checks one.

Question Type Role Sees
aligned yes/no gate everything
scoped yes/no gate everything
malicious yes/no veto diff only
hygiene yes/no cap diff only
breaks yes/no cap diff only
tested yes/no info diff only
injection yes/no cap everything
described yes/no info everything
risk choice info everything
reviewability 5-level score info everything

"Diff only" questions never see the PR title, description, or issue titles, so claims like "approved by security" there can't sway them. hygiene covers honest security mistakes (committed secrets, injectable code, disabled TLS or auth): they need a human, not a deny. [questions.hygiene] cap_at = 0.7 sets its own hold point. tested is informational because on reviewed history rejected PRs scored higher on it than merged ones (docs/calibration.md); [questions.tested] gate = true makes it a gate again.

CLI

lgtm score Temikus/lgtm#1          # score a PR locally; prints a table
lgtm score <PR URL> --json         # machine-readable
lgtm score Temikus/lgtm#1 --post   # also post the sticky comment as you
lgtm config validate

score and replay read GITHUB_TOKEN (or gh auth token) and OPENROUTER_API_KEY. LGTM_BASE_URL points at a different https:// endpoint, such as a proxy.

Calibrating
lgtm replay owner/repo --dry-run           # label history and estimate cost; no model calls
lgtm replay owner/repo --perturb           # score labelled PRs and report
lgtm replay owner/repo --write             # also store [calibration] in .github/lgtm.toml
lgtm replay owner/repo --offline           # rerun from the local dataset: no GitHub calls
lgtm data pull --list docs/known-bad-prs.txt && lgtm replay owner/repo --lists
lgtm data pull --list docs/hard-good-prs.txt --label positive

Replay labels closed PRs from history. By default a PR approved by someone with write access and then merged is positive, and one closed after a changes-requested review, or reverted within 14 days, is negative. Few repos close PRs after a formal changes-requested review, so strict negatives are rare. --labels maintainer adds Prow's lgtm label as an approval and counts a PR closed unmerged by someone other than its author (directly, or through a bot after their /close) as negative; stale closures and authors closing their own PRs stay unlabelled. It works on repos like kubernetes. For a repo with one maintainer and no reviews, --labels merged counts merged PRs as positive and closed ones as negative, skipping bots. These labels are weaker, because they record what shipped rather than what a reviewer judged.

It then scores the labelled PRs and picks the lowest approve_at that approves at most --max-false-approve (default 5%) of rejected PRs, and the highest deny_at below it that denies at most --max-false-deny (default 1%) of merged ones, known-good listed PRs included. Rates per class are used rather than precision, because most repos merge 95% of what they close, so approving nearly everything would already reach 95% precision. The fit needs at least 20 rejected PRs, since one false approval in fewer than 20 is already more than 5%. --write stores the result, with the model version the replies named, and changes nothing else in the file. It writes nothing if the fit fails or if the replies named more than one version. --budget-usd (default 2) caps the run: before each model call, replay reserves that request's estimated cost and stops if the total could pass the budget. --dry-run shows the estimate first.

Replay keeps PRs, labels, diffs, and model replies in Parquet files under your cache directory (lgtm data dir prints it), so a rerun reads only the PR list and asks the model only about calls it hasn't seen. --offline reads nothing from GitHub, and --rescore asks the model again. The first model call of each run always goes to the model, and stored replies are reused only if they came from the version it serves now, so a silent model update can't hide behind the cache. To start again, delete the directory. Every run also adds per-question scores to a scores table you can query, for example with DuckDB: select * from read_parquet('<dir>/scores/*.parquet', union_by_name=true).

Verify a release

Each release tag pins action.yml to a GHCR image by digest, so a tag can't start running a different binary. lgtm init uses the v0 tag; for full immutability, pin the release commit's SHA instead. The image and the release archives are signed with keyless cosign and carry build provenance:

gh attestation verify oci://ghcr.io/temikus/lgtm@sha256:<digest> --repo Temikus/lgtm
gh attestation verify lgtm_<version>_linux_amd64.tar.gz --repo Temikus/lgtm
cosign verify-blob checksums.txt --bundle checksums.txt.bundle \
  --certificate-identity https://github.com/Temikus/lgtm/.github/workflows/release.yml@refs/heads/main \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Data

LGTM sends PR content (title, description, linked issue titles, and diffs) to the model provider. Paths matched by exclude are never sent. On OpenRouter, turn on the data-policy setting that refuses providers which train on inputs. Request bodies are never logged unless LGTM_DEBUG=1, and Action logs on public repos are public. lgtm replay and lgtm data keep PR content and model replies on your machine, in the dataset directory; delete it, or pass --no-data, to keep nothing.

Development

Uses mise for tools and just for tasks.

mise install
just check                 # lint + unit tests
just test integration      # live Jev API; needs OPENROUTER_API_KEY
just release-dry           # build release archives locally
just release minor         # start the release workflow for the next minor version

License

Apache-2.0

Directories

Path Synopsis
cmd
lgtm command
Command lgtm scores pull requests with System One decision models.
Command lgtm scores pull requests with System One decision models.
internal
action
Package action is the GitHub Action entrypoint: it routes a workflow event to scoring or to the human-approval flip.
Package action is the GitHub Action entrypoint: it routes a workflow event to scoring or to the human-approval flip.
ci
Package ci reads whether a commit's required checks failed.
Package ci reads whether a commit's required checks failed.
config
Package config loads and validates .github/lgtm.toml.
Package config loads and validates .github/lgtm.toml.
dataset
Package dataset keeps what replay reads from GitHub and the model in local Parquet files, so later runs can skip those calls.
Package dataset keeps what replay reads from GitHub and the model in local Parquet files, so later runs can skip those calls.
engine
Package engine scores one PR: fetch, plan calls, ask, and decide.
Package engine scores one PR: fetch, plan calls, ask, and decide.
ghout
Package ghout publishes results to GitHub: the LGTM check run, the sticky comment, and the needs-human label.
Package ghout publishes results to GitHub: the LGTM check run, the sticky comment, and the needs-human label.
ghtest
Package ghtest serves a fake GitHub REST API over httptest for tests.
Package ghtest serves a fake GitHub REST API over httptest for tests.
jev
Package jev is a client for System One decision models such as TypeSafe's Jev, served at POST {base}/systemone by OpenRouter and TypeSafe.
Package jev is a client for System One decision models such as TypeSafe's Jev, served at POST {base}/systemone by OpenRouter and TypeSafe.
prstate
Package prstate fetches a PR's diff at pinned SHAs and builds the state the decision model sees.
Package prstate fetches a PR's diff at pinned SHAs and builds the state the decision model sees.
replay
Package replay scores past PRs, compares the scores with what humans decided, and fits the approve and deny thresholds.
Package replay scores past PRs, compares the scores with what humans decided, and fits the approve and deny thresholds.
report
Package report renders a scoring result as a PR comment, check-run output, JSON, or terminal text.
Package report renders a scoring result as a PR comment, check-run output, JSON, or terminal text.
rubric
Package rubric defines the questions LGTM asks about a PR, how repo config changes them, and how each answer becomes a 0-1 value.
Package rubric defines the questions LGTM asks about a PR, how repo config changes them, and how each answer becomes a 0-1 value.
trust
Package trust decides whose PRs and reviews count.
Package trust decides whose PRs and reviews count.
verdict
Package verdict turns normalised answers into approve, deny, or needs-human.
Package verdict turns normalised answers into approve, deny, or needs-human.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL