ai-cr 0.4.8__tar.gz → 0.6.0__tar.gz
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.
- {ai_cr-0.4.8 → ai_cr-0.6.0}/PKG-INFO +12 -11
- {ai_cr-0.4.8 → ai_cr-0.6.0}/README.md +5 -3
- ai_cr-0.6.0/ai_code_review/__main__.py +4 -0
- {ai_cr-0.4.8 → ai_cr-0.6.0}/ai_code_review/bootstrap.py +5 -2
- ai_cr-0.6.0/ai_code_review/cli.py +236 -0
- {ai_cr-0.4.8 → ai_cr-0.6.0}/ai_code_review/constants.py +1 -1
- ai_cr-0.6.0/ai_code_review/core.py +191 -0
- {ai_cr-0.4.8 → ai_cr-0.6.0}/ai_code_review/utils.py +27 -0
- {ai_cr-0.4.8 → ai_cr-0.6.0}/pyproject.toml +8 -9
- ai_cr-0.4.8/ai_code_review/cli.py +0 -112
- ai_cr-0.4.8/ai_code_review/core.py +0 -114
- {ai_cr-0.4.8 → ai_cr-0.6.0}/LICENSE +0 -0
- {ai_cr-0.4.8 → ai_cr-0.6.0}/ai_code_review/.ai-code-review.toml +0 -0
- {ai_cr-0.4.8 → ai_cr-0.6.0}/ai_code_review/__init__.py +0 -0
- {ai_cr-0.4.8 → ai_cr-0.6.0}/ai_code_review/project_config.py +0 -0
- {ai_cr-0.4.8 → ai_cr-0.6.0}/ai_code_review/report_struct.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: ai-cr
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
4
4
|
Summary: LLM-agnostic GitHub AI Code Review Tool with integration to GitHub actions
|
5
5
|
License: MIT
|
6
6
|
Keywords: static code analysis,code review,code quality,ai,coding,assistant,llm,github,automation,devops,developer tools,github actions,workflows,git
|
@@ -15,13 +15,12 @@ Classifier: Programming Language :: Python :: 3.11
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
17
17
|
Classifier: Topic :: Software Development
|
18
|
-
Requires-Dist: GitPython (
|
19
|
-
Requires-Dist: ai-microcore (==4.0.0.
|
20
|
-
Requires-Dist: anthropic (
|
21
|
-
Requires-Dist:
|
22
|
-
Requires-Dist:
|
23
|
-
Requires-Dist:
|
24
|
-
Requires-Dist: unidiff (==0.7.5)
|
18
|
+
Requires-Dist: GitPython (>=3.1.44,<4.0.0)
|
19
|
+
Requires-Dist: ai-microcore (==4.0.0.dev19)
|
20
|
+
Requires-Dist: anthropic (>=0.52.2,<0.53.0)
|
21
|
+
Requires-Dist: google-generativeai (>=0.8.5,<0.9.0)
|
22
|
+
Requires-Dist: typer (>=0.16.0,<0.17.0)
|
23
|
+
Requires-Dist: unidiff (>=0.7.5,<0.8.0)
|
25
24
|
Project-URL: Homepage, https://github.com/Nayjest/github-ai-code-review
|
26
25
|
Project-URL: Repository, https://github.com/Nayjest/github-ai-code-review
|
27
26
|
Description-Content-Type: text/markdown
|
@@ -30,6 +29,7 @@ Description-Content-Type: text/markdown
|
|
30
29
|
<a href="https://pypi.org/project/ai-code-review/" target="_blank"><img src="https://badge.fury.io/py/ai-code-review.svg" alt="PYPI Release"></a>
|
31
30
|
<a href="https://github.com/Nayjest/ai-code-review/actions/workflows/code-style.yml" target="_blank"><img src="https://github.com/Nayjest/ai-code-review/actions/workflows/code-style.yml/badge.svg" alt="Pylint"></a>
|
32
31
|
<a href="https://github.com/Nayjest/ai-code-review/actions/workflows/tests.yml" target="_blank"><img src="https://github.com/Nayjest/ai-code-review/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
|
32
|
+
<img src="https://github.com/Nayjest/ai-code-review/blob/main/coverage.svg" alt="Code Coverage">
|
33
33
|
<a href="https://github.com/Nayjest/ai-code-review/blob/main/LICENSE" target="_blank"><img src="https://img.shields.io/static/v1?label=license&message=MIT&color=d08aff" alt="License"></a>
|
34
34
|
</p>
|
35
35
|
|
@@ -70,7 +70,7 @@ jobs:
|
|
70
70
|
uses: actions/setup-python@v5
|
71
71
|
with: { python-version: "3.13" }
|
72
72
|
- name: Install AI Code Review tool
|
73
|
-
run: pip install ai-code-review==0.
|
73
|
+
run: pip install ai-code-review==0.5.0
|
74
74
|
- name: Run AI code analysis
|
75
75
|
env:
|
76
76
|
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
@@ -91,7 +91,8 @@ jobs:
|
|
91
91
|
> ⚠️ Make sure to add `LLM_API_KEY` to your repository’s GitHub secrets.
|
92
92
|
|
93
93
|
💪 Done!
|
94
|
-
PRs to your repository will now receive AI code reviews automatically. ✨
|
94
|
+
PRs to your repository will now receive AI code reviews automatically. ✨
|
95
|
+
See [GitHub Setup Guide](https://github.com/Nayjest/ai-code-review/blob/main/documentation/github_setup.md) for more details.
|
95
96
|
|
96
97
|
### 2. Run Code Analysis Locally
|
97
98
|
|
@@ -151,7 +152,7 @@ pytest
|
|
151
152
|
|
152
153
|
## 🤝 Contributing
|
153
154
|
|
154
|
-
**Looking for a specific feature or having trouble?**
|
155
|
+
**Looking for a specific feature or having trouble?**
|
155
156
|
Contributions are welcome! ❤️
|
156
157
|
See [CONTRIBUTING.md](https://github.com/Nayjest/ai-code-review/blob/main/CONTRIBUTING.md) for details.
|
157
158
|
|
@@ -2,6 +2,7 @@
|
|
2
2
|
<a href="https://pypi.org/project/ai-code-review/" target="_blank"><img src="https://badge.fury.io/py/ai-code-review.svg" alt="PYPI Release"></a>
|
3
3
|
<a href="https://github.com/Nayjest/ai-code-review/actions/workflows/code-style.yml" target="_blank"><img src="https://github.com/Nayjest/ai-code-review/actions/workflows/code-style.yml/badge.svg" alt="Pylint"></a>
|
4
4
|
<a href="https://github.com/Nayjest/ai-code-review/actions/workflows/tests.yml" target="_blank"><img src="https://github.com/Nayjest/ai-code-review/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
|
5
|
+
<img src="https://github.com/Nayjest/ai-code-review/blob/main/coverage.svg" alt="Code Coverage">
|
5
6
|
<a href="https://github.com/Nayjest/ai-code-review/blob/main/LICENSE" target="_blank"><img src="https://img.shields.io/static/v1?label=license&message=MIT&color=d08aff" alt="License"></a>
|
6
7
|
</p>
|
7
8
|
|
@@ -42,7 +43,7 @@ jobs:
|
|
42
43
|
uses: actions/setup-python@v5
|
43
44
|
with: { python-version: "3.13" }
|
44
45
|
- name: Install AI Code Review tool
|
45
|
-
run: pip install ai-code-review==0.
|
46
|
+
run: pip install ai-code-review==0.5.0
|
46
47
|
- name: Run AI code analysis
|
47
48
|
env:
|
48
49
|
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
@@ -63,7 +64,8 @@ jobs:
|
|
63
64
|
> ⚠️ Make sure to add `LLM_API_KEY` to your repository’s GitHub secrets.
|
64
65
|
|
65
66
|
💪 Done!
|
66
|
-
PRs to your repository will now receive AI code reviews automatically. ✨
|
67
|
+
PRs to your repository will now receive AI code reviews automatically. ✨
|
68
|
+
See [GitHub Setup Guide](https://github.com/Nayjest/ai-code-review/blob/main/documentation/github_setup.md) for more details.
|
67
69
|
|
68
70
|
### 2. Run Code Analysis Locally
|
69
71
|
|
@@ -123,7 +125,7 @@ pytest
|
|
123
125
|
|
124
126
|
## 🤝 Contributing
|
125
127
|
|
126
|
-
**Looking for a specific feature or having trouble?**
|
128
|
+
**Looking for a specific feature or having trouble?**
|
127
129
|
Contributions are welcome! ❤️
|
128
130
|
See [CONTRIBUTING.md](https://github.com/Nayjest/ai-code-review/blob/main/CONTRIBUTING.md) for details.
|
129
131
|
|
@@ -41,7 +41,10 @@ def bootstrap():
|
|
41
41
|
if is_running_in_github_action():
|
42
42
|
ref = os.getenv("GITHUB_WORKFLOW_REF", "")
|
43
43
|
if ref:
|
44
|
-
|
44
|
+
# example value: 'owner/repo/.github/workflows/ai-code-review.yml@refs/pull/1/merge'
|
45
|
+
ref = ref.split("@")[0]
|
46
|
+
ref = ref.split(".github/workflows/")[-1]
|
47
|
+
ref = f" (.github/workflows/{ref})"
|
45
48
|
msg += (
|
46
49
|
f"\nPlease check your GitHub Action Secrets "
|
47
50
|
f"and `env` configuration section of the corresponding workflow step{ref}."
|
@@ -56,4 +59,4 @@ def bootstrap():
|
|
56
59
|
except Exception as e:
|
57
60
|
logging.error(f"Unexpected configuration error: {e}")
|
58
61
|
raise SystemExit(3)
|
59
|
-
mc.logging.LoggingConfig.STRIP_REQUEST_LINES = [
|
62
|
+
mc.logging.LoggingConfig.STRIP_REQUEST_LINES = [300, 15]
|
@@ -0,0 +1,236 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
import sys
|
4
|
+
import os
|
5
|
+
import textwrap
|
6
|
+
import tempfile
|
7
|
+
import requests
|
8
|
+
|
9
|
+
import microcore as mc
|
10
|
+
import typer
|
11
|
+
from git import Repo
|
12
|
+
|
13
|
+
from .core import review, get_diff, filter_diff
|
14
|
+
from .report_struct import Report
|
15
|
+
from .constants import ENV_CONFIG_FILE
|
16
|
+
from .bootstrap import bootstrap
|
17
|
+
from .project_config import ProjectConfig
|
18
|
+
from .utils import no_subcommand, parse_refs_pair
|
19
|
+
|
20
|
+
app = typer.Typer(pretty_exceptions_show_locals=False)
|
21
|
+
app_no_subcommand = typer.Typer(pretty_exceptions_show_locals=False)
|
22
|
+
|
23
|
+
|
24
|
+
def main():
|
25
|
+
if sys.platform == "win32":
|
26
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
27
|
+
if no_subcommand(app):
|
28
|
+
bootstrap()
|
29
|
+
app_no_subcommand()
|
30
|
+
else:
|
31
|
+
app()
|
32
|
+
|
33
|
+
|
34
|
+
@app.callback(invoke_without_command=True)
|
35
|
+
def cli(ctx: typer.Context):
|
36
|
+
if ctx.invoked_subcommand != "setup":
|
37
|
+
bootstrap()
|
38
|
+
|
39
|
+
|
40
|
+
def args_to_target(refs, what, against) -> tuple[str | None, str | None]:
|
41
|
+
_what, _against = parse_refs_pair(refs)
|
42
|
+
if _what:
|
43
|
+
if what:
|
44
|
+
raise typer.BadParameter(
|
45
|
+
"You cannot specify both 'refs' <WHAT>..<AGAINST> and '--what'. Use one of them."
|
46
|
+
)
|
47
|
+
else:
|
48
|
+
_what = what
|
49
|
+
if _against:
|
50
|
+
if against:
|
51
|
+
raise typer.BadParameter(
|
52
|
+
"You cannot specify both 'refs' <WHAT>..<AGAINST> and '--against'. Use one of them."
|
53
|
+
)
|
54
|
+
else:
|
55
|
+
_against = against
|
56
|
+
return _what, _against
|
57
|
+
|
58
|
+
|
59
|
+
def arg_refs() -> typer.Argument:
|
60
|
+
return typer.Argument(
|
61
|
+
default=None,
|
62
|
+
help="Git refs to review, [what]..[against] e.g. 'HEAD..HEAD~1'"
|
63
|
+
)
|
64
|
+
|
65
|
+
|
66
|
+
def arg_what() -> typer.Option:
|
67
|
+
return typer.Option(None, "--what", "-w", help="Git ref to review")
|
68
|
+
|
69
|
+
|
70
|
+
def arg_filters() -> typer.Option:
|
71
|
+
return typer.Option(
|
72
|
+
"", "--filter", "-f", "--filters",
|
73
|
+
help="""
|
74
|
+
filter reviewed files by glob / fnmatch pattern(s),
|
75
|
+
e.g. 'src/**/*.py', may be comma-separated
|
76
|
+
""",
|
77
|
+
)
|
78
|
+
|
79
|
+
|
80
|
+
def arg_out() -> typer.Option:
|
81
|
+
return typer.Option(
|
82
|
+
None,
|
83
|
+
"--out", "-o", "--output",
|
84
|
+
help="Output folder for the code review report"
|
85
|
+
)
|
86
|
+
|
87
|
+
|
88
|
+
def arg_against() -> typer.Option:
|
89
|
+
return typer.Option(
|
90
|
+
None,
|
91
|
+
"--against", "-vs", "--vs",
|
92
|
+
help="Git ref to compare against"
|
93
|
+
)
|
94
|
+
|
95
|
+
|
96
|
+
@app_no_subcommand.command(name="review", help="Perform code review")
|
97
|
+
@app.command(name="review", help="Perform code review")
|
98
|
+
def cmd_review(
|
99
|
+
refs: str = arg_refs(),
|
100
|
+
what: str = arg_what(),
|
101
|
+
against: str = arg_against(),
|
102
|
+
filters: str = arg_filters(),
|
103
|
+
merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
|
104
|
+
out: str = arg_out()
|
105
|
+
):
|
106
|
+
_what, _against = args_to_target(refs, what, against)
|
107
|
+
asyncio.run(review(
|
108
|
+
what=_what,
|
109
|
+
against=_against,
|
110
|
+
filters=filters,
|
111
|
+
use_merge_base=merge_base,
|
112
|
+
out_folder=out,
|
113
|
+
))
|
114
|
+
|
115
|
+
|
116
|
+
@app.command(help="Configure LLM for local usage interactively")
|
117
|
+
def setup():
|
118
|
+
mc.interactive_setup(ENV_CONFIG_FILE)
|
119
|
+
|
120
|
+
|
121
|
+
@app.command()
|
122
|
+
def render(format: str = Report.Format.MARKDOWN):
|
123
|
+
print(Report.load().render(format=format))
|
124
|
+
|
125
|
+
|
126
|
+
@app.command(help="Review remote code")
|
127
|
+
def remote(
|
128
|
+
url: str = typer.Argument(..., help="Git repository URL"),
|
129
|
+
refs: str = arg_refs(),
|
130
|
+
what: str = arg_what(),
|
131
|
+
against: str = arg_against(),
|
132
|
+
filters: str = arg_filters(),
|
133
|
+
merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
|
134
|
+
out: str = arg_out()
|
135
|
+
):
|
136
|
+
_what, _against = args_to_target(refs, what, against)
|
137
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
138
|
+
logging.info(f"Cloning [{mc.ui.green(url)}] to {mc.utils.file_link(temp_dir)} ...")
|
139
|
+
repo = Repo.clone_from(url, branch=_what, to_path=temp_dir)
|
140
|
+
asyncio.run(review(
|
141
|
+
repo=repo,
|
142
|
+
what=_what,
|
143
|
+
against=_against,
|
144
|
+
filters=filters,
|
145
|
+
use_merge_base=merge_base,
|
146
|
+
out_folder=out or '.',
|
147
|
+
))
|
148
|
+
repo.close()
|
149
|
+
|
150
|
+
|
151
|
+
@app.command(help="Leave a GitHub PR comment with the review.")
|
152
|
+
def github_comment(
|
153
|
+
token: str = typer.Option(
|
154
|
+
os.environ.get("GITHUB_TOKEN", ""), help="GitHub token (or set GITHUB_TOKEN env var)"
|
155
|
+
),
|
156
|
+
):
|
157
|
+
"""
|
158
|
+
Leaves a comment with the review on the current GitHub pull request.
|
159
|
+
"""
|
160
|
+
file = "code-review-report.md"
|
161
|
+
if not os.path.exists(file):
|
162
|
+
print(f"Review file not found: {file}")
|
163
|
+
raise typer.Exit(4)
|
164
|
+
|
165
|
+
with open(file, "r", encoding="utf-8") as f:
|
166
|
+
body = f.read()
|
167
|
+
|
168
|
+
if not token:
|
169
|
+
print("GitHub token is required (--token or GITHUB_TOKEN env var).")
|
170
|
+
raise typer.Exit(1)
|
171
|
+
|
172
|
+
github_env = ProjectConfig.load().prompt_vars["github_env"]
|
173
|
+
repo = github_env.get("github_repo", "")
|
174
|
+
pr_env_val = github_env.get("github_pr_number", "")
|
175
|
+
logging.info(f"github_pr_number = {pr_env_val}")
|
176
|
+
|
177
|
+
# e.g. could be "refs/pull/123/merge" or a direct number
|
178
|
+
if "/" in pr_env_val and "pull" in pr_env_val:
|
179
|
+
# refs/pull/123/merge
|
180
|
+
try:
|
181
|
+
pr_num_candidate = pr_env_val.strip("/").split("/")
|
182
|
+
idx = pr_num_candidate.index("pull")
|
183
|
+
pr = int(pr_num_candidate[idx + 1])
|
184
|
+
except Exception:
|
185
|
+
pr = 0
|
186
|
+
else:
|
187
|
+
try:
|
188
|
+
pr = int(pr_env_val)
|
189
|
+
except Exception:
|
190
|
+
pr = 0
|
191
|
+
|
192
|
+
api_url = f"https://api.github.com/repos/{repo}/issues/{pr}/comments"
|
193
|
+
headers = {
|
194
|
+
"Authorization": f"token {token}",
|
195
|
+
"Accept": "application/vnd.github+json",
|
196
|
+
}
|
197
|
+
data = {"body": body}
|
198
|
+
|
199
|
+
resp = requests.post(api_url, headers=headers, json=data)
|
200
|
+
if 200 <= resp.status_code < 300:
|
201
|
+
logging.info(f"Posted review comment to PR #{pr} in {repo}")
|
202
|
+
else:
|
203
|
+
logging.error(f"Failed to post comment: {resp.status_code} {resp.reason}\n{resp.text}")
|
204
|
+
raise typer.Exit(5)
|
205
|
+
|
206
|
+
|
207
|
+
@app.command(help="List files in the diff. Might be useful to check what will be reviewed.")
|
208
|
+
def files(
|
209
|
+
refs: str = arg_refs(),
|
210
|
+
what: str = arg_what(),
|
211
|
+
against: str = arg_against(),
|
212
|
+
filters: str = arg_filters(),
|
213
|
+
merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
|
214
|
+
diff: bool = typer.Option(default=False, help="Show diff content")
|
215
|
+
):
|
216
|
+
_what, _against = args_to_target(refs, what, against)
|
217
|
+
repo = Repo(".")
|
218
|
+
patch_set = get_diff(repo=repo, what=_what, against=_against, use_merge_base=merge_base)
|
219
|
+
patch_set = filter_diff(patch_set, filters)
|
220
|
+
print(
|
221
|
+
f"Changed files: "
|
222
|
+
f"{mc.ui.green(_what or 'INDEX')} vs "
|
223
|
+
f"{mc.ui.yellow(_against or repo.remotes.origin.refs.HEAD.reference.name)}"
|
224
|
+
f"{' filtered by '+mc.ui.cyan(filters) if filters else ''}"
|
225
|
+
)
|
226
|
+
|
227
|
+
for patch in patch_set:
|
228
|
+
if patch.is_added_file:
|
229
|
+
color = mc.ui.green
|
230
|
+
elif patch.is_removed_file:
|
231
|
+
color = mc.ui.red
|
232
|
+
else:
|
233
|
+
color = mc.ui.blue
|
234
|
+
print(f"- {color(patch.path)}")
|
235
|
+
if diff:
|
236
|
+
print(mc.ui.gray(textwrap.indent(str(patch), " ")))
|
@@ -3,5 +3,5 @@ from pathlib import Path
|
|
3
3
|
|
4
4
|
PROJECT_CONFIG_FILE = Path(".ai-code-review.toml")
|
5
5
|
PROJECT_CONFIG_DEFAULTS_FILE = Path(__file__).resolve().parent / PROJECT_CONFIG_FILE
|
6
|
-
ENV_CONFIG_FILE =
|
6
|
+
ENV_CONFIG_FILE = Path("~/.env.ai-code-review").expanduser()
|
7
7
|
JSON_REPORT_FILE_NAME = "code-review-report.json"
|
@@ -0,0 +1,191 @@
|
|
1
|
+
import fnmatch
|
2
|
+
import logging
|
3
|
+
from os import PathLike
|
4
|
+
from typing import Iterable
|
5
|
+
from pathlib import Path
|
6
|
+
|
7
|
+
import microcore as mc
|
8
|
+
from git import Repo
|
9
|
+
from unidiff import PatchSet, PatchedFile
|
10
|
+
from unidiff.constants import DEV_NULL
|
11
|
+
|
12
|
+
from .project_config import ProjectConfig
|
13
|
+
from .report_struct import Report
|
14
|
+
from .constants import JSON_REPORT_FILE_NAME
|
15
|
+
|
16
|
+
|
17
|
+
def is_binary_file(repo: Repo, file_path: str) -> bool:
|
18
|
+
"""
|
19
|
+
Check if a file is binary by attempting to read it as text.
|
20
|
+
Returns True if the file is binary, False otherwise.
|
21
|
+
"""
|
22
|
+
try:
|
23
|
+
# Attempt to read the file content from the repository tree
|
24
|
+
content = repo.tree()[file_path].data_stream.read()
|
25
|
+
# Try decoding as UTF-8; if it fails, it's likely binary
|
26
|
+
content.decode("utf-8")
|
27
|
+
return False
|
28
|
+
except (UnicodeDecodeError, KeyError):
|
29
|
+
return True
|
30
|
+
except Exception as e:
|
31
|
+
logging.warning(f"Error checking if file {file_path} is binary: {e}")
|
32
|
+
return True # Conservatively treat errors as binary to avoid issues
|
33
|
+
|
34
|
+
|
35
|
+
def get_diff(
|
36
|
+
repo: Repo = None,
|
37
|
+
what: str = None,
|
38
|
+
against: str = None,
|
39
|
+
use_merge_base: bool = True,
|
40
|
+
) -> PatchSet | list[PatchedFile]:
|
41
|
+
repo = repo or Repo(".")
|
42
|
+
if not against:
|
43
|
+
against = repo.remotes.origin.refs.HEAD.reference.name # origin/main
|
44
|
+
if not what:
|
45
|
+
what = None # working copy
|
46
|
+
if use_merge_base:
|
47
|
+
if what is None:
|
48
|
+
try:
|
49
|
+
current_ref = repo.active_branch.name
|
50
|
+
except TypeError:
|
51
|
+
# In detached HEAD state, use HEAD directly
|
52
|
+
current_ref = "HEAD"
|
53
|
+
logging.info(
|
54
|
+
"Detected detached HEAD state, using HEAD as current reference"
|
55
|
+
)
|
56
|
+
else:
|
57
|
+
current_ref = what
|
58
|
+
merge_base = repo.merge_base(current_ref or repo.active_branch.name, against)[0]
|
59
|
+
against = merge_base.hexsha
|
60
|
+
logging.info(
|
61
|
+
f"Using merge base: {mc.ui.cyan(merge_base.hexsha[:8])} ({merge_base.summary})"
|
62
|
+
)
|
63
|
+
logging.info(
|
64
|
+
f"Making diff: {mc.ui.green(what or 'INDEX')} vs {mc.ui.yellow(against)}"
|
65
|
+
)
|
66
|
+
diff_content = repo.git.diff(against, what)
|
67
|
+
diff = PatchSet.from_string(diff_content)
|
68
|
+
diff = PatchSet.from_string(diff_content)
|
69
|
+
|
70
|
+
# Filter out binary files
|
71
|
+
non_binary_diff = PatchSet([])
|
72
|
+
for patched_file in diff:
|
73
|
+
# Check if the file is binary using the source or target file path
|
74
|
+
file_path = (
|
75
|
+
patched_file.target_file
|
76
|
+
if patched_file.target_file != DEV_NULL
|
77
|
+
else patched_file.source_file
|
78
|
+
)
|
79
|
+
if file_path == DEV_NULL or is_binary_file(repo, file_path.lstrip("b/")):
|
80
|
+
logging.info(f"Skipping binary file: {patched_file.path}")
|
81
|
+
continue
|
82
|
+
non_binary_diff.append(patched_file)
|
83
|
+
return non_binary_diff
|
84
|
+
|
85
|
+
|
86
|
+
def filter_diff(
|
87
|
+
patch_set: PatchSet | Iterable[PatchedFile], filters: str | list[str]
|
88
|
+
) -> PatchSet | Iterable[PatchedFile]:
|
89
|
+
"""
|
90
|
+
Filter the diff files by the given fnmatch filters.
|
91
|
+
"""
|
92
|
+
assert isinstance(filters, (list, str))
|
93
|
+
if not isinstance(filters, list):
|
94
|
+
filters = [f.strip() for f in filters.split(",") if f.strip()]
|
95
|
+
if not filters:
|
96
|
+
return patch_set
|
97
|
+
files = [
|
98
|
+
file
|
99
|
+
for file in patch_set
|
100
|
+
if any(fnmatch.fnmatch(file.path, pattern) for pattern in filters)
|
101
|
+
]
|
102
|
+
return files
|
103
|
+
|
104
|
+
|
105
|
+
def file_lines(repo: Repo, file: str, max_tokens: int = None) -> str:
|
106
|
+
text = repo.tree()[file].data_stream.read().decode()
|
107
|
+
lines = [f"{i + 1}: {line}\n" for i, line in enumerate(text.splitlines())]
|
108
|
+
if max_tokens:
|
109
|
+
lines, removed_qty = mc.tokenizing.fit_to_token_size(lines, max_tokens)
|
110
|
+
if removed_qty:
|
111
|
+
lines.append(
|
112
|
+
f"(!) DISPLAYING ONLY FIRST {len(lines)} LINES DUE TO LARGE FILE SIZE\n"
|
113
|
+
)
|
114
|
+
return "".join(lines)
|
115
|
+
|
116
|
+
|
117
|
+
def make_cr_summary(cfg: ProjectConfig, report: Report, diff):
|
118
|
+
return (
|
119
|
+
mc.prompt(
|
120
|
+
cfg.summary_prompt,
|
121
|
+
diff=mc.tokenizing.fit_to_token_size(diff, cfg.max_code_tokens)[0],
|
122
|
+
issues=report.issues,
|
123
|
+
**cfg.prompt_vars,
|
124
|
+
).to_llm()
|
125
|
+
if cfg.summary_prompt
|
126
|
+
else ""
|
127
|
+
)
|
128
|
+
|
129
|
+
|
130
|
+
async def review(
|
131
|
+
repo: Repo = None,
|
132
|
+
what: str = None,
|
133
|
+
against: str = None,
|
134
|
+
filters: str | list[str] = "",
|
135
|
+
use_merge_base: bool = True,
|
136
|
+
out_folder: str | PathLike | None = None,
|
137
|
+
):
|
138
|
+
cfg = ProjectConfig.load()
|
139
|
+
repo = repo or Repo(".")
|
140
|
+
out_folder = Path(out_folder or repo.working_tree_dir)
|
141
|
+
diff = get_diff(
|
142
|
+
repo=repo, what=what, against=against, use_merge_base=use_merge_base
|
143
|
+
)
|
144
|
+
diff = filter_diff(diff, filters)
|
145
|
+
if not diff:
|
146
|
+
logging.error("Nothing to review")
|
147
|
+
return
|
148
|
+
lines = {
|
149
|
+
file_diff.path: (
|
150
|
+
file_lines(
|
151
|
+
repo,
|
152
|
+
file_diff.path,
|
153
|
+
cfg.max_code_tokens
|
154
|
+
- mc.tokenizing.num_tokens_from_string(str(file_diff)),
|
155
|
+
)
|
156
|
+
if file_diff.target_file != DEV_NULL and not file_diff.is_added_file
|
157
|
+
else ""
|
158
|
+
)
|
159
|
+
for file_diff in diff
|
160
|
+
}
|
161
|
+
responses = await mc.llm_parallel(
|
162
|
+
[
|
163
|
+
mc.prompt(
|
164
|
+
cfg.prompt,
|
165
|
+
input=file_diff,
|
166
|
+
file_lines=lines[file_diff.path],
|
167
|
+
**cfg.prompt_vars,
|
168
|
+
)
|
169
|
+
for file_diff in diff
|
170
|
+
],
|
171
|
+
retries=cfg.retries,
|
172
|
+
parse_json=True,
|
173
|
+
)
|
174
|
+
issues = {file.path: issues for file, issues in zip(diff, responses) if issues}
|
175
|
+
for file, file_issues in issues.items():
|
176
|
+
for issue in file_issues:
|
177
|
+
for i in issue.get("affected_lines", []):
|
178
|
+
if lines[file]:
|
179
|
+
f_lines = [""] + lines[file].splitlines()
|
180
|
+
i["affected_code"] = "\n".join(
|
181
|
+
f_lines[i["start_line"]: i["end_line"] + 1]
|
182
|
+
)
|
183
|
+
exec(cfg.post_process, {"mc": mc, **locals()})
|
184
|
+
out_folder.mkdir(parents=True, exist_ok=True)
|
185
|
+
report = Report(issues=issues, number_of_processed_files=len(diff))
|
186
|
+
report.summary = make_cr_summary(cfg, report, diff)
|
187
|
+
report.save(file_name=out_folder / JSON_REPORT_FILE_NAME)
|
188
|
+
report_text = report.render(cfg, Report.Format.MARKDOWN)
|
189
|
+
print(mc.ui.yellow(report_text))
|
190
|
+
text_report_path = out_folder / "code-review-report.md"
|
191
|
+
text_report_path.write_text(report_text, encoding="utf-8")
|
@@ -1,5 +1,8 @@
|
|
1
|
+
import sys
|
1
2
|
import os
|
2
3
|
from pathlib import Path
|
4
|
+
import typer
|
5
|
+
|
3
6
|
|
4
7
|
_EXT_TO_HINT: dict[str, str] = {
|
5
8
|
# scripting & languages
|
@@ -87,3 +90,27 @@ def syntax_hint(file_path: str | Path) -> str:
|
|
87
90
|
|
88
91
|
def is_running_in_github_action():
|
89
92
|
return os.getenv("GITHUB_ACTIONS") == "true"
|
93
|
+
|
94
|
+
|
95
|
+
def no_subcommand(app: typer.Typer) -> bool:
|
96
|
+
"""
|
97
|
+
Checks if the current script is being invoked as a command in a target Typer application.
|
98
|
+
"""
|
99
|
+
return not (
|
100
|
+
(first_arg := next((a for a in sys.argv[1:] if not a.startswith('-')), None))
|
101
|
+
and first_arg in (
|
102
|
+
cmd.name or cmd.callback.__name__.replace('_', '-')
|
103
|
+
for cmd in app.registered_commands
|
104
|
+
)
|
105
|
+
or '--help' in sys.argv
|
106
|
+
)
|
107
|
+
|
108
|
+
|
109
|
+
def parse_refs_pair(refs: str) -> tuple[str | None, str | None]:
|
110
|
+
SEPARATOR = '..'
|
111
|
+
if not refs:
|
112
|
+
return None, None
|
113
|
+
if SEPARATOR not in refs:
|
114
|
+
return refs, None
|
115
|
+
what, against = refs.split(SEPARATOR)
|
116
|
+
return what or None, against or None
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "ai-cr"
|
3
|
-
version = "0.
|
3
|
+
version = "0.6.0"
|
4
4
|
description = "LLM-agnostic GitHub AI Code Review Tool with integration to GitHub actions"
|
5
5
|
authors = ["Nayjest <mail@vitaliy.in>"]
|
6
6
|
readme = "README.md"
|
@@ -21,13 +21,12 @@ packages = [
|
|
21
21
|
|
22
22
|
[tool.poetry.dependencies]
|
23
23
|
python = "^3.11"
|
24
|
-
ai-microcore = "4.0.0.
|
25
|
-
GitPython = "3.1.44"
|
26
|
-
unidiff = "0.7.5"
|
27
|
-
google-generativeai = "0.8.5"
|
28
|
-
anthropic = "0.
|
29
|
-
typer = "0.
|
30
|
-
async-typer = "0.1.8"
|
24
|
+
ai-microcore = "4.0.0.dev19"
|
25
|
+
GitPython = "^3.1.44"
|
26
|
+
unidiff = "^0.7.5"
|
27
|
+
google-generativeai = "^0.8.5"
|
28
|
+
anthropic = "^0.52.2"
|
29
|
+
typer = "^0.16.0"
|
31
30
|
|
32
31
|
[tool.poetry.group.dev.dependencies]
|
33
32
|
flake8 = "*"
|
@@ -49,7 +48,7 @@ requires = ["poetry-core"]
|
|
49
48
|
build-backend = "poetry.core.masonry.api"
|
50
49
|
|
51
50
|
[tool.poetry.scripts]
|
52
|
-
ai-cr = "ai_code_review.cli:
|
51
|
+
ai-cr = "ai_code_review.cli:main"
|
53
52
|
|
54
53
|
[tool.pytest.ini_options]
|
55
54
|
minversion = "6.0"
|
@@ -1,112 +0,0 @@
|
|
1
|
-
import asyncio
|
2
|
-
import logging
|
3
|
-
import sys
|
4
|
-
import os
|
5
|
-
import shutil
|
6
|
-
|
7
|
-
import microcore as mc
|
8
|
-
import async_typer
|
9
|
-
import typer
|
10
|
-
from .core import review
|
11
|
-
from .report_struct import Report
|
12
|
-
from git import Repo
|
13
|
-
import requests
|
14
|
-
|
15
|
-
from .constants import ENV_CONFIG_FILE
|
16
|
-
from .bootstrap import bootstrap
|
17
|
-
from .project_config import ProjectConfig
|
18
|
-
|
19
|
-
app = async_typer.AsyncTyper(
|
20
|
-
pretty_exceptions_show_locals=False,
|
21
|
-
)
|
22
|
-
|
23
|
-
|
24
|
-
if sys.platform == "win32":
|
25
|
-
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
26
|
-
|
27
|
-
|
28
|
-
@app.callback(invoke_without_command=True)
|
29
|
-
def cli(ctx: typer.Context, filters=typer.Option("", "--filter", "-f", "--filters")):
|
30
|
-
if ctx.invoked_subcommand != "setup":
|
31
|
-
bootstrap()
|
32
|
-
if not ctx.invoked_subcommand:
|
33
|
-
asyncio.run(review(filters=filters))
|
34
|
-
|
35
|
-
|
36
|
-
@app.async_command(help="Configure LLM for local usage interactively")
|
37
|
-
async def setup():
|
38
|
-
mc.interactive_setup(ENV_CONFIG_FILE)
|
39
|
-
|
40
|
-
|
41
|
-
@app.async_command()
|
42
|
-
async def render(format: str = Report.Format.MARKDOWN):
|
43
|
-
print(Report.load().render(format=format))
|
44
|
-
|
45
|
-
|
46
|
-
@app.async_command(help="Review remote code")
|
47
|
-
async def remote(url=typer.Option(), branch=typer.Option()):
|
48
|
-
if os.path.exists("reviewed-repo"):
|
49
|
-
shutil.rmtree("reviewed-repo")
|
50
|
-
Repo.clone_from(url, branch=branch, to_path="reviewed-repo")
|
51
|
-
prev_dir = os.getcwd()
|
52
|
-
try:
|
53
|
-
os.chdir("reviewed-repo")
|
54
|
-
await review()
|
55
|
-
finally:
|
56
|
-
os.chdir(prev_dir)
|
57
|
-
|
58
|
-
|
59
|
-
@app.async_command(help="Leave a GitHub PR comment with the review.")
|
60
|
-
async def github_comment(
|
61
|
-
token: str = typer.Option(
|
62
|
-
os.environ.get("GITHUB_TOKEN", ""), help="GitHub token (or set GITHUB_TOKEN env var)"
|
63
|
-
),
|
64
|
-
):
|
65
|
-
"""
|
66
|
-
Leaves a comment with the review on the current GitHub pull request.
|
67
|
-
"""
|
68
|
-
file = "code-review-report.txt"
|
69
|
-
if not os.path.exists(file):
|
70
|
-
print(f"Review file not found: {file}")
|
71
|
-
raise typer.Exit(4)
|
72
|
-
|
73
|
-
with open(file, "r", encoding="utf-8") as f:
|
74
|
-
body = f.read()
|
75
|
-
|
76
|
-
if not token:
|
77
|
-
print("GitHub token is required (--token or GITHUB_TOKEN env var).")
|
78
|
-
raise typer.Exit(1)
|
79
|
-
|
80
|
-
github_env = ProjectConfig.load().prompt_vars["github_env"]
|
81
|
-
repo = github_env.get("github_repo", "")
|
82
|
-
pr_env_val = github_env.get("github_pr_number", "")
|
83
|
-
logging.info(f"github_pr_number = {pr_env_val}")
|
84
|
-
|
85
|
-
# e.g. could be "refs/pull/123/merge" or a direct number
|
86
|
-
if "/" in pr_env_val and "pull" in pr_env_val:
|
87
|
-
# refs/pull/123/merge
|
88
|
-
try:
|
89
|
-
pr_num_candidate = pr_env_val.strip("/").split("/")
|
90
|
-
idx = pr_num_candidate.index("pull")
|
91
|
-
pr = int(pr_num_candidate[idx + 1])
|
92
|
-
except Exception:
|
93
|
-
pr = 0
|
94
|
-
else:
|
95
|
-
try:
|
96
|
-
pr = int(pr_env_val)
|
97
|
-
except Exception:
|
98
|
-
pr = 0
|
99
|
-
|
100
|
-
api_url = f"https://api.github.com/repos/{repo}/issues/{pr}/comments"
|
101
|
-
headers = {
|
102
|
-
"Authorization": f"token {token}",
|
103
|
-
"Accept": "application/vnd.github+json",
|
104
|
-
}
|
105
|
-
data = {"body": body}
|
106
|
-
|
107
|
-
resp = requests.post(api_url, headers=headers, json=data)
|
108
|
-
if 200 <= resp.status_code < 300:
|
109
|
-
logging.info(f"Posted review comment to PR #{pr} in {repo}")
|
110
|
-
else:
|
111
|
-
logging.error(f"Failed to post comment: {resp.status_code} {resp.reason}\n{resp.text}")
|
112
|
-
raise typer.Exit(5)
|
@@ -1,114 +0,0 @@
|
|
1
|
-
import fnmatch
|
2
|
-
import logging
|
3
|
-
from typing import Iterable
|
4
|
-
|
5
|
-
import microcore as mc
|
6
|
-
from git import Repo
|
7
|
-
from unidiff import PatchSet, PatchedFile
|
8
|
-
from unidiff.constants import DEV_NULL
|
9
|
-
|
10
|
-
from .project_config import ProjectConfig
|
11
|
-
from .report_struct import Report
|
12
|
-
|
13
|
-
|
14
|
-
def get_diff(repo: Repo = None, against: str = "HEAD") -> PatchSet | list[PatchedFile]:
|
15
|
-
repo = repo or Repo(".")
|
16
|
-
base = repo.remotes.origin.refs.HEAD.reference.name
|
17
|
-
logging.info(f"{base}...{against}")
|
18
|
-
diff_content = repo.git.diff(base, against)
|
19
|
-
diff = PatchSet.from_string(diff_content)
|
20
|
-
return diff
|
21
|
-
|
22
|
-
|
23
|
-
def filter_diff(
|
24
|
-
patch_set: PatchSet | Iterable[PatchedFile], filters: str | list[str]
|
25
|
-
) -> PatchSet | Iterable[PatchedFile]:
|
26
|
-
"""
|
27
|
-
Filter the diff files by the given fnmatch filters.
|
28
|
-
"""
|
29
|
-
print([f.path for f in patch_set])
|
30
|
-
assert isinstance(filters, (list, str))
|
31
|
-
if not isinstance(filters, list):
|
32
|
-
filters = [f.strip() for f in filters.split(",") if f.strip()]
|
33
|
-
if not filters:
|
34
|
-
return patch_set
|
35
|
-
files = [
|
36
|
-
file
|
37
|
-
for file in patch_set
|
38
|
-
if any(fnmatch.fnmatch(file.path, pattern) for pattern in filters)
|
39
|
-
]
|
40
|
-
print([f.path for f in files])
|
41
|
-
return files
|
42
|
-
|
43
|
-
|
44
|
-
def file_lines(repo: Repo, file: str, max_tokens: int = None) -> str:
|
45
|
-
text = repo.tree()[file].data_stream.read().decode()
|
46
|
-
lines = [f"{i + 1}: {line}\n" for i, line in enumerate(text.splitlines())]
|
47
|
-
if max_tokens:
|
48
|
-
lines, removed_qty = mc.tokenizing.fit_to_token_size(lines, max_tokens)
|
49
|
-
if removed_qty:
|
50
|
-
lines.append(
|
51
|
-
f"(!) DISPLAYING ONLY FIRST {len(lines)} LINES DUE TO LARGE FILE SIZE\n"
|
52
|
-
)
|
53
|
-
return "".join(lines)
|
54
|
-
|
55
|
-
|
56
|
-
def make_cr_summary(cfg: ProjectConfig, report: Report, diff):
|
57
|
-
return mc.prompt(
|
58
|
-
cfg.summary_prompt,
|
59
|
-
diff=mc.tokenizing.fit_to_token_size(diff, cfg.max_code_tokens)[0],
|
60
|
-
issues=report.issues,
|
61
|
-
**cfg.prompt_vars,
|
62
|
-
).to_llm() if cfg.summary_prompt else ""
|
63
|
-
|
64
|
-
|
65
|
-
async def review(filters: str | list[str] = ""):
|
66
|
-
cfg = ProjectConfig.load()
|
67
|
-
repo = Repo(".")
|
68
|
-
diff = get_diff(repo=repo, against="HEAD")
|
69
|
-
diff = filter_diff(diff, filters)
|
70
|
-
if not diff:
|
71
|
-
logging.error("Nothing to review")
|
72
|
-
return
|
73
|
-
lines = {
|
74
|
-
file_diff.path: (
|
75
|
-
file_lines(
|
76
|
-
repo,
|
77
|
-
file_diff.path,
|
78
|
-
cfg.max_code_tokens
|
79
|
-
- mc.tokenizing.num_tokens_from_string(str(file_diff)),
|
80
|
-
)
|
81
|
-
if file_diff.target_file != DEV_NULL
|
82
|
-
else ""
|
83
|
-
)
|
84
|
-
for file_diff in diff
|
85
|
-
}
|
86
|
-
responses = await mc.llm_parallel(
|
87
|
-
[
|
88
|
-
mc.prompt(
|
89
|
-
cfg.prompt,
|
90
|
-
input=file_diff,
|
91
|
-
file_lines=lines[file_diff.path],
|
92
|
-
**cfg.prompt_vars,
|
93
|
-
)
|
94
|
-
for file_diff in diff
|
95
|
-
],
|
96
|
-
retries=cfg.retries,
|
97
|
-
parse_json=True,
|
98
|
-
)
|
99
|
-
issues = {file.path: issues for file, issues in zip(diff, responses) if issues}
|
100
|
-
for file, file_issues in issues.items():
|
101
|
-
for issue in file_issues:
|
102
|
-
for i in issue.get("affected_lines", []):
|
103
|
-
if lines[file]:
|
104
|
-
f_lines = [""] + lines[file].splitlines()
|
105
|
-
i["affected_code"] = "\n".join(
|
106
|
-
f_lines[i["start_line"]: i["end_line"]+1]
|
107
|
-
)
|
108
|
-
exec(cfg.post_process, {"mc": mc, **locals()})
|
109
|
-
report = Report(issues=issues, number_of_processed_files=len(diff))
|
110
|
-
report.summary = make_cr_summary(cfg, report, diff)
|
111
|
-
report.save()
|
112
|
-
report_text = report.render(cfg, Report.Format.MARKDOWN)
|
113
|
-
print(mc.ui.yellow(report_text))
|
114
|
-
open("code-review-report.txt", "w", encoding="utf-8").write(report_text)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|