ai-cr 0.5.0__tar.gz → 1.0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ai-cr
3
- Version: 0.5.0
3
+ Version: 1.0.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 (==3.1.44)
19
- Requires-Dist: ai-microcore (==4.0.0.dev18)
20
- Requires-Dist: anthropic (==0.52.2)
21
- Requires-Dist: async-typer (==0.1.8)
22
- Requires-Dist: google-generativeai (==0.8.5)
23
- Requires-Dist: typer (==0.9.4)
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.5.0
73
+ run: pip install ai-code-review~=1.0
74
74
  - name: Run AI code analysis
75
75
  env:
76
76
  LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
@@ -84,34 +84,66 @@ jobs:
84
84
  with:
85
85
  name: ai-code-review-results
86
86
  path: |
87
- code-review-report.txt
87
+ code-review-report.md
88
88
  code-review-report.json
89
89
  ```
90
90
 
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
- ### 2. Run Code Analysis Locally
97
+ ### 2. Running Code Analysis Locally
97
98
 
98
- Install and run:
99
+ #### Initial Local Setup
99
100
 
101
+ **Prerequisites:** [Python](https://www.python.org/downloads/) 3.11 / 3.12 / 3.13
102
+
103
+ **Step1:** Install [ai-code-review](https://github.com/Nayjest/ai-code-review) using [pip](https://en.wikipedia.org/wiki/Pip_(package_manager)).
100
104
  ```bash
101
- # Prerequisites: Python 3.11+
102
105
  pip install ai-code-review
106
+ ```
107
+
108
+ > **Troubleshooting:**
109
+ > pip may be also available via cli as `pip3` depending on your Python installation.
110
+
111
+ **Step2:** Perform initial setup
103
112
 
104
- # One-time setup using interactive wizard (saves configuration in ~/.env.ai-code-review)
113
+ The following command will perform one-time setup using an interactive wizard.
114
+ You will be prompted to enter LLM configuration details (API type, API key, etc).
115
+ Configuration will be saved to ~/.env.ai-code-review.
116
+
117
+ ```bash
105
118
  ai-code-review setup
119
+ ```
120
+
121
+ > **Troubleshooting:**
122
+ > On some systems, `ai-code-review` command may not became available immediately after installation.
123
+ > Try restarting your terminal or running `python -m ai_code_review` instead.
106
124
 
107
- # Run review on committed changes in current branch vs main
125
+
126
+ #### Perform your first AI code review locally
127
+
128
+ **Step1:** Navigate to your repository root directory.
129
+ **Step2:** Switch to the branch you want to review.
130
+ **Step3:** Run following command
131
+ ```bash
108
132
  ai-code-review
109
133
  ```
110
134
 
111
- To review a remote repository:
135
+ > **Note:** This will analyze the current branch against the repository main branch by default.
136
+ > Files that are not staged for commit will be ignored.
137
+ > See `ai-code-review --help` for more options.
138
+
139
+ **Reviewing remote repository**
112
140
 
113
141
  ```bash
114
- ai-code-review remote --url https://github.com/owner/repo --branch feature-branch
142
+ ai-code-review remote git@github.com:owner/repo.git <FEATURE_BRANCH>..<MAIN_BRANCH>
143
+ ```
144
+ Use interactive help for details:
145
+ ```bash
146
+ ai-code-review remote --help
115
147
  ```
116
148
 
117
149
  ## 🔧 Configuration
@@ -128,6 +160,8 @@ You can override the default config by placing `.ai-code-review.toml` in your re
128
160
 
129
161
  See default configuration [here](https://github.com/Nayjest/ai-code-review/blob/main/ai_code_review/.ai-code-review.toml).
130
162
 
163
+ More details can be found in [📖 Configuration Cookbook](https://github.com/Nayjest/ai-code-review/blob/main/documentation/config_cookbook.md)
164
+
131
165
  ## 💻 Development Setup
132
166
 
133
167
  Install dependencies:
@@ -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.5.0
46
+ run: pip install ai-code-review~=1.0
46
47
  - name: Run AI code analysis
47
48
  env:
48
49
  LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
@@ -56,34 +57,66 @@ jobs:
56
57
  with:
57
58
  name: ai-code-review-results
58
59
  path: |
59
- code-review-report.txt
60
+ code-review-report.md
60
61
  code-review-report.json
61
62
  ```
62
63
 
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
- ### 2. Run Code Analysis Locally
70
+ ### 2. Running Code Analysis Locally
69
71
 
70
- Install and run:
72
+ #### Initial Local Setup
71
73
 
74
+ **Prerequisites:** [Python](https://www.python.org/downloads/) 3.11 / 3.12 / 3.13
75
+
76
+ **Step1:** Install [ai-code-review](https://github.com/Nayjest/ai-code-review) using [pip](https://en.wikipedia.org/wiki/Pip_(package_manager)).
72
77
  ```bash
73
- # Prerequisites: Python 3.11+
74
78
  pip install ai-code-review
79
+ ```
80
+
81
+ > **Troubleshooting:**
82
+ > pip may be also available via cli as `pip3` depending on your Python installation.
83
+
84
+ **Step2:** Perform initial setup
75
85
 
76
- # One-time setup using interactive wizard (saves configuration in ~/.env.ai-code-review)
86
+ The following command will perform one-time setup using an interactive wizard.
87
+ You will be prompted to enter LLM configuration details (API type, API key, etc).
88
+ Configuration will be saved to ~/.env.ai-code-review.
89
+
90
+ ```bash
77
91
  ai-code-review setup
92
+ ```
93
+
94
+ > **Troubleshooting:**
95
+ > On some systems, `ai-code-review` command may not became available immediately after installation.
96
+ > Try restarting your terminal or running `python -m ai_code_review` instead.
78
97
 
79
- # Run review on committed changes in current branch vs main
98
+
99
+ #### Perform your first AI code review locally
100
+
101
+ **Step1:** Navigate to your repository root directory.
102
+ **Step2:** Switch to the branch you want to review.
103
+ **Step3:** Run following command
104
+ ```bash
80
105
  ai-code-review
81
106
  ```
82
107
 
83
- To review a remote repository:
108
+ > **Note:** This will analyze the current branch against the repository main branch by default.
109
+ > Files that are not staged for commit will be ignored.
110
+ > See `ai-code-review --help` for more options.
111
+
112
+ **Reviewing remote repository**
84
113
 
85
114
  ```bash
86
- ai-code-review remote --url https://github.com/owner/repo --branch feature-branch
115
+ ai-code-review remote git@github.com:owner/repo.git <FEATURE_BRANCH>..<MAIN_BRANCH>
116
+ ```
117
+ Use interactive help for details:
118
+ ```bash
119
+ ai-code-review remote --help
87
120
  ```
88
121
 
89
122
  ## 🔧 Configuration
@@ -100,6 +133,8 @@ You can override the default config by placing `.ai-code-review.toml` in your re
100
133
 
101
134
  See default configuration [here](https://github.com/Nayjest/ai-code-review/blob/main/ai_code_review/.ai-code-review.toml).
102
135
 
136
+ More details can be found in [📖 Configuration Cookbook](https://github.com/Nayjest/ai-code-review/blob/main/documentation/config_cookbook.md)
137
+
103
138
  ## 💻 Development Setup
104
139
 
105
140
  Install dependencies:
@@ -38,21 +38,23 @@ prompt = """
38
38
  ----TASK----
39
39
  Review the provided code diff carefully and identify *only* highly confident issues which are relevant to any code context.
40
40
 
41
- ----CODE----
41
+ ----CODEBASE CHANGES TO REVIEW----
42
42
  {{ input }}
43
43
  --------
44
44
 
45
45
  {% if file_lines -%}
46
- ----ADDITIONAL CONTEXT----
46
+ ----ADDITIONAL CONTEXT: FULL FILE CONTENT AFTER APPLYING REVIEWED CHANGES----
47
47
  {{ file_lines }}
48
48
  {%- endif %}
49
49
 
50
50
  ----TASK GUIDELINES----
51
51
  - Only report issues you are **100% confident** are relevant to any context.
52
+ - Never report issues about using software versions that have not yet been released.
52
53
  - Only include issues that are **significantly valuable** to the maintainers (e.g., bugs, security flaws, or clear maintainability concerns).
53
54
  - Do **not** report vague, theoretical, or overly generic advice.
54
55
  - Do **not** report anything with medium or lower confidence.
55
- - Typographical errors have highest severity
56
+ - Typographical errors have highest severity.
57
+ {{ requirements -}}
56
58
  {{ json_requirements }}
57
59
 
58
60
  Respond with a valid JSON array of issues in the following format:
@@ -124,8 +126,9 @@ If code changes contains exceptional achievements, you may additionally present
124
126
  --Available Awards--
125
127
  {{ awards }}
126
128
  ---
127
- Your response will be parsed programmatically, so do not include any additional text.
128
- Use Markdown formatting in your response.
129
+ - Your response will be parsed programmatically, so do not include any additional text.
130
+ - Use Markdown formatting in your response.
131
+ {{ summary_requirements -}}
129
132
  """
130
133
 
131
134
  [prompt_vars]
@@ -303,4 +306,6 @@ restored lost knowledge and now bestow it upon a new generation."
303
306
  decorators add depth and texture, and observer masterfully completes the composition.
304
307
  The Gang of Four gives a standing ovation from the stalls."
305
308
  ```
306
- """
309
+ """
310
+ requirements = ""
311
+ summary_requirements = ""
@@ -1,4 +1,4 @@
1
- from .cli import main
2
-
3
- if __name__ == "__main__":
4
- main()
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -59,4 +59,4 @@ def bootstrap():
59
59
  except Exception as e:
60
60
  logging.error(f"Unexpected configuration error: {e}")
61
61
  raise SystemExit(3)
62
- mc.logging.LoggingConfig.STRIP_REQUEST_LINES = [100, 15]
62
+ mc.logging.LoggingConfig.STRIP_REQUEST_LINES = [300, 15]
@@ -0,0 +1,238 @@
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, verbose: bool = typer.Option(default=False)):
36
+ if verbose:
37
+ mc.logging.LoggingConfig.STRIP_REQUEST_LINES = None
38
+ if ctx.invoked_subcommand != "setup":
39
+ bootstrap()
40
+
41
+
42
+ def args_to_target(refs, what, against) -> tuple[str | None, str | None]:
43
+ _what, _against = parse_refs_pair(refs)
44
+ if _what:
45
+ if what:
46
+ raise typer.BadParameter(
47
+ "You cannot specify both 'refs' <WHAT>..<AGAINST> and '--what'. Use one of them."
48
+ )
49
+ else:
50
+ _what = what
51
+ if _against:
52
+ if against:
53
+ raise typer.BadParameter(
54
+ "You cannot specify both 'refs' <WHAT>..<AGAINST> and '--against'. Use one of them."
55
+ )
56
+ else:
57
+ _against = against
58
+ return _what, _against
59
+
60
+
61
+ def arg_refs() -> typer.Argument:
62
+ return typer.Argument(
63
+ default=None,
64
+ help="Git refs to review, [what]..[against] e.g. 'HEAD..HEAD~1'"
65
+ )
66
+
67
+
68
+ def arg_what() -> typer.Option:
69
+ return typer.Option(None, "--what", "-w", help="Git ref to review")
70
+
71
+
72
+ def arg_filters() -> typer.Option:
73
+ return typer.Option(
74
+ "", "--filter", "-f", "--filters",
75
+ help="""
76
+ filter reviewed files by glob / fnmatch pattern(s),
77
+ e.g. 'src/**/*.py', may be comma-separated
78
+ """,
79
+ )
80
+
81
+
82
+ def arg_out() -> typer.Option:
83
+ return typer.Option(
84
+ None,
85
+ "--out", "-o", "--output",
86
+ help="Output folder for the code review report"
87
+ )
88
+
89
+
90
+ def arg_against() -> typer.Option:
91
+ return typer.Option(
92
+ None,
93
+ "--against", "-vs", "--vs",
94
+ help="Git ref to compare against"
95
+ )
96
+
97
+
98
+ @app_no_subcommand.command(name="review", help="Perform code review")
99
+ @app.command(name="review", help="Perform code review")
100
+ def cmd_review(
101
+ refs: str = arg_refs(),
102
+ what: str = arg_what(),
103
+ against: str = arg_against(),
104
+ filters: str = arg_filters(),
105
+ merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
106
+ out: str = arg_out()
107
+ ):
108
+ _what, _against = args_to_target(refs, what, against)
109
+ asyncio.run(review(
110
+ what=_what,
111
+ against=_against,
112
+ filters=filters,
113
+ use_merge_base=merge_base,
114
+ out_folder=out,
115
+ ))
116
+
117
+
118
+ @app.command(help="Configure LLM for local usage interactively")
119
+ def setup():
120
+ mc.interactive_setup(ENV_CONFIG_FILE)
121
+
122
+
123
+ @app.command()
124
+ def render(format: str = Report.Format.MARKDOWN):
125
+ print(Report.load().render(format=format))
126
+
127
+
128
+ @app.command(help="Review remote code")
129
+ def remote(
130
+ url: str = typer.Argument(..., help="Git repository URL"),
131
+ refs: str = arg_refs(),
132
+ what: str = arg_what(),
133
+ against: str = arg_against(),
134
+ filters: str = arg_filters(),
135
+ merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
136
+ out: str = arg_out()
137
+ ):
138
+ _what, _against = args_to_target(refs, what, against)
139
+ with tempfile.TemporaryDirectory() as temp_dir:
140
+ logging.info(f"Cloning [{mc.ui.green(url)}] to {mc.utils.file_link(temp_dir)} ...")
141
+ repo = Repo.clone_from(url, branch=_what, to_path=temp_dir)
142
+ asyncio.run(review(
143
+ repo=repo,
144
+ what=_what,
145
+ against=_against,
146
+ filters=filters,
147
+ use_merge_base=merge_base,
148
+ out_folder=out or '.',
149
+ ))
150
+ repo.close()
151
+
152
+
153
+ @app.command(help="Leave a GitHub PR comment with the review.")
154
+ def github_comment(
155
+ token: str = typer.Option(
156
+ os.environ.get("GITHUB_TOKEN", ""), help="GitHub token (or set GITHUB_TOKEN env var)"
157
+ ),
158
+ ):
159
+ """
160
+ Leaves a comment with the review on the current GitHub pull request.
161
+ """
162
+ file = "code-review-report.md"
163
+ if not os.path.exists(file):
164
+ print(f"Review file not found: {file}")
165
+ raise typer.Exit(4)
166
+
167
+ with open(file, "r", encoding="utf-8") as f:
168
+ body = f.read()
169
+
170
+ if not token:
171
+ print("GitHub token is required (--token or GITHUB_TOKEN env var).")
172
+ raise typer.Exit(1)
173
+
174
+ github_env = ProjectConfig.load().prompt_vars["github_env"]
175
+ repo = github_env.get("github_repo", "")
176
+ pr_env_val = github_env.get("github_pr_number", "")
177
+ logging.info(f"github_pr_number = {pr_env_val}")
178
+
179
+ # e.g. could be "refs/pull/123/merge" or a direct number
180
+ if "/" in pr_env_val and "pull" in pr_env_val:
181
+ # refs/pull/123/merge
182
+ try:
183
+ pr_num_candidate = pr_env_val.strip("/").split("/")
184
+ idx = pr_num_candidate.index("pull")
185
+ pr = int(pr_num_candidate[idx + 1])
186
+ except Exception:
187
+ pr = 0
188
+ else:
189
+ try:
190
+ pr = int(pr_env_val)
191
+ except Exception:
192
+ pr = 0
193
+
194
+ api_url = f"https://api.github.com/repos/{repo}/issues/{pr}/comments"
195
+ headers = {
196
+ "Authorization": f"token {token}",
197
+ "Accept": "application/vnd.github+json",
198
+ }
199
+ data = {"body": body}
200
+
201
+ resp = requests.post(api_url, headers=headers, json=data)
202
+ if 200 <= resp.status_code < 300:
203
+ logging.info(f"Posted review comment to PR #{pr} in {repo}")
204
+ else:
205
+ logging.error(f"Failed to post comment: {resp.status_code} {resp.reason}\n{resp.text}")
206
+ raise typer.Exit(5)
207
+
208
+
209
+ @app.command(help="List files in the diff. Might be useful to check what will be reviewed.")
210
+ def files(
211
+ refs: str = arg_refs(),
212
+ what: str = arg_what(),
213
+ against: str = arg_against(),
214
+ filters: str = arg_filters(),
215
+ merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
216
+ diff: bool = typer.Option(default=False, help="Show diff content")
217
+ ):
218
+ _what, _against = args_to_target(refs, what, against)
219
+ repo = Repo(".")
220
+ patch_set = get_diff(repo=repo, what=_what, against=_against, use_merge_base=merge_base)
221
+ patch_set = filter_diff(patch_set, filters)
222
+ print(
223
+ f"Changed files: "
224
+ f"{mc.ui.green(_what or 'INDEX')} vs "
225
+ f"{mc.ui.yellow(_against or repo.remotes.origin.refs.HEAD.reference.name)}"
226
+ f"{' filtered by '+mc.ui.cyan(filters) if filters else ''}"
227
+ )
228
+
229
+ for patch in patch_set:
230
+ if patch.is_added_file:
231
+ color = mc.ui.green
232
+ elif patch.is_removed_file:
233
+ color = mc.ui.red
234
+ else:
235
+ color = mc.ui.blue
236
+ print(f"- {color(patch.path)}")
237
+ if diff:
238
+ print(mc.ui.gray(textwrap.indent(str(patch), " ")))
@@ -0,0 +1,22 @@
1
+ """
2
+ Python REPL
3
+ """
4
+ # flake8: noqa: F401
5
+ import code
6
+ from ema.cli import app
7
+
8
+ # Imports for usage in REPL
9
+ import os
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from enum import Enum
14
+ from time import time
15
+ from rich.pretty import pprint
16
+
17
+ import microcore as mc
18
+ from microcore import ui
19
+
20
+ @app.command(help="python REPL")
21
+ def repl():
22
+ code.interact(local=globals())
@@ -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")
@@ -92,11 +92,11 @@ def is_running_in_github_action():
92
92
  return os.getenv("GITHUB_ACTIONS") == "true"
93
93
 
94
94
 
95
- def is_app_command_invocation(app: typer.Typer) -> bool:
95
+ def no_subcommand(app: typer.Typer) -> bool:
96
96
  """
97
97
  Checks if the current script is being invoked as a command in a target Typer application.
98
98
  """
99
- return (
99
+ return not (
100
100
  (first_arg := next((a for a in sys.argv[1:] if not a.startswith('-')), None))
101
101
  and first_arg in (
102
102
  cmd.name or cmd.callback.__name__.replace('_', '-')
@@ -106,7 +106,7 @@ def is_app_command_invocation(app: typer.Typer) -> bool:
106
106
  )
107
107
 
108
108
 
109
- def parse_refs_pair(refs: str):
109
+ def parse_refs_pair(refs: str) -> tuple[str | None, str | None]:
110
110
  SEPARATOR = '..'
111
111
  if not refs:
112
112
  return None, None
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "ai-cr"
3
- version = "0.5.0"
3
+ version = "1.0.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.dev18"
25
- GitPython = "3.1.44"
26
- unidiff = "0.7.5"
27
- google-generativeai = "0.8.5"
28
- anthropic = "0.52.2"
29
- typer = "0.9.4"
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 = "*"
@@ -1,157 +0,0 @@
1
- import asyncio
2
- import logging
3
- import sys
4
- import os
5
- import shutil
6
- import requests
7
-
8
- import microcore as mc
9
- import async_typer
10
- import typer
11
- from ai_code_review.utils import parse_refs_pair
12
- from git import Repo
13
-
14
- from .core import review
15
- from .report_struct import Report
16
- from .constants import ENV_CONFIG_FILE
17
- from .bootstrap import bootstrap
18
- from .project_config import ProjectConfig
19
- from .utils import is_app_command_invocation
20
-
21
-
22
- app = async_typer.AsyncTyper(pretty_exceptions_show_locals=False)
23
- default_command_app = async_typer.AsyncTyper(pretty_exceptions_show_locals=False)
24
- if sys.platform == "win32":
25
- asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
26
-
27
-
28
- def main():
29
- if is_app_command_invocation(app):
30
- app()
31
- else:
32
- bootstrap()
33
- default_command_app()
34
-
35
-
36
- @app.callback(invoke_without_command=True)
37
- def cli(ctx: typer.Context):
38
- if ctx.invoked_subcommand != "setup":
39
- bootstrap()
40
-
41
-
42
- @default_command_app.async_command(name="review", help="Perform code review")
43
- @app.async_command(name="review", help="Perform code review")
44
- async def cmd_review(
45
- refs: str = typer.Argument(
46
- default=None,
47
- help="Git refs to review, [what]..[against] e.g. 'HEAD..HEAD~1'"
48
- ),
49
- what: str = typer.Option(None, "--what", "-w", help="Git ref to review"),
50
- against: str = typer.Option(
51
- None,
52
- "--against", "-vs", "--vs",
53
- help="Git ref to compare against"
54
- ),
55
- filters: str = typer.Option(
56
- "", "--filter", "-f", "--filters",
57
- help="""
58
- filter reviewed files by glob / fnmatch pattern(s),
59
- e.g. 'src/**/*.py', may be comma-separated
60
- """,
61
- )
62
- ):
63
- _what, _against = parse_refs_pair(refs)
64
- if _what:
65
- if what:
66
- raise typer.BadParameter(
67
- "You cannot specify both 'refs' <WHAT>..<AGAINST> and '--what'. Use one of them."
68
- )
69
- else:
70
- _what = what
71
- if _against:
72
- if against:
73
- raise typer.BadParameter(
74
- "You cannot specify both 'refs' <WHAT>..<AGAINST> and '--against'. Use one of them."
75
- )
76
- else:
77
- _against = against
78
- await review(what=_what, against=_against, filters=filters)
79
-
80
-
81
- @app.async_command(help="Configure LLM for local usage interactively")
82
- async def setup():
83
- mc.interactive_setup(ENV_CONFIG_FILE)
84
-
85
-
86
- @app.async_command()
87
- async def render(format: str = Report.Format.MARKDOWN):
88
- print(Report.load().render(format=format))
89
-
90
-
91
- @app.async_command(help="Review remote code")
92
- async def remote(url=typer.Option(), branch=typer.Option()):
93
- if os.path.exists("reviewed-repo"):
94
- shutil.rmtree("reviewed-repo")
95
- Repo.clone_from(url, branch=branch, to_path="reviewed-repo")
96
- prev_dir = os.getcwd()
97
- try:
98
- os.chdir("reviewed-repo")
99
- await review()
100
- finally:
101
- os.chdir(prev_dir)
102
-
103
-
104
- @app.async_command(help="Leave a GitHub PR comment with the review.")
105
- async def github_comment(
106
- token: str = typer.Option(
107
- os.environ.get("GITHUB_TOKEN", ""), help="GitHub token (or set GITHUB_TOKEN env var)"
108
- ),
109
- ):
110
- """
111
- Leaves a comment with the review on the current GitHub pull request.
112
- """
113
- file = "code-review-report.txt"
114
- if not os.path.exists(file):
115
- print(f"Review file not found: {file}")
116
- raise typer.Exit(4)
117
-
118
- with open(file, "r", encoding="utf-8") as f:
119
- body = f.read()
120
-
121
- if not token:
122
- print("GitHub token is required (--token or GITHUB_TOKEN env var).")
123
- raise typer.Exit(1)
124
-
125
- github_env = ProjectConfig.load().prompt_vars["github_env"]
126
- repo = github_env.get("github_repo", "")
127
- pr_env_val = github_env.get("github_pr_number", "")
128
- logging.info(f"github_pr_number = {pr_env_val}")
129
-
130
- # e.g. could be "refs/pull/123/merge" or a direct number
131
- if "/" in pr_env_val and "pull" in pr_env_val:
132
- # refs/pull/123/merge
133
- try:
134
- pr_num_candidate = pr_env_val.strip("/").split("/")
135
- idx = pr_num_candidate.index("pull")
136
- pr = int(pr_num_candidate[idx + 1])
137
- except Exception:
138
- pr = 0
139
- else:
140
- try:
141
- pr = int(pr_env_val)
142
- except Exception:
143
- pr = 0
144
-
145
- api_url = f"https://api.github.com/repos/{repo}/issues/{pr}/comments"
146
- headers = {
147
- "Authorization": f"token {token}",
148
- "Accept": "application/vnd.github+json",
149
- }
150
- data = {"body": body}
151
-
152
- resp = requests.post(api_url, headers=headers, json=data)
153
- if 200 <= resp.status_code < 300:
154
- logging.info(f"Posted review comment to PR #{pr} in {repo}")
155
- else:
156
- logging.error(f"Failed to post comment: {resp.status_code} {resp.reason}\n{resp.text}")
157
- raise typer.Exit(5)
@@ -1,125 +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(
15
- repo: Repo = None,
16
- what: str = None,
17
- against: str = None
18
- ) -> PatchSet | list[PatchedFile]:
19
- repo = repo or Repo(".")
20
- if not against:
21
- against = repo.remotes.origin.refs.HEAD.reference.name # origin/main
22
- if not what:
23
- what = None # working copy
24
- logging.info(f"Reviewing {mc.ui.green(what or 'working copy')} vs {mc.ui.yellow(against)}")
25
- diff_content = repo.git.diff(against, what)
26
- diff = PatchSet.from_string(diff_content)
27
- return diff
28
-
29
-
30
- def filter_diff(
31
- patch_set: PatchSet | Iterable[PatchedFile], filters: str | list[str]
32
- ) -> PatchSet | Iterable[PatchedFile]:
33
- """
34
- Filter the diff files by the given fnmatch filters.
35
- """
36
- print([f.path for f in patch_set])
37
- assert isinstance(filters, (list, str))
38
- if not isinstance(filters, list):
39
- filters = [f.strip() for f in filters.split(",") if f.strip()]
40
- if not filters:
41
- return patch_set
42
- files = [
43
- file
44
- for file in patch_set
45
- if any(fnmatch.fnmatch(file.path, pattern) for pattern in filters)
46
- ]
47
- print([f.path for f in files])
48
- return files
49
-
50
-
51
- def file_lines(repo: Repo, file: str, max_tokens: int = None) -> str:
52
- text = repo.tree()[file].data_stream.read().decode()
53
- lines = [f"{i + 1}: {line}\n" for i, line in enumerate(text.splitlines())]
54
- if max_tokens:
55
- lines, removed_qty = mc.tokenizing.fit_to_token_size(lines, max_tokens)
56
- if removed_qty:
57
- lines.append(
58
- f"(!) DISPLAYING ONLY FIRST {len(lines)} LINES DUE TO LARGE FILE SIZE\n"
59
- )
60
- return "".join(lines)
61
-
62
-
63
- def make_cr_summary(cfg: ProjectConfig, report: Report, diff):
64
- return mc.prompt(
65
- cfg.summary_prompt,
66
- diff=mc.tokenizing.fit_to_token_size(diff, cfg.max_code_tokens)[0],
67
- issues=report.issues,
68
- **cfg.prompt_vars,
69
- ).to_llm() if cfg.summary_prompt else ""
70
-
71
-
72
- async def review(
73
- what: str = None,
74
- against: str = None,
75
- filters: str | list[str] = ""
76
- ):
77
- cfg = ProjectConfig.load()
78
- repo = Repo(".")
79
- diff = get_diff(repo=repo, what=what, against=against)
80
- diff = filter_diff(diff, filters)
81
- if not diff:
82
- logging.error("Nothing to review")
83
- return
84
- lines = {
85
- file_diff.path: (
86
- file_lines(
87
- repo,
88
- file_diff.path,
89
- cfg.max_code_tokens
90
- - mc.tokenizing.num_tokens_from_string(str(file_diff)),
91
- )
92
- if file_diff.target_file != DEV_NULL and not file_diff.is_added_file
93
- else ""
94
- )
95
- for file_diff in diff
96
- }
97
- responses = await mc.llm_parallel(
98
- [
99
- mc.prompt(
100
- cfg.prompt,
101
- input=file_diff,
102
- file_lines=lines[file_diff.path],
103
- **cfg.prompt_vars,
104
- )
105
- for file_diff in diff
106
- ],
107
- retries=cfg.retries,
108
- parse_json=True,
109
- )
110
- issues = {file.path: issues for file, issues in zip(diff, responses) if issues}
111
- for file, file_issues in issues.items():
112
- for issue in file_issues:
113
- for i in issue.get("affected_lines", []):
114
- if lines[file]:
115
- f_lines = [""] + lines[file].splitlines()
116
- i["affected_code"] = "\n".join(
117
- f_lines[i["start_line"]: i["end_line"]+1]
118
- )
119
- exec(cfg.post_process, {"mc": mc, **locals()})
120
- report = Report(issues=issues, number_of_processed_files=len(diff))
121
- report.summary = make_cr_summary(cfg, report, diff)
122
- report.save()
123
- report_text = report.render(cfg, Report.Format.MARKDOWN)
124
- print(mc.ui.yellow(report_text))
125
- open("code-review-report.txt", "w", encoding="utf-8").write(report_text)
File without changes
File without changes
File without changes