diffjudge 0.1.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.
- diffjudge/__init__.py +5 -0
- diffjudge/__main__.py +4 -0
- diffjudge/cli.py +117 -0
- diffjudge/github.py +97 -0
- diffjudge/llm.py +67 -0
- diffjudge/prompts.py +54 -0
- diffjudge/review.py +50 -0
- diffjudge-0.1.0.dist-info/METADATA +142 -0
- diffjudge-0.1.0.dist-info/RECORD +12 -0
- diffjudge-0.1.0.dist-info/WHEEL +4 -0
- diffjudge-0.1.0.dist-info/entry_points.txt +2 -0
- diffjudge-0.1.0.dist-info/licenses/LICENSE +21 -0
diffjudge/__init__.py
ADDED
diffjudge/__main__.py
ADDED
diffjudge/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Command-line interface for diffjudge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from . import __version__, github
|
|
11
|
+
from .llm import LLMError
|
|
12
|
+
from .github import GitHubError
|
|
13
|
+
from .review import DEFAULT_MAX_DIFF_BYTES, format_comment, review_pr
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="diffjudge",
|
|
19
|
+
description="AI-powered pull request reviewer.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--repo",
|
|
23
|
+
required=True,
|
|
24
|
+
metavar="OWNER/NAME",
|
|
25
|
+
help="GitHub repository slug, e.g. octocat/hello-world.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--pr",
|
|
29
|
+
required=True,
|
|
30
|
+
type=int,
|
|
31
|
+
metavar="N",
|
|
32
|
+
help="pull request number to review.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--model",
|
|
36
|
+
default=os.environ.get("DIFFJUDGE_MODEL", "gpt-4o-mini"),
|
|
37
|
+
metavar="NAME",
|
|
38
|
+
help="model name (default: gpt-4o-mini, or $DIFFJUDGE_MODEL).",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--base-url",
|
|
42
|
+
default=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
|
43
|
+
metavar="URL",
|
|
44
|
+
help="OpenAI-compatible base URL (default: $OPENAI_BASE_URL or OpenAI).",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--api-key",
|
|
48
|
+
default=os.environ.get("OPENAI_API_KEY"),
|
|
49
|
+
metavar="KEY",
|
|
50
|
+
help="LLM API key (default: $OPENAI_API_KEY).",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--github-token",
|
|
54
|
+
default=os.environ.get("GITHUB_TOKEN"),
|
|
55
|
+
metavar="TOKEN",
|
|
56
|
+
help="GitHub token for fetching the diff and posting (default: $GITHUB_TOKEN).",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--max-diff-bytes",
|
|
60
|
+
type=int,
|
|
61
|
+
default=DEFAULT_MAX_DIFF_BYTES,
|
|
62
|
+
metavar="N",
|
|
63
|
+
help="truncate diffs larger than this many bytes (default: {0}).".format(
|
|
64
|
+
DEFAULT_MAX_DIFF_BYTES
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--post",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="post the review as a PR comment instead of printing it.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--version",
|
|
74
|
+
action="version",
|
|
75
|
+
version="%(prog)s {0}".format(__version__),
|
|
76
|
+
)
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
81
|
+
parser = build_parser()
|
|
82
|
+
args = parser.parse_args(argv)
|
|
83
|
+
|
|
84
|
+
if not args.api_key:
|
|
85
|
+
parser.exit(2, "diffjudge: no API key (set $OPENAI_API_KEY or --api-key)\n")
|
|
86
|
+
if args.post and not args.github_token:
|
|
87
|
+
parser.exit(2, "diffjudge: --post needs a GitHub token (set $GITHUB_TOKEN)\n")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
review = review_pr(
|
|
91
|
+
args.repo,
|
|
92
|
+
args.pr,
|
|
93
|
+
model=args.model,
|
|
94
|
+
api_key=args.api_key,
|
|
95
|
+
base_url=args.base_url,
|
|
96
|
+
github_token=args.github_token,
|
|
97
|
+
max_diff_bytes=args.max_diff_bytes,
|
|
98
|
+
)
|
|
99
|
+
except (LLMError, GitHubError) as exc:
|
|
100
|
+
parser.exit(1, "diffjudge: {0}\n".format(exc))
|
|
101
|
+
|
|
102
|
+
if args.post:
|
|
103
|
+
try:
|
|
104
|
+
url = github.post_comment(
|
|
105
|
+
args.repo, args.pr, format_comment(review), token=args.github_token
|
|
106
|
+
)
|
|
107
|
+
except GitHubError as exc:
|
|
108
|
+
parser.exit(1, "diffjudge: {0}\n".format(exc))
|
|
109
|
+
sys.stderr.write("diffjudge: posted review -> {0}\n".format(url))
|
|
110
|
+
else:
|
|
111
|
+
sys.stdout.write(review.rstrip() + "\n")
|
|
112
|
+
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
raise SystemExit(main())
|
diffjudge/github.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Tiny GitHub REST client (stdlib only): fetch a PR diff and post a comment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
API_ROOT = "https://api.github.com"
|
|
11
|
+
Opener = Callable[..., Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitHubError(RuntimeError):
|
|
15
|
+
"""Raised when a GitHub API request fails."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pr_url(repo: str, pr: int) -> str:
|
|
19
|
+
return "{0}/repos/{1}/pulls/{2}".format(API_ROOT, repo, pr)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def comments_url(repo: str, pr: int) -> str:
|
|
23
|
+
return "{0}/repos/{1}/issues/{2}/comments".format(API_ROOT, repo, pr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _request(
|
|
27
|
+
url: str,
|
|
28
|
+
*,
|
|
29
|
+
token: Optional[str] = None,
|
|
30
|
+
method: str = "GET",
|
|
31
|
+
accept: str = "application/vnd.github+json",
|
|
32
|
+
data: Optional[bytes] = None,
|
|
33
|
+
opener: Optional[Opener] = None,
|
|
34
|
+
timeout: int = 60,
|
|
35
|
+
) -> bytes:
|
|
36
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
37
|
+
req.add_header("Accept", accept)
|
|
38
|
+
req.add_header("User-Agent", "diffjudge")
|
|
39
|
+
req.add_header("X-GitHub-Api-Version", "2022-11-28")
|
|
40
|
+
if token:
|
|
41
|
+
req.add_header("Authorization", "Bearer " + token)
|
|
42
|
+
if data is not None:
|
|
43
|
+
req.add_header("Content-Type", "application/json")
|
|
44
|
+
|
|
45
|
+
do_open = opener or urllib.request.urlopen
|
|
46
|
+
try:
|
|
47
|
+
with do_open(req, timeout=timeout) as resp:
|
|
48
|
+
return resp.read()
|
|
49
|
+
except urllib.error.HTTPError as exc:
|
|
50
|
+
body = exc.read().decode("utf-8", "replace")
|
|
51
|
+
raise GitHubError(
|
|
52
|
+
"GitHub API error (HTTP {0}): {1}".format(exc.code, body[:300])
|
|
53
|
+
)
|
|
54
|
+
except urllib.error.URLError as exc:
|
|
55
|
+
raise GitHubError("GitHub request failed: {0}".format(exc.reason))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_pr_diff(
|
|
59
|
+
repo: str, pr: int, *, token: Optional[str] = None, opener: Optional[Opener] = None
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Return the unified diff for a pull request."""
|
|
62
|
+
raw = _request(
|
|
63
|
+
pr_url(repo, pr),
|
|
64
|
+
token=token,
|
|
65
|
+
accept="application/vnd.github.v3.diff",
|
|
66
|
+
opener=opener,
|
|
67
|
+
)
|
|
68
|
+
return raw.decode("utf-8", "replace")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_pr_title(
|
|
72
|
+
repo: str, pr: int, *, token: Optional[str] = None, opener: Optional[Opener] = None
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Return the pull request title (best effort, empty string on absence)."""
|
|
75
|
+
raw = _request(pr_url(repo, pr), token=token, opener=opener)
|
|
76
|
+
data = json.loads(raw.decode("utf-8"))
|
|
77
|
+
return data.get("title", "") or ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def post_comment(
|
|
81
|
+
repo: str,
|
|
82
|
+
pr: int,
|
|
83
|
+
body: str,
|
|
84
|
+
*,
|
|
85
|
+
token: str,
|
|
86
|
+
opener: Optional[Opener] = None,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Post a comment on a pull request, returning the created comment URL."""
|
|
89
|
+
payload = json.dumps({"body": body}).encode("utf-8")
|
|
90
|
+
raw = _request(
|
|
91
|
+
comments_url(repo, pr),
|
|
92
|
+
token=token,
|
|
93
|
+
method="POST",
|
|
94
|
+
data=payload,
|
|
95
|
+
opener=opener,
|
|
96
|
+
)
|
|
97
|
+
return json.loads(raw.decode("utf-8")).get("html_url", "")
|
diffjudge/llm.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Minimal client for OpenAI-compatible chat-completion APIs, using only stdlib.
|
|
2
|
+
|
|
3
|
+
Works against the OpenAI API, OpenRouter, a local llama.cpp server, or anything
|
|
4
|
+
else exposing a ``/chat/completions`` endpoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
Message = Dict[str, str]
|
|
15
|
+
Opener = Callable[..., Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LLMError(RuntimeError):
|
|
19
|
+
"""Raised when the LLM request fails or returns an unexpected shape."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_message(data: Dict[str, Any]) -> str:
|
|
23
|
+
"""Pull the assistant message text out of a chat-completions response."""
|
|
24
|
+
try:
|
|
25
|
+
content = data["choices"][0]["message"]["content"]
|
|
26
|
+
except (KeyError, IndexError, TypeError):
|
|
27
|
+
raise LLMError(
|
|
28
|
+
"unexpected LLM response shape: {0}".format(json.dumps(data)[:300])
|
|
29
|
+
)
|
|
30
|
+
if not isinstance(content, str):
|
|
31
|
+
raise LLMError("LLM returned non-text content")
|
|
32
|
+
return content.strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def chat_completion(
|
|
36
|
+
messages: List[Message],
|
|
37
|
+
*,
|
|
38
|
+
model: str,
|
|
39
|
+
api_key: str,
|
|
40
|
+
base_url: str = "https://api.openai.com/v1",
|
|
41
|
+
temperature: float = 0.2,
|
|
42
|
+
timeout: int = 120,
|
|
43
|
+
opener: Optional[Opener] = None,
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Call an OpenAI-compatible chat-completions endpoint and return the text."""
|
|
46
|
+
url = base_url.rstrip("/") + "/chat/completions"
|
|
47
|
+
payload = json.dumps(
|
|
48
|
+
{"model": model, "messages": messages, "temperature": temperature}
|
|
49
|
+
).encode("utf-8")
|
|
50
|
+
|
|
51
|
+
req = urllib.request.Request(url, data=payload, method="POST")
|
|
52
|
+
req.add_header("Content-Type", "application/json")
|
|
53
|
+
req.add_header("Authorization", "Bearer " + api_key)
|
|
54
|
+
|
|
55
|
+
do_open = opener or urllib.request.urlopen
|
|
56
|
+
try:
|
|
57
|
+
with do_open(req, timeout=timeout) as resp:
|
|
58
|
+
raw = resp.read().decode("utf-8")
|
|
59
|
+
except urllib.error.HTTPError as exc:
|
|
60
|
+
body = exc.read().decode("utf-8", "replace")
|
|
61
|
+
raise LLMError(
|
|
62
|
+
"LLM request failed (HTTP {0}): {1}".format(exc.code, body[:500])
|
|
63
|
+
)
|
|
64
|
+
except urllib.error.URLError as exc:
|
|
65
|
+
raise LLMError("LLM request failed: {0}".format(exc.reason))
|
|
66
|
+
|
|
67
|
+
return extract_message(json.loads(raw))
|
diffjudge/prompts.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Prompt construction for the reviewer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
Message = Dict[str, str]
|
|
8
|
+
|
|
9
|
+
SYSTEM_PROMPT = (
|
|
10
|
+
"You are a meticulous senior software engineer reviewing a pull request. "
|
|
11
|
+
"Review the unified diff and give a focused, useful review.\n\n"
|
|
12
|
+
"Priorities, in order:\n"
|
|
13
|
+
"1. Correctness bugs and logic errors introduced by the change.\n"
|
|
14
|
+
"2. Security issues (injection, auth, secrets, unsafe input handling).\n"
|
|
15
|
+
"3. Missing edge cases, error handling, or tests for new behavior.\n"
|
|
16
|
+
"4. Clear, concrete improvements.\n\n"
|
|
17
|
+
"Rules:\n"
|
|
18
|
+
"- Be specific: reference file names and, where you can, the relevant lines.\n"
|
|
19
|
+
"- Do not nitpick formatting or style unless it affects correctness.\n"
|
|
20
|
+
"- Do not invent issues. If the change looks good, say so plainly.\n"
|
|
21
|
+
"- Keep it concise. No filler.\n\n"
|
|
22
|
+
"Respond in GitHub-flavored markdown with exactly these sections:\n"
|
|
23
|
+
"## Summary\n"
|
|
24
|
+
"A two or three sentence overview of what the PR does.\n\n"
|
|
25
|
+
"## Findings\n"
|
|
26
|
+
"A bullet list of concrete issues, each prefixed with a severity "
|
|
27
|
+
"(**blocker** / **major** / **minor** / **nit**). Write 'No significant "
|
|
28
|
+
"issues found.' if there are none.\n\n"
|
|
29
|
+
"## Suggestions\n"
|
|
30
|
+
"Optional improvements that are nice-to-have, or 'None.'"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_messages(
|
|
35
|
+
diff: str, *, title: Optional[str] = None, truncated: bool = False
|
|
36
|
+
) -> List[Message]:
|
|
37
|
+
"""Build the chat messages for reviewing a diff."""
|
|
38
|
+
header = "Pull request"
|
|
39
|
+
if title:
|
|
40
|
+
header += ': "{0}"'.format(title)
|
|
41
|
+
|
|
42
|
+
note = ""
|
|
43
|
+
if truncated:
|
|
44
|
+
note = (
|
|
45
|
+
"\n\nNote: the diff was truncated because it is large. Review what is "
|
|
46
|
+
"shown and say so if you cannot assess the whole change."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
user = "{0}\n\nUnified diff:\n\n```diff\n{1}\n```{2}".format(header, diff, note)
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
53
|
+
{"role": "user", "content": user},
|
|
54
|
+
]
|
diffjudge/review.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Orchestration: fetch a PR diff, ask the model, return the review markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from . import github, llm, prompts
|
|
8
|
+
|
|
9
|
+
DEFAULT_MAX_DIFF_BYTES = 60_000
|
|
10
|
+
COMMENT_HEADER = "## diffjudge review\n\n"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def truncate_diff(diff: str, max_bytes: int) -> Tuple[str, bool]:
|
|
14
|
+
"""Truncate a diff to at most ``max_bytes`` UTF-8 bytes.
|
|
15
|
+
|
|
16
|
+
Returns the (possibly shortened) text and whether truncation happened.
|
|
17
|
+
"""
|
|
18
|
+
encoded = diff.encode("utf-8")
|
|
19
|
+
if len(encoded) <= max_bytes:
|
|
20
|
+
return diff, False
|
|
21
|
+
clipped = encoded[:max_bytes].decode("utf-8", "ignore")
|
|
22
|
+
return clipped, True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def review_pr(
|
|
26
|
+
repo: str,
|
|
27
|
+
pr: int,
|
|
28
|
+
*,
|
|
29
|
+
model: str,
|
|
30
|
+
api_key: str,
|
|
31
|
+
base_url: str = "https://api.openai.com/v1",
|
|
32
|
+
github_token: Optional[str] = None,
|
|
33
|
+
max_diff_bytes: int = DEFAULT_MAX_DIFF_BYTES,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Produce a markdown review for a pull request."""
|
|
36
|
+
diff = github.get_pr_diff(repo, pr, token=github_token)
|
|
37
|
+
if not diff.strip():
|
|
38
|
+
return "_diffjudge: this pull request has an empty diff, nothing to review._"
|
|
39
|
+
|
|
40
|
+
title = github.get_pr_title(repo, pr, token=github_token)
|
|
41
|
+
text, truncated = truncate_diff(diff, max_diff_bytes)
|
|
42
|
+
messages = prompts.build_messages(text, title=title, truncated=truncated)
|
|
43
|
+
return llm.chat_completion(
|
|
44
|
+
messages, model=model, api_key=api_key, base_url=base_url
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_comment(review: str) -> str:
|
|
49
|
+
"""Wrap a review in the comment header used when posting to a PR."""
|
|
50
|
+
return COMMENT_HEADER + review.strip() + "\n"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diffjudge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered pull request reviewer. Reviews diffs with any OpenAI-compatible model and posts the review. Zero dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Sev7nOfNine/diffjudge
|
|
6
|
+
Project-URL: Repository, https://github.com/Sev7nOfNine/diffjudge
|
|
7
|
+
Project-URL: Issues, https://github.com/Sev7nOfNine/diffjudge/issues
|
|
8
|
+
Author: Seven Of Nine
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,automation,cli,code-review,github,github-action,llm,maintainer,openai,pull-request
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# diffjudge
|
|
28
|
+
|
|
29
|
+
**AI-powered pull request reviewer — with zero dependencies.**
|
|
30
|
+
|
|
31
|
+
`diffjudge` fetches a pull request's diff, asks a language model to review it, and
|
|
32
|
+
either prints the review or posts it back as a PR comment. It works with any
|
|
33
|
+
OpenAI-compatible API: the OpenAI platform, OpenRouter, or a local
|
|
34
|
+
`llama.cpp` / Ollama server. The whole thing is built on the Python standard
|
|
35
|
+
library — no `requests`, no SDKs, nothing to audit but a few hundred lines.
|
|
36
|
+
|
|
37
|
+
Use it as a CLI for ad-hoc reviews, or drop the bundled GitHub Action into a
|
|
38
|
+
workflow to get an automatic review on every pull request.
|
|
39
|
+
|
|
40
|
+
## What a review looks like
|
|
41
|
+
|
|
42
|
+
```markdown
|
|
43
|
+
## diffjudge review
|
|
44
|
+
|
|
45
|
+
## Summary
|
|
46
|
+
Adds retry handling to the upload client and a backoff helper.
|
|
47
|
+
|
|
48
|
+
## Findings
|
|
49
|
+
- **major** `client.py`: the retry loop never breaks on a 4xx response, so a bad
|
|
50
|
+
request is retried 5 times before failing.
|
|
51
|
+
- **minor** `client.py`: `backoff()` ignores the `jitter` argument.
|
|
52
|
+
|
|
53
|
+
## Suggestions
|
|
54
|
+
- Consider adding a test for the 4xx-no-retry path.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install diffjudge
|
|
61
|
+
# or, for an isolated CLI:
|
|
62
|
+
pipx install diffjudge
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Requires Python 3.8+.
|
|
66
|
+
|
|
67
|
+
## CLI usage
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
export OPENAI_API_KEY=sk-...
|
|
71
|
+
export GITHUB_TOKEN=ghp_... # only needed to fetch private diffs or to --post
|
|
72
|
+
|
|
73
|
+
# Print a review to your terminal
|
|
74
|
+
diffjudge --repo octocat/hello-world --pr 42
|
|
75
|
+
|
|
76
|
+
# Post the review as a comment on the PR
|
|
77
|
+
diffjudge --repo octocat/hello-world --pr 42 --post
|
|
78
|
+
|
|
79
|
+
# Use a different model or provider
|
|
80
|
+
diffjudge --repo octocat/hello-world --pr 42 --model gpt-4o
|
|
81
|
+
diffjudge --repo me/proj --pr 7 --base-url http://localhost:8080/v1 --model local-model
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Options
|
|
85
|
+
|
|
86
|
+
| Flag | Description |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `--repo OWNER/NAME` | Repository to review (required). |
|
|
89
|
+
| `--pr N` | Pull request number (required). |
|
|
90
|
+
| `--model NAME` | Model name (default `gpt-4o-mini`, or `$DIFFJUDGE_MODEL`). |
|
|
91
|
+
| `--base-url URL` | OpenAI-compatible base URL (default `$OPENAI_BASE_URL` or OpenAI). |
|
|
92
|
+
| `--api-key KEY` | LLM API key (default `$OPENAI_API_KEY`). |
|
|
93
|
+
| `--github-token TOKEN` | GitHub token (default `$GITHUB_TOKEN`). |
|
|
94
|
+
| `--max-diff-bytes N` | Truncate diffs larger than this (default 60000). |
|
|
95
|
+
| `--post` | Post the review as a PR comment instead of printing it. |
|
|
96
|
+
|
|
97
|
+
## GitHub Action
|
|
98
|
+
|
|
99
|
+
Add a workflow that reviews every pull request. Store your provider key as a
|
|
100
|
+
repository secret (e.g. `OPENAI_API_KEY`):
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
name: diffjudge
|
|
104
|
+
on:
|
|
105
|
+
pull_request:
|
|
106
|
+
|
|
107
|
+
permissions:
|
|
108
|
+
contents: read
|
|
109
|
+
pull-requests: write
|
|
110
|
+
|
|
111
|
+
jobs:
|
|
112
|
+
review:
|
|
113
|
+
runs-on: ubuntu-latest
|
|
114
|
+
steps:
|
|
115
|
+
- uses: Sev7nOfNine/diffjudge@v0.1.0
|
|
116
|
+
with:
|
|
117
|
+
api-key: ${{ secrets.OPENAI_API_KEY }}
|
|
118
|
+
model: gpt-4o-mini
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Point `base-url` at any OpenAI-compatible endpoint to use a different provider or
|
|
122
|
+
a self-hosted model.
|
|
123
|
+
|
|
124
|
+
## Privacy note
|
|
125
|
+
|
|
126
|
+
`diffjudge` sends the pull request diff to whatever provider you configure. Only
|
|
127
|
+
point it at providers you trust with your code, and prefer a self-hosted model
|
|
128
|
+
(`--base-url`) for private repositories where that matters.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pip install -e ".[test]"
|
|
134
|
+
python -m pytest
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The HTTP layers accept an injectable opener, so the whole suite runs offline
|
|
138
|
+
with no real API calls.
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
diffjudge/__init__.py,sha256=jLI64M6OleZILOtN76jMhuqSnO7n50REF8CiA9v1OCI,104
|
|
2
|
+
diffjudge/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
3
|
+
diffjudge/cli.py,sha256=b9bjNjl9__OdLwuD8XGh-m6z20JApMKaD1Ybq_-Bv4Y,3437
|
|
4
|
+
diffjudge/github.py,sha256=ai075cduqajw2TusVn6-48gqLxd1oi2H21HcYHg0Hp0,2848
|
|
5
|
+
diffjudge/llm.py,sha256=3cqvwLpp_3z7cCxtM0m09nT31nLS92PbQ_DQZy69DlY,2199
|
|
6
|
+
diffjudge/prompts.py,sha256=l372yd7Qc-SxLZPTH69x7EDm6ox-bRGSkIjb2a_arvc,2025
|
|
7
|
+
diffjudge/review.py,sha256=upUT1GD-c6CF2SG8WWQit55s7RqY9_-jNJbsj5I0xOA,1589
|
|
8
|
+
diffjudge-0.1.0.dist-info/METADATA,sha256=W-qWjQjj7BnuzGK8Z7PK3lYhTSbZ18B503wnHpWTqSg,4381
|
|
9
|
+
diffjudge-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
diffjudge-0.1.0.dist-info/entry_points.txt,sha256=7_Ji9ft4HAH4d0LbyFzyQoLeNd3ENob_Ag45H_lLodQ,49
|
|
11
|
+
diffjudge-0.1.0.dist-info/licenses/LICENSE,sha256=z_MXotUe6DP_vu70fPgxIdzHdFq5mHzyHp7p3j22NRA,1070
|
|
12
|
+
diffjudge-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seven Of Nine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|