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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ai-cr
3
- Version: 0.4.8
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 (==3.1.44)
19
- Requires-Dist: ai-microcore (==4.0.0.dev16)
20
- Requires-Dist: anthropic (==0.49.0)
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.4.7
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.4.7
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
 
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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
- ref = f" ({ref})"
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 = [100, 15]
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 = ""#Path("~/.env.ai-code-review").expanduser()
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.4.8"
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.dev16"
25
- GitPython = "3.1.44"
26
- unidiff = "0.7.5"
27
- google-generativeai = "0.8.5"
28
- anthropic = "0.49.0"
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 = "*"
@@ -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:app"
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