ai-cr 0.5.0__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.5.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 (==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
 
@@ -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
 
@@ -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
 
@@ -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
 
@@ -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,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), " ")))
@@ -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 = "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.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