imbik-rectifier 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of imbik-rectifier might be problematic. Click here for more details.

@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: imbik-rectifier
3
+ Version: 0.2.0
4
+ Summary: Review the changes in a git checkout with rectifier, imbik's code reviewer
5
+ Project-URL: Homepage, https://rectifier.imbik.ai
6
+ Author: imbik
7
+ Classifier: Environment :: Console
8
+ Classifier: License :: Other/Proprietary License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Software Development :: Quality Assurance
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+
14
+ # rectifier review
15
+
16
+ Review the changes in a git checkout with [rectifier](https://rectifier.imbik.ai), imbik's code reviewer. The review
17
+ runs on rectifier's service; this command sends the change, waits, and prints the review.
18
+
19
+ The [docs](https://rectifier.imbik.ai/docs/) have the rest: how to set it up, every option, and how to use it with
20
+ Claude Code.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ uv tool install imbik-rectifier # or: pipx install imbik-rectifier
26
+ ```
27
+
28
+ It needs Python 3.10 or later and git, and nothing else.
29
+
30
+ ## Use
31
+
32
+ Make a token on the dashboard's CLI tokens page, then, in a checkout of a repository your account has connected:
33
+
34
+ ```bash
35
+ export RECTIFIER_CLI_TOKEN=rct_...
36
+ rectifier review # the branch, against where it left the default branch
37
+ rectifier review --last 3 # the last 3 commits
38
+ rectifier review abc123 # one commit
39
+ rectifier review --uncommitted # the uncommitted changes
40
+ rectifier review --post # and post it on the branch's open pull or merge request
41
+ ```
42
+
43
+ The report goes to stdout, and a copy to `.rectifier/reviews/`. The exit code is 0 when nothing is blocking, 1 when
44
+ something is, 2 when the review did not run, and 3 when `--uncommitted` stopped for you to agree to the files it sends.
45
+ `rectifier review --help` and the [command's docs](https://rectifier.imbik.ai/docs/?page=cli) have the rest.
46
+
47
+ Commits your forge does not have yet are uploaded for the review. Before `--uncommitted` sends anything, it lists the
48
+ files it would send, tracked and untracked, and asks. Run without a terminal, as by Claude Code, it sends nothing and
49
+ prints a code instead: `rectifier review --uncommitted --confirm <code>`, run once you agree, sends exactly those files.
50
+ The service deletes them after the review ends.
51
+
52
+ ## Upgrade
53
+
54
+ ```bash
55
+ uv tool upgrade imbik-rectifier # or: pipx upgrade imbik-rectifier
56
+ ```
@@ -0,0 +1,9 @@
1
+ rectifier_client/__init__.py,sha256=72QeCnSADGJqOgAXARMMcZQ_vMKYt4wr7gZ--aMiKl4,332
2
+ rectifier_client/change.py,sha256=lNIqBPlDdASCWFpQG81BLzyfSxcXfIXohZw-WBbjOZA,16758
3
+ rectifier_client/cli.py,sha256=nc2CvxcCNQ4L6O9rgu05eXcfPDyJYb6T6huZQfeOTvI,882
4
+ rectifier_client/report.py,sha256=tycOngcGW-fU9EhM0Q6UIUvIExQOra9jNkDsSW3xWXw,8967
5
+ rectifier_client/review.py,sha256=rvmLeokiaUrbDTdRfPVoMrhT6WcFyDLm16VM1254Pq0,27094
6
+ imbik_rectifier-0.2.0.dist-info/METADATA,sha256=mqyINLSOkkdYxKp9eB5kfIrF2Yswayl4M_rFEHf-Ky8,2342
7
+ imbik_rectifier-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ imbik_rectifier-0.2.0.dist-info/entry_points.txt,sha256=M6JK8vP5oevy0pO8b_DG6CK5JOT8gi_0rPm0t3wkTYQ,56
9
+ imbik_rectifier-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rectifier = rectifier_client.cli:main
@@ -0,0 +1,7 @@
1
+ """`rectifier review`: the command that sends changes in a local checkout to rectifier's review service and prints the
2
+ review. It uses the standard library only.
3
+ """
4
+
5
+ __version__ = "0.2.0"
6
+ """The version sent with every request (`cli_version`). The service refuses a version too old to read its answers, and
7
+ says how to upgrade."""
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env python3
2
+ """What `rectifier review` reviews: the commits one call names, read from the local checkout, and the repository and
3
+ forge they belong to.
4
+
5
+ (no input) the branch, against where it left the default branch
6
+ abc123 one commit, against its first parent
7
+ --last 3 the last 3 commits, as one change
8
+ main..feature a range, as one change
9
+ --uncommitted the uncommitted changes, against HEAD
10
+ --uncommitted --base X the uncommitted changes and the commits since X, against X
11
+
12
+ Uncommitted changes are reviewed only with `--uncommitted`, as a temporary commit made on the side (`snapshot`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import tempfile
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Callable
25
+ from urllib.parse import urlparse
26
+
27
+ SCOPES = ("branch", "commit", "last", "range", "uncommitted")
28
+
29
+ SNAPSHOT_IDENTITY = {
30
+ "GIT_AUTHOR_NAME": "rectifier", "GIT_AUTHOR_EMAIL": "rectifier@imbik.ai",
31
+ "GIT_COMMITTER_NAME": "rectifier", "GIT_COMMITTER_EMAIL": "rectifier@imbik.ai",
32
+ }
33
+ """The author and committer of the temporary commit that `--uncommitted` makes from the user's uncommitted changes.
34
+ Rectifier makes that commit, not the user, and it works whether or not the user's git has a name and email set."""
35
+
36
+ Git = Callable[..., str]
37
+
38
+
39
+ class LocalError(Exception):
40
+ """Something about the checkout or the request that stops a review before it starts. The message says what."""
41
+
42
+
43
+ def _git(repo: Path, *args: str, env: dict[str, str] | None = None, errors: str = "strict") -> str:
44
+ # Bytes, decoded here: text mode reads a carriage return, which git keeps in an author's email, as a newline.
45
+ out = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, timeout=600, env=env)
46
+ if out.returncode != 0:
47
+ raise LocalError(f"git {' '.join(args)} failed: {out.stderr.decode(errors='replace').strip()[:200]}")
48
+ # `errors="replace"` is for output only shown or recorded; a name passed back to git keeps the strict default.
49
+ return out.stdout.decode(errors=errors)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Selection:
54
+ """The commits one call reviews, as a base and a head."""
55
+
56
+ base: str
57
+ head: str
58
+ scope: str
59
+ """What kind of input it was: `branch`, `commit`, `last`, `range` or `uncommitted` (`SCOPES`)."""
60
+ notes: tuple[str, ...] = ()
61
+ """What the reader should know about how the input was read, such as a `--last N` that ran past the first
62
+ commit, or the untracked files a review of uncommitted changes takes in."""
63
+ untracked: tuple[str, ...] = ()
64
+ """For uncommitted changes, the untracked files the temporary commit takes in."""
65
+ on: str = ""
66
+ """For uncommitted changes, the real commit the temporary one sits on, HEAD. The report names this one, since
67
+ `head` is a commit nobody else has."""
68
+ branch: str = ""
69
+ """The checkout's branch; empty when HEAD is detached."""
70
+ against: str = ""
71
+ """For the whole branch, the name of what it is compared against, such as `origin/main`."""
72
+
73
+ @property
74
+ def sliced(self) -> bool:
75
+ """A part of the branch rather than the whole of it, which gets a lite review by default."""
76
+ return self.scope != "branch"
77
+
78
+
79
+ def select(repo: Path, target: str | None = None, last: int | None = None, base: str | None = None,
80
+ uncommitted: bool = False, git: Git = _git) -> Selection:
81
+ """The base and head one call reviews. Every name is turned into a full sha here, so the report records the exact
82
+ commits.
83
+
84
+ No input is the whole branch, against where it left `base` or the remote's default branch. `target` is a commit,
85
+ reviewed against its first parent, or a range `A..B`, reviewed from where B left A. `last` is the last N commits on
86
+ this branch's own line, first parents only, so commits a merge brought in do not count. `uncommitted` is the
87
+ working tree against HEAD, or with `base` against where HEAD left it, as a temporary commit (`snapshot`).
88
+ """
89
+ if target and last is not None:
90
+ raise LocalError("give a commit or a range, or --last, not both")
91
+ if uncommitted and (target or last is not None):
92
+ raise LocalError("--uncommitted goes with no commit, range or --last; --base is the one input it takes")
93
+ if base and (target or last is not None):
94
+ raise LocalError("--base goes with no input or --uncommitted: it changes what the branch is compared against")
95
+
96
+ def sha(rev: str) -> str:
97
+ try:
98
+ return git(repo, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}").strip()
99
+ except LocalError:
100
+ raise LocalError(f"{rev!r} is not a commit in this checkout") from None
101
+
102
+ def count(a: str, b: str) -> int:
103
+ return int(git(repo, "rev-list", "--count", f"{a}..{b}").strip() or 0)
104
+
105
+ def merge_base(a: str, b: str) -> str:
106
+ try:
107
+ return git(repo, "merge-base", a, b).strip()
108
+ except LocalError:
109
+ raise LocalError(f"{a[:7]} and {b[:7]} have no commit in common") from None
110
+
111
+ branch = git(repo, "rev-parse", "--abbrev-ref", "HEAD").strip()
112
+ branch = "" if branch == "HEAD" else branch
113
+
114
+ if uncommitted:
115
+ on = sha("HEAD")
116
+ start = merge_base(sha(base), on) if base else on
117
+ snap, untracked = snapshot(repo)
118
+ if snap == "":
119
+ raise LocalError("there are no uncommitted changes to review")
120
+ return Selection(start, snap, "uncommitted", untracked=untracked, on=on, branch=branch)
121
+
122
+ if last is not None:
123
+ if last < 1:
124
+ raise LocalError("--last takes a number of commits, 1 or more")
125
+ head = sha("HEAD")
126
+ line = git(repo, "rev-list", "--first-parent", f"--max-count={last + 1}", head).split()
127
+ if len(line) < 2:
128
+ raise LocalError("HEAD is the repository's first commit, so it has no parent to compare with")
129
+ notes: tuple[str, ...] = ()
130
+ if len(line) <= last:
131
+ notes = (f"--last {last} goes past the first commit, so this reviews the {len(line) - 1} commits after "
132
+ f"it",)
133
+ start = line[min(last, len(line) - 1)]
134
+ return Selection(start, head, "last", notes, branch=branch)
135
+
136
+ if target and ".." in target:
137
+ left, _, right = target.partition("...") if "..." in target else target.partition("..")
138
+ b = sha(right or "HEAD")
139
+ start = merge_base(sha(left or "HEAD"), b)
140
+ if not count(start, b):
141
+ raise LocalError(f"{target} has no commits to review")
142
+ return Selection(start, b, "range", branch=branch)
143
+
144
+ if target:
145
+ head = sha(target)
146
+ parents = git(repo, "rev-list", "--parents", "--max-count=1", head).split()[1:]
147
+ if not parents:
148
+ raise LocalError(f"{target} is the repository's first commit, so it has no parent to compare with")
149
+ return Selection(parents[0], head, "commit", branch=branch)
150
+
151
+ against = base or default_branch(repo, git)
152
+ head = sha("HEAD")
153
+ start = merge_base(sha(against), head)
154
+ if not count(start, head):
155
+ raise LocalError(f"HEAD has no commits that {against} does not have, so there is nothing to review. "
156
+ f"Name a commit, a range or --last N")
157
+ return Selection(start, head, "branch", branch=branch, against=against)
158
+
159
+
160
+ def snapshot(repo: Path) -> tuple[str, tuple[str, ...]]:
161
+ """The working tree as a commit on top of HEAD, and the untracked files it takes in. Empty when nothing differs.
162
+
163
+ Made with a copy of the index, so the user's index, branch and files are left as they were: `git add -A` into the
164
+ copy takes in staged and unstaged changes and the untracked files git does not ignore, then `write-tree` and
165
+ `commit-tree` make a commit no ref points to, by rectifier (`SNAPSHOT_IDENTITY`) at the current time.
166
+ """
167
+ untracked = tuple(p for p in _git(repo, "ls-files", "--others", "--exclude-standard", "-z",
168
+ errors="replace").split("\0") if p)
169
+ index = Path(_git(repo, "rev-parse", "--path-format=absolute", "--git-path", "index").strip())
170
+ with tempfile.TemporaryDirectory(prefix="rectifier-index-") as scratch:
171
+ copy = Path(scratch) / "index"
172
+ if index.exists():
173
+ # `copy2`, which keeps the index's own modification time: git trusts a file whose size and time match
174
+ # what the index recorded unless the index is no older than the file, and a copy made now would be newer
175
+ # than an edit that kept the size, so that edit would be missed.
176
+ shutil.copy2(index, copy)
177
+ env = {**os.environ, "GIT_INDEX_FILE": str(copy), **SNAPSHOT_IDENTITY}
178
+
179
+ def run(*args: str) -> str:
180
+ return _git(repo, *args, env=env).strip()
181
+
182
+ run("add", "-A")
183
+ tree = run("write-tree")
184
+ if tree == run("rev-parse", "HEAD^{tree}"):
185
+ return "", ()
186
+ return run("commit-tree", tree, "-p", "HEAD", "-m", "rectifier: uncommitted changes"), untracked
187
+
188
+
189
+ STATUS = {"A": "added", "M": "modified", "D": "deleted", "T": "changed"}
190
+ """How the list of uncommitted changes names each kind of change to a tracked file (`git diff --name-status`)."""
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class Sending:
195
+ """The files `--uncommitted` would send, for the user to see and agree to before anything is sent."""
196
+
197
+ tracked: tuple[tuple[str, str], ...]
198
+ """Each tracked file with a change, as its kind of change (`STATUS`) and its path."""
199
+ untracked: tuple[str, ...]
200
+ code: str
201
+ """Six characters of the temporary commit's tree, which is the files' contents: the same files give the same code,
202
+ and any change to one, or a file more, gives another (`--confirm`)."""
203
+
204
+
205
+ def sending_of(repo: Path, selection: Selection, git: Git = _git) -> Sending:
206
+ """What a review of uncommitted changes sends: the files that differ from HEAD, tracked and untracked."""
207
+ rows = git(repo, "diff", "--name-status", "--no-renames", "-z", selection.on, selection.head,
208
+ errors="replace").split("\0")
209
+ untracked = set(selection.untracked)
210
+ tracked = tuple((STATUS.get(kind[:1], "changed"), path) for kind, path in zip(rows[::2], rows[1::2])
211
+ if path and path not in untracked)
212
+ code = git(repo, "rev-parse", f"{selection.head}^{{tree}}").strip()[:6]
213
+ return Sending(tracked, tuple(sorted(untracked)), code)
214
+
215
+
216
+ def uncommitted_left_out(repo: Path, git: Git = _git) -> bool:
217
+ """Whether the working tree has changes a review without `--uncommitted` leaves out, for the command to say so."""
218
+ return bool(git(repo, "status", "--porcelain", "--untracked-files=no").strip())
219
+
220
+
221
+ def default_branch(repo: Path, git: Git = _git) -> str:
222
+ """The remote's default branch, such as `origin/main` or `origin/dev`, as git recorded it at clone time."""
223
+ try:
224
+ return git(repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").strip()
225
+ except LocalError:
226
+ raise LocalError("cannot tell this repository's default branch. Run `git remote set-head origin --auto`, "
227
+ "or pass --base <branch>") from None
228
+
229
+
230
+ HOST = re.compile(r"[A-Za-z0-9][A-Za-z0-9.-]*")
231
+ """A host name, as an `origin` URL may name one: letters, digits, dots and dashes, starting with a letter or digit."""
232
+
233
+
234
+ def origin(repo: Path, git: Git = _git) -> tuple[str, str]:
235
+ """The `origin` remote's host and repository path, such as `github.com` and `owner/name`. Both are empty when
236
+ there is no `origin` or its URL is not one of a forge."""
237
+ try:
238
+ url = git(repo, "remote", "get-url", "origin").strip()
239
+ except LocalError:
240
+ return "", ""
241
+ if "://" in url:
242
+ host, path = urlparse(url).hostname or "", urlparse(url).path
243
+ elif re.match(r"^[\w.-]+@[\w.-]+:", url): # git@host:owner/name.git
244
+ host, path = url.split("@", 1)[1].split(":", 1)
245
+ else:
246
+ return "", ""
247
+ if not HOST.fullmatch(host): # one that starts with `-` would reach `ssh -G` as an option (`ssh_hostname`)
248
+ return "", ""
249
+ return host, re.sub(r"\.git$", "", path.strip("/"))
250
+
251
+
252
+ FORGES = {"github.com": "github", "ssh.github.com": "github", "gitlab.com": "gitlab", "altssh.gitlab.com": "gitlab"}
253
+ """The hosts an `origin` the service can review is on, by forge: github.com and gitlab.com, each with the host it also
254
+ takes SSH on, over port 443."""
255
+
256
+
257
+ def ssh_hostname(host: str) -> str:
258
+ """The host SSH connects to for `host`, from the user's SSH configuration: an alias such as `github-work` stands for
259
+ `github.com`. `host` itself when it is no alias, or when `ssh` cannot say."""
260
+ try:
261
+ said = subprocess.run(["ssh", "-G", "--", host], capture_output=True, text=True, timeout=10).stdout
262
+ except (OSError, subprocess.SubprocessError):
263
+ return host
264
+ return next((line.split(None, 1)[1].strip() for line in said.splitlines()
265
+ if line.startswith("hostname ") and len(line.split(None, 1)) == 2), host)
266
+
267
+
268
+ def forge_of(repo: Path, git: Git = _git, resolve: Callable[[str], str] = ssh_hostname) -> str:
269
+ """The forge `origin` is on, `github` or `gitlab` (`FORGES`), with an SSH alias read as the host it stands for.
270
+ Raises for any other host, before anything is sent: the service would refuse it, and say so less plainly."""
271
+ named, _ = origin(repo, git)
272
+ if not named:
273
+ try:
274
+ url = git(repo, "remote", "get-url", "origin").strip()
275
+ except LocalError:
276
+ raise LocalError("this checkout has no `origin` remote, so its repository is unknown") from None
277
+ raise LocalError(f"origin is {url}, which is neither GitHub (github.com) nor GitLab (gitlab.com); rectifier "
278
+ f"reviews repositories on those two")
279
+ host = named.lower() if named.lower() in FORGES else resolve(named).lower()
280
+ if host not in FORGES:
281
+ where = f"{named}," if host == named.lower() else f"{named}, which your SSH configuration points at {host},"
282
+ raise LocalError(f"origin is on {where} which is neither GitHub (github.com) nor GitLab (gitlab.com); "
283
+ f"rectifier reviews repositories on those two")
284
+ return FORGES[host]
285
+
286
+
287
+ def repo_name(repo: Path, git: Git = _git) -> str:
288
+ """The repository's `owner/name`, or `group/sub/name` on GitLab, read from the `origin` URL. A checkout with no
289
+ usable remote is `local/<directory>`."""
290
+ _, path = origin(repo, git)
291
+ if "/" in path:
292
+ return path
293
+ top = Path(git(repo, "rev-parse", "--show-toplevel").strip())
294
+ return f"local/{top.name}"
295
+
296
+
297
+ def under_review(scope: str, commits: int, base: str, head: str, branch: str = "", against: str = "") -> str:
298
+ """What is under review, in the report's words: "commit 3f9c2a1", "3 commits, 3f9c2a1..8d41e07", "branch feature, 3
299
+ commits against origin/main" or "uncommitted changes against 8d41e07 on branch feature". `commits` counts the
300
+ commits under review, which for uncommitted changes stop at HEAD."""
301
+ n, base, head = commits, base[:7], head[:7]
302
+ counted = f"{n} commit{'' if n == 1 else 's'}"
303
+ if scope == "branch":
304
+ return f"{'branch ' + branch if branch else 'HEAD'}, {counted} against {against}"
305
+ if scope == "uncommitted":
306
+ on = f" on branch {branch}" if branch else ""
307
+ return f"uncommitted changes and {counted} since {base}{on}" if n else f"uncommitted changes against {base}{on}"
308
+ if scope == "commit" or n == 1:
309
+ return f"commit {head}"
310
+ return f"{counted}, {base}..{head}"
311
+
312
+
313
+ @dataclass(frozen=True)
314
+ class Size:
315
+ """What the report says of the change before any finding: what it is, and how many files and lines it touches."""
316
+
317
+ under_review: str
318
+ files_changed: int
319
+ added: int
320
+ deleted: int
321
+
322
+
323
+ def size_of(repo: Path, selection: Selection, git: Git = _git) -> Size:
324
+ """The selection's size, as `git diff --numstat` counts it, and what it is (`under_review`)."""
325
+ files = added = deleted = 0
326
+ for row in git(repo, "diff", "--numstat", selection.base, selection.head, errors="replace").splitlines():
327
+ plus, minus, _ = (row.split("\t", 2) + ["", "", ""])[:3]
328
+ files += 1
329
+ added += int(plus) if plus.isdigit() else 0
330
+ deleted += int(minus) if minus.isdigit() else 0
331
+ # The commits under review stop at HEAD for uncommitted changes: the temporary commit on top is not one of them.
332
+ last = selection.on or selection.head
333
+ commits = int(git(repo, "rev-list", "--count", f"{selection.base}..{last}").strip() or 0)
334
+ said = under_review(selection.scope, commits, selection.base, selection.head, selection.branch, selection.against)
335
+ return Size(said, files, added, deleted)
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+ """`rectifier`, as the `imbik-rectifier` package installs it.
3
+
4
+ rectifier review --last 3 send changes in this checkout to be reviewed (`rectifier review --help`)
5
+ rectifier --version
6
+
7
+ Docs: https://rectifier.imbik.ai/docs/
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+
14
+ from rectifier_client import __version__
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ argv = sys.argv[1:] if argv is None else argv
19
+ if argv[:1] == ["review"]:
20
+ from rectifier_client import review
21
+
22
+ return review.main(argv[1:])
23
+ if argv[:1] in (["--version"], ["-V"]):
24
+ print(f"rectifier {__version__} (imbik-rectifier)")
25
+ return 0
26
+ asked = argv[:1] in (["-h"], ["--help"])
27
+ print(__doc__.strip(), file=sys.stdout if asked else sys.stderr)
28
+ return 0 if asked else 2
29
+
30
+
31
+ if __name__ == "__main__":
32
+ raise SystemExit(main())
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """The review as `rectifier review` prints it and keeps it: the verdict, the counts, and each finding with its message
3
+ for a coding agent, in markdown; a copy under `.rectifier/reviews/`; and the exit code.
4
+
5
+ The service sends each finding ready to print: its text already rendered as a pull request comment shows it, whether it
6
+ blocks by the account's blocking severity, the severity to show it at, why it is unconfirmed when no check confirmed
7
+ it, and its message for an agent. It sends the verdict, and any note on how the review went, as sentences. This lays
8
+ them out and decides none of it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import itertools
14
+ import json
15
+ import re
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Mapping
19
+
20
+ SAVED = Path(".rectifier") / "reviews"
21
+ """Where each review's markdown and JSON are kept, under the checkout's top level."""
22
+
23
+ IGNORE = "*\n"
24
+ """What `.rectifier/.gitignore` holds: everything in the folder, the file itself included, so nothing there is ever
25
+ committed and the repository's own `.gitignore` is left alone."""
26
+
27
+ DEPTHS = {"light": "Lite", "deep": "Deep"}
28
+
29
+ LIKELIHOODS = {"normal_use": "Normal use", "edge_case": "Edge case", "edge_case_rare": "Edge case"}
30
+ """How a finding's `likelihood` reads in the report."""
31
+
32
+ RARE = "edge_case_rare"
33
+ """Rendered as an edge case with `rare` in front of its reason: `Edge case (rare: reason)`."""
34
+
35
+ BACKTICKS = re.compile(r"`+")
36
+
37
+ CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
38
+ """Characters a terminal can take as commands rather than text, such as the escape that opens a sequence to write the
39
+ clipboard or clear the screen. Newline and tab are not among them."""
40
+
41
+
42
+ def visible(text: str) -> str:
43
+ """`text` as the report prints and keeps it: each control character (`CONTROL`) written out, as `\\x1b`, so a
44
+ terminal shows it rather than acting on it. A finding can quote a line of the repository's own files, which can hold
45
+ any byte. A Windows line ending is one newline."""
46
+ return CONTROL.sub(lambda c: f"\\x{ord(c.group(0)):02x}", text.replace("\r\n", "\n"))
47
+
48
+
49
+ def why_not_a_review(produced: Mapping[str, Any]) -> str:
50
+ """Why the service says this run is not a review, or empty when it is one. A run that did not complete can still
51
+ have returned no findings, so the count alone never says the change is clean."""
52
+ return str((produced.get("meta") or {}).get("error") or "")
53
+
54
+
55
+ def report(produced: dict[str, Any]) -> str:
56
+ """The review as markdown: the verdict, the counts, and each finding with its message for a coding agent."""
57
+ findings = produced.get("findings") or []
58
+ about = produced.get("review") or {}
59
+ depth = DEPTHS.get(about.get("depth") or "", "Configured")
60
+ first = (f"{_capital(about.get('under_review') or 'the change')}. {_count(about.get('files_changed', 0), 'file')}, "
61
+ f"+{about.get('added', 0)}/-{about.get('deleted', 0)}. {depth} review.")
62
+ meta = produced.get("meta") or {}
63
+ why = why_not_a_review(produced)
64
+ if why:
65
+ return visible("\n".join(["## Rectifier review: did not complete", "", f"{first} {why}"]))
66
+ counts = (f"{_count(len(findings), 'finding')}, {sum(bool(f.get('blocking')) for f in findings)} blocking."
67
+ if findings else "Nothing found.")
68
+ verdict = produced.get("verdict") or "Accept"
69
+ if partial := meta.get("partial"):
70
+ # The service's sentence on the part of the review that did not finish. The exit code stays the verdict's.
71
+ verdict += " (partial)"
72
+ counts += f"\n{partial}"
73
+ if said := about.get("environment_line"):
74
+ counts += f"\n{said}"
75
+ out = [f"## Rectifier review: {verdict}", "", f"{first} {counts}"]
76
+ for finding in findings:
77
+ out += ["", *_finding(finding, about)]
78
+ return visible("\n".join(out))
79
+
80
+
81
+ def _finding(finding: dict[str, Any], about: dict[str, Any]) -> list[str]:
82
+ path, line = finding.get("file") or "", finding.get("line")
83
+ severity = finding.get("severity") or "Finding"
84
+ # No check confirmed it, so it blocks nothing: its heading says so, with what its reviewer rated it when the service
85
+ # shows it lower, as the post does, and why it is unconfirmed follows on a line of its own.
86
+ unconfirmed = finding.get("unconfirmed") or ""
87
+ if unconfirmed:
88
+ was = f"was {finding['lowered_from']}, " if finding.get("lowered_from") else ""
89
+ severity = f"{severity} ({was}not blocking)"
90
+ lines = [f"### {severity}: {finding.get('title') or ''}"]
91
+ if path:
92
+ lines.append(f"`{path}:{line}`" if line else f"`{path}`")
93
+ if unconfirmed:
94
+ lines += ["", f"{_capital(unconfirmed)}."]
95
+ lines += ["", (finding.get("body") or "").strip()]
96
+ if (finding.get("impact") or "").strip():
97
+ lines += ["", f"**Impact:** {finding['impact'].strip()}"]
98
+ likelihood = LIKELIHOODS.get(finding.get("likelihood") or "", finding.get("likelihood") or "")
99
+ if likelihood:
100
+ reason = (finding.get("likelihood_reason") or "").strip()
101
+ aside = ": ".join(filter(None, ["rare" if finding.get("likelihood") == RARE else "", reason]))
102
+ lines += ["", f"**Likelihood:** {likelihood}" + (f" ({aside})" if aside else "")]
103
+ if finding.get("agent_message"):
104
+ message = _agent_message(finding["agent_message"], about)
105
+ fence = "`" * max(3, 1 + max((len(run) for run in BACKTICKS.findall(message)), default=0))
106
+ lines += ["", "Message to fix with an agent:", "", f"{fence}text", message, fence]
107
+ return lines
108
+
109
+
110
+ def _agent_message(message: str, about: dict[str, Any]) -> str:
111
+ """The service's message for a coding agent, with a line naming the repository and the commits in place of the
112
+ pull request line a posted comment has."""
113
+ task, _, rest = message.partition("\n\n")
114
+ at = "on top of commit" if about.get("uncommitted") else "reviewed at commit"
115
+ where = f"Repository: {about.get('repo')}, {about.get('under_review')}, {at} {about.get('head')}"
116
+ return f"{task}\n\n{where}\n{rest}"
117
+
118
+
119
+ def kept(top: Path, text: str, produced: dict[str, Any]) -> Path | None:
120
+ """The report of this same review saved by an earlier run, or None. Run again, as with `--post` within the hour,
121
+ the command is handed the review it already has, and one review is kept once.
122
+
123
+ The same review is the same job with the same report. The service runs a new review under the same job when the
124
+ earlier one failed, or once its result is no longer kept, and that new report is saved on its own."""
125
+ about = produced.get("review") or {}
126
+ job, head = about.get("job") or "", (about.get("head") or produced.get("head_sha") or "")[:7]
127
+ if not job or not (top / SAVED).is_dir():
128
+ return None
129
+ for path in sorted((top / SAVED).glob(f"*-{head}*.json")):
130
+ try:
131
+ if ((json.loads(path.read_text()).get("review") or {}).get("job") == job
132
+ and path.with_suffix(".md").read_text() == text + "\n"):
133
+ return path.with_suffix(".md")
134
+ except (OSError, ValueError):
135
+ continue
136
+ return None
137
+
138
+
139
+ def save(top: Path, text: str, produced: dict[str, Any], now: datetime | None = None) -> Path:
140
+ """Keep the report and the JSON under `.rectifier/reviews/`, and return the report's path. The first save in a
141
+ checkout also writes `.rectifier/.gitignore`, so git never sees the folder."""
142
+ folder = top / SAVED.parent
143
+ folder.mkdir(exist_ok=True)
144
+ ignore = folder / ".gitignore"
145
+ if not ignore.exists():
146
+ ignore.write_text(IGNORE)
147
+ reviews = top / SAVED
148
+ reviews.mkdir(exist_ok=True)
149
+ about = produced.get("review") or {}
150
+ head = about.get("head") or produced.get("head_sha") or ""
151
+ name = (f"{(now or datetime.now()).strftime('%Y-%m-%d-%H%M%S')}-{head[:7]}"
152
+ + ("-uncommitted" if about.get("uncommitted") else ""))
153
+ # Two reviews of the same head can end in the same second, from two terminals: the markdown file is created only
154
+ # if its name is free, and a taken name gets -2, -3 and so on, so one review never overwrites another.
155
+ for n in itertools.count(1):
156
+ stem = reviews / (name if n == 1 else f"{name}-{n}")
157
+ try:
158
+ with stem.with_suffix(".md").open("x") as kept:
159
+ kept.write(text + "\n")
160
+ break
161
+ except FileExistsError:
162
+ continue
163
+ stem.with_suffix(".json").write_text(json.dumps(produced, indent=1) + "\n")
164
+ return stem.with_suffix(".md")
165
+
166
+
167
+ def exit_code(produced: dict[str, Any]) -> int:
168
+ """0 when nothing found is blocking, 1 when something is, 2 when the review did not complete. Which findings
169
+ block is the service's to say."""
170
+ if why_not_a_review(produced):
171
+ return 2
172
+ return 1 if any(f.get("blocking") for f in produced.get("findings") or []) else 0
173
+
174
+
175
+ def _count(n: int, noun: str) -> str:
176
+ return f"{n} {noun}{'' if n == 1 else 's'}"
177
+
178
+
179
+ def _capital(text: str) -> str:
180
+ return text[:1].upper() + text[1:]
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env python3
2
+ """`rectifier review`: send changes in a local checkout to the reviewer service, and print what it found. `HELP` is
3
+ what `rectifier review --help` prints.
4
+
5
+ The review runs on the reviewer service, never here. The command sends the commits the forge does not have yet as a
6
+ git bundle, so nothing needs pushing, waits for the review, and prints it as markdown that Claude Code can act on and
7
+ a person can read. A review takes minutes, so Claude Code runs it in the background. Stopped, the review goes on, and
8
+ the same command picks it up again, except with `--uncommitted`: its temporary commit is new on every run.
9
+
10
+ A commit, the last N commits, a range and uncommitted changes get a lite review, and the whole branch the depth your
11
+ account's rules give its pull requests; `--deep` and `--lite` change that. `--post` posts the review on the branch's
12
+ open pull request, or merge request on GitLab, as a comment asking for a review would, when it has the reviewed
13
+ commits.
14
+
15
+ `--uncommitted` lists the files it would send, tracked and untracked, and asks before sending them. With no terminal to
16
+ ask in, as when Claude Code runs it, it sends nothing and gives a code: the same command with `--confirm <code>`, run
17
+ once the user agrees, sends exactly those files, and stops again if any of them changed.
18
+
19
+ `RECTIFIER_URL` points the command at another reviewer service, such as a local one for testing.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import base64
26
+ import json
27
+ import os
28
+ import secrets
29
+ import shlex
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ import time
34
+ import urllib.error
35
+ import urllib.request
36
+ from pathlib import Path
37
+ from typing import Any, Callable
38
+ from urllib.parse import urlparse
39
+
40
+ from rectifier_client import __version__, change, report
41
+ from rectifier_client.change import LocalError
42
+
43
+ HELP = """\
44
+ Send changes in this checkout to rectifier, and print the review.
45
+
46
+ rectifier review the branch, against where it left the default branch
47
+ rectifier review abc123 one commit, against its first parent
48
+ rectifier review --last 3 the last 3 commits, as one change
49
+ rectifier review main..feature a range, as one change
50
+ rectifier review --uncommitted the uncommitted changes, against HEAD
51
+ rectifier review --uncommitted --base X the uncommitted changes and the commits since X
52
+
53
+ Each run reviews what you name again: plain `rectifier review` reads the whole
54
+ branch every time. Use --last or a range to review only new commits.
55
+
56
+ A review runs on rectifier's service and takes a few minutes. Ctrl-C stops the
57
+ wait, not the review; run the same command to pick it up (with --uncommitted,
58
+ it starts a new review). The review prints to stdout, and a copy goes to
59
+ .rectifier/reviews/.
60
+
61
+ Exit codes: 0 nothing blocks, 1 a finding blocks, 2 the review did not run or
62
+ did not finish, 3 --uncommitted is waiting for you to agree to its files,
63
+ 130 stopped with Ctrl-C.
64
+
65
+ Set RECTIFIER_CLI_TOKEN to a token from the dashboard's CLI tokens page.
66
+ Docs: https://rectifier.imbik.ai/docs/?page=cli
67
+ """
68
+
69
+ DEFAULT_URL = "https://rectifier-api.imbik.ai"
70
+ PATH = "/api/cli/reviews"
71
+ POLL = 10.0
72
+ """Seconds between asks while the review waits or runs."""
73
+
74
+ WAIT_FOR = 2 * 3600.0
75
+ """Seconds the command waits for a review before it stops waiting, well past the longest review. The review goes on, and
76
+ the same command picks it up."""
77
+
78
+ RETRY_FOR = 120.0
79
+ """Seconds the wait keeps asking a service it cannot reach, a laptop waking or the service restarting, before it gives
80
+ up. The review goes on either way."""
81
+
82
+ REFS = "refs/rectifier/review-"
83
+ """Where each run makes the ref its bundle carries the head under, `<REFS><pid>-<random>`, deleted after the bundle. One
84
+ per run, so two runs at once in one repository, or in its worktrees, which share these refs, never bundle or delete
85
+ each other's."""
86
+
87
+
88
+ def bundle_of(top: Path, head: str, git: change.Git = change._git) -> bytes:
89
+ """The commits up to `head` that no `origin/*` branch has, as a git bundle; empty when there are none.
90
+
91
+ Only `origin`'s: the review reads the forge `origin` is on, so a commit only another remote has (a colleague's
92
+ fork) must be uploaded. `git bundle` takes refs, not shas, so the head goes under a ref of this run's own."""
93
+ # With none of origin's branches, nothing would be left out, and a one-commit review would upload the whole history.
94
+ if not git(top, "for-each-ref", "--count=1", "refs/remotes/origin").strip():
95
+ raise LocalError("this checkout has none of origin's branches, so the whole history would be uploaded. Run "
96
+ "`git fetch origin`, then run `rectifier review` again")
97
+ if git(top, "rev-list", "--count", head, "--not", "--remotes=origin").strip() == "0":
98
+ return b"" # the forge already has every commit
99
+ ref = f"{REFS}{os.getpid()}-{secrets.token_hex(4)}"
100
+ git(top, "update-ref", ref, head)
101
+ try:
102
+ with tempfile.TemporaryDirectory(prefix="rectifier-bundle-") as scratch:
103
+ path = Path(scratch) / "review.bundle"
104
+ made = subprocess.run(["git", "-C", str(top), "bundle", "create", "-q", str(path), ref, "--not",
105
+ "--remotes=origin"], capture_output=True, text=True, timeout=600)
106
+ if made.returncode != 0:
107
+ raise LocalError(f"git bundle failed: {made.stderr.strip()[:200]}")
108
+ return path.read_bytes()
109
+ finally:
110
+ git(top, "update-ref", "-d", ref)
111
+
112
+
113
+ def request_of(top: Path, selection: change.Selection, depth: str, post: bool,
114
+ git: change.Git = change._git, command: str = "") -> dict[str, Any]:
115
+ """What the service is asked: which commits, from where, and the commits it may not have."""
116
+ try:
117
+ upstream = git(top, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").strip()
118
+ except LocalError:
119
+ upstream = ""
120
+ # Where origin is first: a missing or non-forge origin is refused for that, before any advice about its branches.
121
+ forge, repo = change.forge_of(top, git), change.repo_name(top, git)
122
+ bundle = bundle_of(top, selection.head, git)
123
+ return {"forge": forge, "repo": repo, "scope": selection.scope,
124
+ "base": selection.base, "head": selection.head, "on": selection.on, "branch": selection.branch,
125
+ "against": selection.against, "upstream": upstream, "depth": depth, "post": post,
126
+ "cli_version": __version__, "command": command, "bundle": base64.b64encode(bundle).decode()}
127
+
128
+
129
+ def agreed(sending: change.Sending, a: argparse.Namespace, url: str, on: str,
130
+ ask: Callable[[str], str | None]) -> bool:
131
+ """Whether the user agreed to send these files: by a `--confirm` code that still names them, or by answering yes in
132
+ the terminal. Says on stderr what it lists, and what to do when it stops."""
133
+ if a.confirm and a.confirm == sending.code:
134
+ return True
135
+ print(files_sent(sending, url, on), file=sys.stderr)
136
+ if a.confirm:
137
+ print(f"\nThese files changed since code {a.confirm} was given.", file=sys.stderr)
138
+ elif (answer := ask("\nSend these files for review? [y/N] ")) is not None:
139
+ if answer.lower() in ("y", "yes"):
140
+ return True
141
+ print("Nothing was sent.", file=sys.stderr)
142
+ return False
143
+ # With the checkout it was run on: pasted where it was printed, it reviews the same one.
144
+ where = ["-C", a.repo] if a.repo != "." else []
145
+ again = shlex.join([*shlex.split(command_of(a)), *where, "--confirm", sending.code])
146
+ print(f"\nNothing was sent. To send exactly these files, run: {again}", file=sys.stderr)
147
+ return False
148
+
149
+
150
+ def command_of(a: argparse.Namespace) -> str:
151
+ """The command as a posted review names it, in its "Requested with" line: what it was asked to review and how,
152
+ without `-C` and `--json`, which only change what happens on this machine."""
153
+ words = ["rectifier", "review", *([a.target] if a.target else []), *(["--last", str(a.last)] if a.last else []),
154
+ *(["--uncommitted"] if a.uncommitted else []), *(["--base", a.base] if a.base else []),
155
+ *({"deep": ["--deep"], "light": ["--lite"]}.get(a.depth or "", [])), *(["--post"] if a.post else [])]
156
+ return shlex.join(words)
157
+
158
+
159
+ class Unreachable(LocalError):
160
+ """The service did not answer: no connection, a timeout, or a proxy in front of it answering for it. Asking again
161
+ later can work."""
162
+
163
+
164
+ class Gone(LocalError):
165
+ """The service knows no such review (404)."""
166
+
167
+
168
+ class TooLarge(LocalError):
169
+ """A proxy in front of the service refused the request's size (413)."""
170
+
171
+
172
+ AGAIN = (502, 503, 504)
173
+ """What a proxy in front of the service answers while the service restarts or is down: worth asking again."""
174
+
175
+
176
+ LOCAL = ("localhost", "127.0.0.1", "::1")
177
+ """The hosts `RECTIFIER_URL` may name over plain http: this machine, which the token never leaves."""
178
+
179
+
180
+ def checked_url(url: str) -> str:
181
+ """`RECTIFIER_URL`, refused unless it is https, or plain http to this machine (`LOCAL`), since every request carries
182
+ the token. A name that only starts like a local one, such as `localhost.example.com`, is not local."""
183
+ parsed = urlparse(url)
184
+ if parsed.scheme == "https" and parsed.hostname:
185
+ return url
186
+ if parsed.scheme == "http" and parsed.hostname in LOCAL:
187
+ return url
188
+ raise LocalError(f"RECTIFIER_URL is {url}, which is not https: the token would go unencrypted. Use an https "
189
+ f"address, or http only for a service on this machine (localhost or 127.0.0.1)")
190
+
191
+
192
+ class Reply:
193
+ """An HTTP answer, as `Service` reads one: its status, its body, and where a redirect pointed."""
194
+
195
+ def __init__(self, status_code: int, content: bytes, location: str = ""):
196
+ self.status_code, self.content, self.location = status_code, content, location
197
+
198
+ def json(self) -> Any:
199
+ return json.loads(self.content)
200
+
201
+
202
+ def _encoded(said: Any) -> bytes:
203
+ return json.dumps(said).encode()
204
+
205
+
206
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
207
+ """Follows no redirect. `urllib` would send the request's headers, the token among them, to whatever host a redirect
208
+ names; the service never redirects, so a redirect is answered as it came (`Service._call`)."""
209
+
210
+ def redirect_request(self, *args: Any, **kw: Any) -> None:
211
+ return None
212
+
213
+
214
+ _OPENER = urllib.request.build_opener(_NoRedirect)
215
+
216
+ _LOCAL_OPENER = urllib.request.build_opener(_NoRedirect, urllib.request.ProxyHandler({}))
217
+ """For a service on this machine (`LOCAL`), no proxy either: plain http is allowed there only because the request does
218
+ not leave the machine, and a proxy that `http_proxy` names would take it, token and upload, elsewhere unencrypted."""
219
+
220
+
221
+ class Http:
222
+ """HTTP from the standard library, so the command installs with nothing beside it. An answer that is not 2xx is a
223
+ `Reply` like any other: what it says is the service's reason, or a proxy's page. A redirect is not followed."""
224
+
225
+ def request(self, method: str, url: str, headers: dict[str, str] | None = None, timeout: float = 300,
226
+ json: Any = None) -> Reply:
227
+ body = None if json is None else _encoded(json)
228
+ sent = urllib.request.Request(url, data=body, method=method,
229
+ headers={**(headers or {}), "user-agent": f"imbik-rectifier/{__version__}",
230
+ **({"content-type": "application/json"} if body is not None else {})})
231
+ try:
232
+ opener = _LOCAL_OPENER if urlparse(url).hostname in LOCAL else _OPENER
233
+ with opener.open(sent, timeout=timeout) as answer:
234
+ return Reply(answer.status, answer.read())
235
+ except urllib.error.HTTPError as answer:
236
+ return Reply(answer.code, answer.read(), answer.headers.get("location") or "")
237
+
238
+
239
+ class Service:
240
+ """The reviewer service's CLI endpoints, with the user's token."""
241
+
242
+ def __init__(self, url: str, token: str, http: Any = None):
243
+ self.url, self.token, self.http = url.rstrip("/"), token, http or Http()
244
+
245
+ def _call(self, method: str, path: str, **kw: Any) -> dict[str, Any]:
246
+ try:
247
+ reply = self.http.request(method, self.url + path, headers={"authorization": f"Bearer {self.token}"},
248
+ timeout=300, **kw)
249
+ except Exception as trouble: # noqa: BLE001 the network, said in the command's words
250
+ raise Unreachable(f"cannot reach the reviewer service at {self.url}: {trouble}") from trouble
251
+ try:
252
+ said = reply.json() if reply.content else {}
253
+ except ValueError:
254
+ # Not the service's own answer: a proxy in front of it refused the request (a bundle too large, say)
255
+ # or found the service down, and answered with a page of its own.
256
+ said = {}
257
+ if reply.status_code == 401:
258
+ raise LocalError(f"RECTIFIER_CLI_TOKEN was refused ({said.get('error')}). Make a new token on the dashboard's "
259
+ f"CLI tokens page")
260
+ if reply.status_code == 404:
261
+ raise Gone(said.get("error") or "the reviewer service knows no such review")
262
+ if reply.status_code == 413:
263
+ raise TooLarge("the request is larger than the reviewer service takes")
264
+ if 300 <= reply.status_code < 400:
265
+ to = getattr(reply, "location", "") or "another address"
266
+ raise LocalError(f"the reviewer service at {self.url} answered {reply.status_code}, a redirect to {to}. "
267
+ f"rectifier review does not follow redirects, so the token was not sent there. Check "
268
+ f"RECTIFIER_URL")
269
+ if reply.status_code in AGAIN or (reply.status_code != 200 and not said):
270
+ why = f": {said['error']}" if said.get("error") else ""
271
+ raise Unreachable(f"the reviewer service at {self.url} answered {reply.status_code}{why}")
272
+ if reply.status_code != 200:
273
+ raise LocalError(said.get("error") or f"the reviewer service answered {reply.status_code}")
274
+ if not said:
275
+ raise LocalError("the reviewer service's answer was not one it gives")
276
+ return said
277
+
278
+ def submit(self, request: dict[str, Any]) -> dict[str, Any]:
279
+ """Never asked twice: with `--uncommitted`, a second ask is a second review."""
280
+ try:
281
+ return self._call("POST", PATH, json=request)
282
+ except TooLarge:
283
+ size = len(request.get("bundle") or "") * 3 / 4 / 1e6 # base64 is 4 characters for every 3 bytes
284
+ raise LocalError(f"the upload ({size:.0f} MB of commits the forge does not have) is larger than the "
285
+ f"reviewer service takes. Push the commits, then run the command again: commits the "
286
+ f"forge has are not uploaded") from None
287
+
288
+ def status(self, job: str) -> dict[str, Any]:
289
+ return self._call("GET", f"{PATH}/{job}")
290
+
291
+
292
+ class LostContact(LocalError):
293
+ """The wait stopped: it could not reach the service for `RETRY_FOR` seconds, or the review had not ended after
294
+ `WAIT_FOR`. The review goes on."""
295
+
296
+
297
+ def wait(service: Service, job: str, sleep: Callable[[float], None] = time.sleep) -> dict[str, Any]:
298
+ """Ask until the review has ended, saying on stderr where it is when that changes. Returns its result.
299
+
300
+ A service it cannot reach is asked again, at growing gaps, for up to `RETRY_FOR` seconds: a laptop that wakes
301
+ takes a moment to reconnect, and a deploy restarts the service. Asking again cannot change a refused token, so that
302
+ ends the wait at once. A review the service no longer knows ended more than an hour ago, while this command was
303
+ stopped or asleep, and its result is gone."""
304
+ said_last, lost, waited = "", 0.0, 0.0
305
+ while True:
306
+ if waited >= WAIT_FOR:
307
+ raise LostContact(f"review {job} has not ended after {WAIT_FOR / 3600:.0f} hours")
308
+ try:
309
+ said = service.status(job)
310
+ except Unreachable as trouble:
311
+ if lost >= RETRY_FOR:
312
+ raise LostContact(str(trouble)) from trouble
313
+ if not lost:
314
+ print(f"lost contact with the reviewer service; trying again for up to {RETRY_FOR / 60:.0f} minutes "
315
+ f"(review {job} goes on)", file=sys.stderr, flush=True)
316
+ gap = min(max(POLL, lost), RETRY_FOR - lost)
317
+ sleep(gap)
318
+ lost, waited = lost + gap, waited + gap
319
+ continue
320
+ except Gone:
321
+ raise LocalError(f"review {job} ended more than an hour ago, and its result is deleted; run the command "
322
+ f"again for a new review") from None
323
+ if lost:
324
+ print("reached the reviewer service again", file=sys.stderr, flush=True)
325
+ lost = 0.0
326
+ if said["state"] in ("done", "failed"):
327
+ return said["result"]
328
+ where = (f"waiting, {said.get('position', 0)} ahead" if said["state"] == "pending" and said.get("position")
329
+ else "waiting" if said["state"] == "pending" else "running")
330
+ if where != said_last:
331
+ print(f"review {job}: {where}", file=sys.stderr, flush=True)
332
+ said_last = where
333
+ sleep(POLL)
334
+ waited += POLL
335
+
336
+
337
+ NEEDS_OK = 3
338
+ """The exit code when `--uncommitted` stopped for the user to agree to the files it sends."""
339
+
340
+
341
+ def ask_in_terminal(question: str) -> str | None:
342
+ """The user's answer, read where they typed the command; None when it was not typed in a terminal."""
343
+ if not sys.stdin.isatty():
344
+ return None
345
+ print(question, end="", file=sys.stderr, flush=True)
346
+ return sys.stdin.readline().strip()
347
+
348
+
349
+ def files_sent(sending: change.Sending, url: str, on: str) -> str:
350
+ """The files `--uncommitted` sends, and where, as the user sees them before agreeing."""
351
+ out = [f"rectifier review --uncommitted sends these files to rectifier's review service at {url},",
352
+ f"on top of commit {on[:7]}, to be reviewed. The service deletes them after the review ends."]
353
+ if sending.tracked:
354
+ out += ["", f"Tracked files with changes ({len(sending.tracked)}):",
355
+ *(f" {kind:<9} {path}" for kind, path in sending.tracked)]
356
+ if sending.untracked:
357
+ out += ["", f"Untracked files ({len(sending.untracked)}):", *(f" {path}" for path in sending.untracked)]
358
+ return report.visible("\n".join(out))
359
+
360
+
361
+ class PipeSafe:
362
+ """A stream that takes no more writes once its reader has gone, as with `| head` or `2>&1 | head`, so a closed pipe
363
+ never ends the command: the report is saved and the exit code is the review's all the same."""
364
+
365
+ def __init__(self, stream: Any):
366
+ self.stream, self.gone = stream, False
367
+
368
+ def write(self, text: str) -> int:
369
+ if not self.gone:
370
+ try:
371
+ return self.stream.write(text)
372
+ except BrokenPipeError:
373
+ self._gone()
374
+ return len(text)
375
+
376
+ def flush(self) -> None:
377
+ if not self.gone:
378
+ try:
379
+ self.stream.flush()
380
+ except BrokenPipeError:
381
+ self._gone()
382
+
383
+ def _gone(self) -> None:
384
+ """Point the stream's descriptor at nowhere, so what is left in its buffer, flushed when Python exits, fails
385
+ neither."""
386
+ self.gone = True
387
+ try:
388
+ nowhere = os.open(os.devnull, os.O_WRONLY)
389
+ except OSError:
390
+ return
391
+ try:
392
+ os.dup2(nowhere, self.stream.fileno())
393
+ except (OSError, ValueError, AttributeError):
394
+ pass
395
+ finally:
396
+ os.close(nowhere)
397
+
398
+ def __getattr__(self, name: str) -> Any:
399
+ return getattr(self.stream, name)
400
+
401
+
402
+ def main(argv: list[str] | None = None, http: Any = None, sleep: Callable[[float], None] = time.sleep,
403
+ git: change.Git = change._git, ask: Callable[[str], str | None] = ask_in_terminal) -> int:
404
+ out, err = sys.stdout, sys.stderr
405
+ sys.stdout, sys.stderr = PipeSafe(out), PipeSafe(err)
406
+ try:
407
+ return _main(argv, http, sleep, git, ask)
408
+ except Exception as error: # noqa: BLE001 exit 1 means a blocking finding, so nothing else may end in it
409
+ print(f"rectifier review: {type(error).__name__}: {error}", file=sys.stderr)
410
+ return 2
411
+ finally:
412
+ sys.stdout, sys.stderr = out, err
413
+
414
+
415
+ def _main(argv: list[str] | None, http: Any, sleep: Callable[[float], None], git: change.Git,
416
+ ask: Callable[[str], str | None]) -> int:
417
+ ap = argparse.ArgumentParser(prog="rectifier review", description=HELP,
418
+ formatter_class=argparse.RawDescriptionHelpFormatter)
419
+ # One of the first group picks what is reviewed, and `change.select` refuses two; the second group goes with any.
420
+ pick = ap.add_argument_group("what to review (pick one; with none, the whole branch)")
421
+ pick.add_argument("target", nargs="?", metavar="COMMITS", help="one commit (abc123) or a range (main..feature)")
422
+ pick.add_argument("--last", type=int, metavar="N", help="the last N commits on this branch, as one change")
423
+ pick.add_argument("--uncommitted", action="store_true", help="the uncommitted changes, untracked files included")
424
+ pick.add_argument("--base", metavar="REF", help="with no choice or --uncommitted: compare against REF instead of "
425
+ "the default branch")
426
+ how = ap.add_argument_group("how to review")
427
+ depth = how.add_mutually_exclusive_group()
428
+ depth.add_argument("--deep", dest="depth", action="store_const", const="deep", help="a deep review")
429
+ depth.add_argument("--lite", dest="depth", action="store_const", const="light", help="a lite review")
430
+ how.add_argument("--post", action="store_true", help="post the review on the branch's open pull or merge request")
431
+ how.add_argument("--json", action="store_true", help="print the JSON instead of the markdown")
432
+ how.add_argument("-C", dest="repo", default=".", help="the checkout to review; default is this directory")
433
+ how.add_argument("--confirm", metavar="CODE", help="with --uncommitted, send the files an earlier run listed under "
434
+ "this code, without asking")
435
+ a = ap.parse_args(argv)
436
+ token = os.environ.get("RECTIFIER_CLI_TOKEN", "")
437
+ if not token:
438
+ print("rectifier review: set RECTIFIER_CLI_TOKEN to a token from the dashboard's CLI tokens page", file=sys.stderr)
439
+ return 2
440
+ if a.post and a.uncommitted:
441
+ print("rectifier review: uncommitted changes are never posted; commit them, or leave out --post",
442
+ file=sys.stderr)
443
+ return 2
444
+ if a.confirm and not a.uncommitted:
445
+ print("rectifier review: --confirm goes with --uncommitted: it sends the uncommitted changes an earlier run "
446
+ "listed", file=sys.stderr)
447
+ return 2
448
+ try:
449
+ url = checked_url(os.environ.get("RECTIFIER_URL") or DEFAULT_URL)
450
+ except LocalError as error:
451
+ print(f"rectifier review: {error}", file=sys.stderr)
452
+ return 2
453
+ service = Service(url, token, http)
454
+ job = ""
455
+ again = "running the command again starts a new review" if a.uncommitted else "the same command picks it up"
456
+ try:
457
+ top = Path(git(Path(a.repo), "rev-parse", "--show-toplevel").strip())
458
+ selection = change.select(top, a.target, a.last, a.base, a.uncommitted, git)
459
+ if not a.uncommitted and change.uncommitted_left_out(top, git):
460
+ print("note: uncommitted changes are not reviewed; --uncommitted reviews them", file=sys.stderr)
461
+ for note in selection.notes:
462
+ print(f"note: {note}", file=sys.stderr)
463
+ if a.uncommitted and not agreed(change.sending_of(top, selection, git), a, service.url, selection.on, ask):
464
+ return NEEDS_OK
465
+ request = request_of(top, selection, a.depth or "", a.post, git, command_of(a))
466
+ size = change.size_of(top, selection, git)
467
+ print(f"sending {size.under_review} to {service.url}", file=sys.stderr, flush=True)
468
+ job = service.submit(request)["job"]
469
+ result = wait(service, job, sleep)
470
+ except LostContact as error:
471
+ print(f"rectifier review: {error}. Stopped waiting: review {job} goes on, and {again}", file=sys.stderr)
472
+ return 2
473
+ except LocalError as error:
474
+ print(f"rectifier review: {error}", file=sys.stderr)
475
+ return 2
476
+ except KeyboardInterrupt:
477
+ if job:
478
+ print(f"\nstopped waiting; review {job} goes on, and {again}", file=sys.stderr)
479
+ return 130
480
+ # Whether it was posted, said after the report, as the last thing the user reads. A review asked to be posted
481
+ # that was not is a warning.
482
+ told = []
483
+ if result.get("note"):
484
+ told.append(f"WARNING: review {result['note']}" if a.post else f"note: {result['note']}")
485
+ n = result.get("number")
486
+ named = f"merge request !{n}" if request["forge"] == "gitlab" else f"pull request #{n}"
487
+ if result.get("posted"):
488
+ told.append(f"posted on {named}")
489
+ elif a.post and not result.get("note") and result.get("state") == "done":
490
+ # Asked as it was ending, after the service had decided not to post: asked again, it posts this review.
491
+ told.append(f"WARNING: review not posted: --post came as the review was ending. To post it, run "
492
+ f"`{command_of(a)}` again")
493
+ elif n and not a.post and not selection.on and result.get("state") == "done":
494
+ # Asked again with --post, the service posts this review rather than running another.
495
+ told.append(f"note: not posted: to post this review on {named}, run the same command with --post within an "
496
+ f"hour")
497
+ produced = result.get("produced") or {"findings": [], "meta": {}}
498
+ if result.get("state") == "failed":
499
+ # Whatever the review made, it did not end as one (a post that failed, say): the report and the exit code say so.
500
+ produced.setdefault("meta", {})["error"] = result.get("error") or "the review did not complete"
501
+ produced["review"] = {"under_review": size.under_review, "base": selection.base,
502
+ "head": selection.on or selection.head, "uncommitted": bool(selection.on),
503
+ "repo": request["repo"], "job": job, "depth": produced.get("depth") or "",
504
+ "number": result.get("number"), "files_changed": size.files_changed, "added": size.added,
505
+ "deleted": size.deleted, "environment_line": result.get("environment_line") or ""}
506
+ text = report.report(produced)
507
+ before = report.kept(top, text, produced)
508
+ saved = before or report.save(top, text, produced)
509
+ print(json.dumps(produced, indent=1) if a.json else text, flush=True)
510
+ print(f"{'already saved' if before else 'saved'}: {saved.relative_to(top)} and .json", file=sys.stderr)
511
+ for line in told:
512
+ print(line, file=sys.stderr)
513
+ return report.exit_code(produced)
514
+
515
+
516
+ if __name__ == "__main__":
517
+ raise SystemExit(main())