github-code-review 3.2.0__tar.gz → 3.4.1__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.
- {github_code_review-3.2.0 → github_code_review-3.4.1}/PKG-INFO +17 -14
- {github_code_review-3.2.0 → github_code_review-3.4.1}/README.md +9 -9
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/bootstrap.py +6 -1
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/cli.py +30 -4
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/cli_base.py +11 -1
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/config.toml +16 -1
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/constants.py +1 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/core.py +173 -89
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/env.py +1 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/gh_api.py +3 -3
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/project_config.py +4 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/report_struct.py +55 -29
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/utils.py +31 -3
- {github_code_review-3.2.0 → github_code_review-3.4.1}/pyproject.toml +8 -7
- {github_code_review-3.2.0 → github_code_review-3.4.1}/LICENSE +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/__init__.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/__main__.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/__init__.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/deploy.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/fix.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/gh_post_review_comment.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/gh_react_to_comment.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/linear_comment.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/repl.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/version.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/context.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/issue_trackers.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/pipeline.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/pipeline_steps/__init__.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/pipeline_steps/jira.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/pipeline_steps/linear.py +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/answer.j2 +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/github_workflows/components/env-vars.j2 +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/github_workflows/components/installs.j2 +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/github_workflows/gito-code-review.yml.j2 +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/github_workflows/gito-react-to-comments.yml.j2 +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/partial/aux_files.j2 +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/questions/changes_summary.j2 +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/questions/release_notes.j2 +0 -0
- {github_code_review-3.2.0 → github_code_review-3.4.1}/gito/tpl/questions/test_cases.j2 +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: github-code-review
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.1
|
|
4
4
|
Summary: AI code review tool that works with any language model provider. It detects issues in GitHub pull requests or local changes—instantly, reliably, and without vendor lock-in.
|
|
5
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Keywords: static code analysis,code review,code quality,ai,coding,assistant,llm,github,automation,devops,developer tools,github actions,workflows,git
|
|
7
8
|
Author: Nayjest
|
|
8
9
|
Author-email: mail@vitaliy.in
|
|
@@ -14,14 +15,16 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
19
|
Classifier: Topic :: Software Development
|
|
18
20
|
Requires-Dist: GitPython (>=3.1.44,<4.0.0)
|
|
19
|
-
Requires-Dist: ai-microcore (
|
|
20
|
-
Requires-Dist: anthropic (>=0.57.1,<
|
|
21
|
+
Requires-Dist: ai-microcore (>=4.5,<4.6)
|
|
22
|
+
Requires-Dist: anthropic (>=0.57.1,<1)
|
|
21
23
|
Requires-Dist: ghapi (>=1.0.6,<1.1.0)
|
|
22
24
|
Requires-Dist: google-generativeai (>=0.8.5,<0.9.0)
|
|
23
25
|
Requires-Dist: jira (>=3.8.0,<4.0.0)
|
|
24
|
-
Requires-Dist:
|
|
26
|
+
Requires-Dist: pydantic (>=2.12.3,<3.0.0)
|
|
27
|
+
Requires-Dist: typer (>=0.16.0,<0.21)
|
|
25
28
|
Requires-Dist: unidiff (>=0.7.5,<0.8.0)
|
|
26
29
|
Project-URL: Homepage, https://github.com/Nayjest/Gito
|
|
27
30
|
Project-URL: Repository, https://github.com/Nayjest/Gito
|
|
@@ -99,7 +102,7 @@ jobs:
|
|
|
99
102
|
uses: actions/setup-python@v5
|
|
100
103
|
with: { python-version: "3.13" }
|
|
101
104
|
- name: Install AI Code Review tool
|
|
102
|
-
run: pip install gito.bot~=3.
|
|
105
|
+
run: pip install gito.bot~=3.4
|
|
103
106
|
- name: Run AI code analysis
|
|
104
107
|
env:
|
|
105
108
|
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
|
@@ -130,15 +133,15 @@ See [GitHub Setup Guide](https://github.com/Nayjest/Gito/blob/main/documentation
|
|
|
130
133
|
|
|
131
134
|
**Prerequisites:** [Python](https://www.python.org/downloads/) 3.11 / 3.12 / 3.13
|
|
132
135
|
|
|
133
|
-
**
|
|
136
|
+
**Step 1:** Install [gito.bot](https://github.com/Nayjest/Gito) using [pip](https://en.wikipedia.org/wiki/Pip_(package_manager)).
|
|
134
137
|
```bash
|
|
135
138
|
pip install gito.bot
|
|
136
139
|
```
|
|
137
140
|
|
|
138
141
|
> **Troubleshooting:**
|
|
139
|
-
> pip may be
|
|
142
|
+
> pip may also be available via cli as `pip3` depending on your Python installation.
|
|
140
143
|
|
|
141
|
-
**
|
|
144
|
+
**Step 2:** Perform initial setup
|
|
142
145
|
|
|
143
146
|
The following command will perform one-time setup using an interactive wizard.
|
|
144
147
|
You will be prompted to enter LLM configuration details (API type, API key, etc).
|
|
@@ -149,15 +152,15 @@ gito setup
|
|
|
149
152
|
```
|
|
150
153
|
|
|
151
154
|
> **Troubleshooting:**
|
|
152
|
-
> On some systems, `gito` command may not
|
|
155
|
+
> On some systems, `gito` command may not become available immediately after installation.
|
|
153
156
|
> Try restarting your terminal or running `python -m gito` instead.
|
|
154
157
|
|
|
155
158
|
|
|
156
159
|
#### Perform your first AI code review locally
|
|
157
160
|
|
|
158
|
-
**
|
|
159
|
-
**
|
|
160
|
-
**
|
|
161
|
+
**Step 1:** Navigate to your repository root directory.
|
|
162
|
+
**Step 2:** Switch to the branch you want to review.
|
|
163
|
+
**Step 3:** Run following command
|
|
161
164
|
```bash
|
|
162
165
|
gito review
|
|
163
166
|
```
|
|
@@ -231,5 +234,5 @@ See [CONTRIBUTING.md](https://github.com/Nayjest/Gito/blob/main/CONTRIBUTING.md)
|
|
|
231
234
|
|
|
232
235
|
Licensed under the [MIT License](https://github.com/Nayjest/Gito/blob/main/LICENSE).
|
|
233
236
|
|
|
234
|
-
© 2025 [Vitalii Stepanenko](mailto:mail@vitaliy.in)
|
|
237
|
+
© 2025–2026 [Vitalii Stepanenko](mailto:mail@vitaliy.in)
|
|
235
238
|
|
|
@@ -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 gito.bot~=3.
|
|
73
|
+
run: pip install gito.bot~=3.4
|
|
74
74
|
- name: Run AI code analysis
|
|
75
75
|
env:
|
|
76
76
|
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
|
@@ -101,15 +101,15 @@ See [GitHub Setup Guide](https://github.com/Nayjest/Gito/blob/main/documentation
|
|
|
101
101
|
|
|
102
102
|
**Prerequisites:** [Python](https://www.python.org/downloads/) 3.11 / 3.12 / 3.13
|
|
103
103
|
|
|
104
|
-
**
|
|
104
|
+
**Step 1:** Install [gito.bot](https://github.com/Nayjest/Gito) using [pip](https://en.wikipedia.org/wiki/Pip_(package_manager)).
|
|
105
105
|
```bash
|
|
106
106
|
pip install gito.bot
|
|
107
107
|
```
|
|
108
108
|
|
|
109
109
|
> **Troubleshooting:**
|
|
110
|
-
> pip may be
|
|
110
|
+
> pip may also be available via cli as `pip3` depending on your Python installation.
|
|
111
111
|
|
|
112
|
-
**
|
|
112
|
+
**Step 2:** Perform initial setup
|
|
113
113
|
|
|
114
114
|
The following command will perform one-time setup using an interactive wizard.
|
|
115
115
|
You will be prompted to enter LLM configuration details (API type, API key, etc).
|
|
@@ -120,15 +120,15 @@ gito setup
|
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
> **Troubleshooting:**
|
|
123
|
-
> On some systems, `gito` command may not
|
|
123
|
+
> On some systems, `gito` command may not become available immediately after installation.
|
|
124
124
|
> Try restarting your terminal or running `python -m gito` instead.
|
|
125
125
|
|
|
126
126
|
|
|
127
127
|
#### Perform your first AI code review locally
|
|
128
128
|
|
|
129
|
-
**
|
|
130
|
-
**
|
|
131
|
-
**
|
|
129
|
+
**Step 1:** Navigate to your repository root directory.
|
|
130
|
+
**Step 2:** Switch to the branch you want to review.
|
|
131
|
+
**Step 3:** Run following command
|
|
132
132
|
```bash
|
|
133
133
|
gito review
|
|
134
134
|
```
|
|
@@ -202,4 +202,4 @@ See [CONTRIBUTING.md](https://github.com/Nayjest/Gito/blob/main/CONTRIBUTING.md)
|
|
|
202
202
|
|
|
203
203
|
Licensed under the [MIT License](https://github.com/Nayjest/Gito/blob/main/LICENSE).
|
|
204
204
|
|
|
205
|
-
© 2025 [Vitalii Stepanenko](mailto:mail@vitaliy.in)
|
|
205
|
+
© 2025–2026 [Vitalii Stepanenko](mailto:mail@vitaliy.in)
|
|
@@ -13,6 +13,7 @@ from .env import Env
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def setup_logging(log_level: int = logging.INFO):
|
|
16
|
+
"""Setup custom CLI logging format with colored output."""
|
|
16
17
|
class CustomFormatter(logging.Formatter):
|
|
17
18
|
def format(self, record):
|
|
18
19
|
dt = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
|
|
@@ -23,7 +24,11 @@ def setup_logging(log_level: int = logging.INFO):
|
|
|
23
24
|
if record.levelno >= logging.ERROR:
|
|
24
25
|
message = mc.ui.red(message)
|
|
25
26
|
level_name = mc.ui.red(level_name)
|
|
26
|
-
|
|
27
|
+
|
|
28
|
+
formatted_message = f"{dt} {level_name}: {message}"
|
|
29
|
+
if record.exc_info:
|
|
30
|
+
formatted_message += "\n" + self.formatException(record.exc_info)
|
|
31
|
+
return formatted_message
|
|
27
32
|
|
|
28
33
|
handler = logging.StreamHandler()
|
|
29
34
|
handler.setFormatter(CustomFormatter())
|
|
@@ -7,6 +7,7 @@ import textwrap
|
|
|
7
7
|
import microcore as mc
|
|
8
8
|
import typer
|
|
9
9
|
from git import Repo
|
|
10
|
+
from gito.constants import REFS_VALUE_ALL
|
|
10
11
|
|
|
11
12
|
from .core import review, get_diff, filter_diff, answer
|
|
12
13
|
from .cli_base import (
|
|
@@ -24,6 +25,7 @@ from .constants import HOME_ENV_PATH, GITHUB_MD_REPORT_FILE_NAME
|
|
|
24
25
|
from .bootstrap import bootstrap
|
|
25
26
|
from .utils import no_subcommand, extract_gh_owner_repo, remove_html_comments
|
|
26
27
|
from .gh_api import resolve_gh_token
|
|
28
|
+
from .project_config import ProjectConfig
|
|
27
29
|
|
|
28
30
|
# Import fix command to register it
|
|
29
31
|
from .commands import fix, gh_react_to_comment, repl, deploy, version # noqa
|
|
@@ -97,6 +99,7 @@ def cmd_review(
|
|
|
97
99
|
filters: str = arg_filters(),
|
|
98
100
|
merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
|
|
99
101
|
url: str = typer.Option("", "--url", help="Git repository URL"),
|
|
102
|
+
path: str = typer.Option("", "--path", help="Git repository path"),
|
|
100
103
|
post_comment: bool = typer.Option(default=False, help="Post review comment to GitHub"),
|
|
101
104
|
pr: int = typer.Option(
|
|
102
105
|
default=None,
|
|
@@ -106,8 +109,17 @@ def cmd_review(
|
|
|
106
109
|
in the github actions PR is resolved from the environment)
|
|
107
110
|
""")
|
|
108
111
|
),
|
|
109
|
-
out: str = arg_out()
|
|
112
|
+
out: str = arg_out(),
|
|
113
|
+
all: bool = typer.Option(default=False, help="Review all codebase"),
|
|
110
114
|
):
|
|
115
|
+
if all:
|
|
116
|
+
if refs and refs != REFS_VALUE_ALL:
|
|
117
|
+
raise typer.BadParameter(
|
|
118
|
+
"The --all option overrides the refs argument. "
|
|
119
|
+
"Please remove the refs argument if you want to review all codebase."
|
|
120
|
+
)
|
|
121
|
+
refs = REFS_VALUE_ALL
|
|
122
|
+
merge_base = False
|
|
111
123
|
_what, _against = args_to_target(refs, what, against)
|
|
112
124
|
pr = pr or os.getenv("PR_NUMBER_FROM_WORKFLOW_DISPATCH")
|
|
113
125
|
with get_repo_context(url, _what) as (repo, out_folder):
|
|
@@ -160,6 +172,11 @@ def cmd_answer(
|
|
|
160
172
|
aux_files: list[str] = typer.Option(
|
|
161
173
|
default=None,
|
|
162
174
|
help="Auxiliary files that might be helpful"
|
|
175
|
+
),
|
|
176
|
+
save_to: str = typer.Option(
|
|
177
|
+
help="Save answer to file",
|
|
178
|
+
default=None,
|
|
179
|
+
show_default=False
|
|
163
180
|
)
|
|
164
181
|
):
|
|
165
182
|
_what, _against = args_to_target(refs, what, against)
|
|
@@ -183,6 +200,11 @@ def cmd_answer(
|
|
|
183
200
|
if post_to == 'linear':
|
|
184
201
|
logging.info("Posting answer to Linear...")
|
|
185
202
|
linear_comment(remove_html_comments(out))
|
|
203
|
+
if save_to:
|
|
204
|
+
with open(save_to, "w", encoding="utf-8") as f:
|
|
205
|
+
f.write(out)
|
|
206
|
+
logging.info(f"Answer saved to {mc.utils.file_link(save_to)}")
|
|
207
|
+
|
|
186
208
|
return out
|
|
187
209
|
|
|
188
210
|
|
|
@@ -191,8 +213,8 @@ def setup():
|
|
|
191
213
|
mc.interactive_setup(HOME_ENV_PATH)
|
|
192
214
|
|
|
193
215
|
|
|
194
|
-
@app.command(name="
|
|
195
|
-
@app.command(name="
|
|
216
|
+
@app.command(name="report", help="Render and display code review report.")
|
|
217
|
+
@app.command(name="render", hidden=True)
|
|
196
218
|
def render(
|
|
197
219
|
format: str = typer.Argument(default=Report.Format.CLI),
|
|
198
220
|
source: str = typer.Option(
|
|
@@ -223,11 +245,15 @@ def files(
|
|
|
223
245
|
try:
|
|
224
246
|
patch_set = get_diff(repo=repo, what=_what, against=_against, use_merge_base=merge_base)
|
|
225
247
|
patch_set = filter_diff(patch_set, filters)
|
|
248
|
+
cfg = ProjectConfig.load_for_repo(repo)
|
|
249
|
+
if cfg.exclude_files:
|
|
250
|
+
patch_set = filter_diff(patch_set, cfg.exclude_files, exclude=True)
|
|
226
251
|
print(
|
|
227
252
|
f"Changed files: "
|
|
228
253
|
f"{mc.ui.green(_what or 'INDEX')} vs "
|
|
229
254
|
f"{mc.ui.yellow(_against or repo.remotes.origin.refs.HEAD.reference.name)}"
|
|
230
|
-
f"{' filtered by ' + mc.ui.cyan(filters) if filters else ''}"
|
|
255
|
+
f"{' filtered by ' + mc.ui.cyan(filters) if filters else ''} --> "
|
|
256
|
+
f"{mc.ui.cyan(len(patch_set or []))} file(s)."
|
|
231
257
|
)
|
|
232
258
|
|
|
233
259
|
for patch in patch_set:
|
|
@@ -5,10 +5,15 @@ import tempfile
|
|
|
5
5
|
import microcore as mc
|
|
6
6
|
import typer
|
|
7
7
|
from git import Repo
|
|
8
|
-
from gito.
|
|
8
|
+
from gito.constants import REFS_VALUE_ALL
|
|
9
|
+
|
|
10
|
+
from .utils import parse_refs_pair
|
|
11
|
+
from .env import Env
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
def args_to_target(refs, what, against) -> tuple[str | None, str | None]:
|
|
15
|
+
if refs == REFS_VALUE_ALL:
|
|
16
|
+
return REFS_VALUE_ALL, None
|
|
12
17
|
_what, _against = parse_refs_pair(refs)
|
|
13
18
|
if _what:
|
|
14
19
|
if what:
|
|
@@ -73,6 +78,8 @@ app = typer.Typer(pretty_exceptions_show_locals=False)
|
|
|
73
78
|
|
|
74
79
|
@contextlib.contextmanager
|
|
75
80
|
def get_repo_context(url: str, branch: str):
|
|
81
|
+
if branch == REFS_VALUE_ALL:
|
|
82
|
+
branch = None
|
|
76
83
|
"""Context manager for handling both local and remote repositories."""
|
|
77
84
|
if url:
|
|
78
85
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
@@ -81,10 +88,13 @@ def get_repo_context(url: str, branch: str):
|
|
|
81
88
|
f"Cloning [{mc.ui.green(url)}] to {mc.utils.file_link(temp_dir)} ..."
|
|
82
89
|
)
|
|
83
90
|
repo = Repo.clone_from(url, branch=branch, to_path=temp_dir)
|
|
91
|
+
prev_folder = Env.working_folder
|
|
92
|
+
Env.working_folder = temp_dir
|
|
84
93
|
try:
|
|
85
94
|
yield repo, temp_dir
|
|
86
95
|
finally:
|
|
87
96
|
repo.close()
|
|
97
|
+
Env.working_folder = prev_folder
|
|
88
98
|
else:
|
|
89
99
|
logging.info("get_repo_context: Using local repo...")
|
|
90
100
|
repo = Repo(".")
|
|
@@ -37,6 +37,12 @@ report_template_md = """
|
|
|
37
37
|
{%- endfor -%}
|
|
38
38
|
{{ "\n" }}
|
|
39
39
|
{%- endfor -%}
|
|
40
|
+
{%- if report.processing_warnings -%}
|
|
41
|
+
{{- "\n\n" }}## Processing Warnings
|
|
42
|
+
{%- for warning in report.processing_warnings -%}
|
|
43
|
+
{{- "\n" }} - {{ warning.message -}}
|
|
44
|
+
{%- endfor -%}
|
|
45
|
+
{%- endif -%}
|
|
40
46
|
{{- HTML_CR_COMMENT_MARKER -}}
|
|
41
47
|
"""
|
|
42
48
|
report_template_cli = """
|
|
@@ -109,6 +115,13 @@ report_template_cli = """
|
|
|
109
115
|
{%- endfor -%}
|
|
110
116
|
{{ "\n" }}
|
|
111
117
|
{%- endfor -%}
|
|
118
|
+
{%- if report.processing_warnings -%}
|
|
119
|
+
{{- "\n" }}
|
|
120
|
+
{{- "\n" }}{{ Style.BRIGHT }}⚠️ PROCESSING WARNINGS{{ Style.RESET_ALL -}}
|
|
121
|
+
{%- for warning in report.processing_warnings -%}
|
|
122
|
+
{{- "\n" }} {{ Fore.YELLOW }}- {{ warning.message }}{{ Style.RESET_ALL -}}
|
|
123
|
+
{%- endfor -%}
|
|
124
|
+
{%- endif -%}
|
|
112
125
|
"""
|
|
113
126
|
retries = 3
|
|
114
127
|
prompt = """
|
|
@@ -184,7 +197,7 @@ Confidence scale:
|
|
|
184
197
|
- 1 — Highest, 100% confidence that code requires changes in any context
|
|
185
198
|
- 2 — Very High
|
|
186
199
|
- 3 — High
|
|
187
|
-
- 4 — Medium
|
|
200
|
+
- 4 — Medium, Should not be reported
|
|
188
201
|
|
|
189
202
|
(!) - If no issues found according to the criteria, respond with empty list: []
|
|
190
203
|
"""
|
|
@@ -204,12 +217,14 @@ Summarize the code review in one sentence.
|
|
|
204
217
|
--Issues Detected by You--
|
|
205
218
|
{{ issues | tojson(indent=2) }}
|
|
206
219
|
---
|
|
220
|
+
{% if awards -%}
|
|
207
221
|
If the code changes include exceptional achievements, you may also present an award to the author in the summary text.
|
|
208
222
|
- (!) Only give awards to initial codebase authors, NOT to reviewers.
|
|
209
223
|
- (!) If you give an award, place the hidden <!-- award --> HTML comment on its own line immediately before the award text.
|
|
210
224
|
--Available Awards--
|
|
211
225
|
{{ awards }}
|
|
212
226
|
---
|
|
227
|
+
{%- endif %}
|
|
213
228
|
{% if pipeline_out.associated_issue and pipeline_out.associated_issue.title %}
|
|
214
229
|
----SUBTASK----
|
|
215
230
|
Include one sentence about how the code changes address the requirements of the associated issue listed below.
|
|
@@ -12,3 +12,4 @@ EXECUTABLE = "gito"
|
|
|
12
12
|
TEXT_ICON_URL = 'https://raw.githubusercontent.com/Nayjest/Gito/main/press-kit/logo/gito-bot-1_64top.png' # noqa: E501
|
|
13
13
|
HTML_TEXT_ICON = f'<a href="https://github.com/Nayjest/Gito"><img src="{TEXT_ICON_URL}" align="left" width=64 height=50 title="Gito v{Env.gito_version}"/></a>' # noqa: E501
|
|
14
14
|
HTML_CR_COMMENT_MARKER = '<!-- GITO_COMMENT:CODE_REVIEW_REPORT -->'
|
|
15
|
+
REFS_VALUE_ALL = '!all'
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gito core business logic.
|
|
3
|
+
"""
|
|
1
4
|
import os
|
|
2
5
|
import fnmatch
|
|
3
6
|
import logging
|
|
@@ -6,7 +9,6 @@ from pathlib import Path
|
|
|
6
9
|
from functools import partial
|
|
7
10
|
|
|
8
11
|
import microcore as mc
|
|
9
|
-
from gito.gh_api import gh_api
|
|
10
12
|
from microcore import ui
|
|
11
13
|
from git import Repo, Commit
|
|
12
14
|
from git.exc import GitCommandError
|
|
@@ -15,11 +17,12 @@ from unidiff.constants import DEV_NULL
|
|
|
15
17
|
|
|
16
18
|
from .context import Context
|
|
17
19
|
from .project_config import ProjectConfig
|
|
18
|
-
from .report_struct import Report
|
|
19
|
-
from .constants import JSON_REPORT_FILE_NAME
|
|
20
|
+
from .report_struct import ProcessingWarning, Report, RawIssue
|
|
21
|
+
from .constants import JSON_REPORT_FILE_NAME, REFS_VALUE_ALL
|
|
20
22
|
from .utils import make_streaming_function
|
|
21
23
|
from .pipeline import Pipeline
|
|
22
24
|
from .env import Env
|
|
25
|
+
from .gh_api import gh_api
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
def review_subject_is_index(what):
|
|
@@ -94,7 +97,7 @@ def get_base_branch(repo: Repo, pr: int | str = None):
|
|
|
94
97
|
except AttributeError:
|
|
95
98
|
try:
|
|
96
99
|
logging.info(
|
|
97
|
-
"Checking if repo has 'main' or 'master'
|
|
100
|
+
"Checking if repo has 'main' or 'master' branches to use as --against branch..."
|
|
98
101
|
)
|
|
99
102
|
remote_refs = repo.remotes.origin.refs
|
|
100
103
|
for branch_name in ['main', 'master']:
|
|
@@ -115,91 +118,99 @@ def get_diff(
|
|
|
115
118
|
pr: str | int = None
|
|
116
119
|
) -> PatchSet | list[PatchedFile]:
|
|
117
120
|
repo = repo or Repo(".")
|
|
121
|
+
if what == REFS_VALUE_ALL:
|
|
122
|
+
what = get_base_branch(repo, pr=pr)
|
|
123
|
+
# Git's canonical empty tree hash
|
|
124
|
+
against = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
125
|
+
use_merge_base = False
|
|
118
126
|
if not against:
|
|
119
127
|
# 'origin/main', 'origin/master', etc
|
|
120
128
|
against = get_base_branch(repo, pr=pr)
|
|
121
129
|
if review_subject_is_index(what):
|
|
122
130
|
what = None # working copy
|
|
123
131
|
if use_merge_base:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"Detected detached HEAD state, using HEAD as current reference"
|
|
132
|
-
)
|
|
133
|
-
else:
|
|
134
|
-
current_ref = what
|
|
135
|
-
merge_base = repo.merge_base(current_ref or repo.active_branch.name, against)[0]
|
|
136
|
-
logging.info(
|
|
137
|
-
f"Merge base({ui.green(current_ref)},{ui.yellow(against)})"
|
|
138
|
-
f" --> {ui.cyan(merge_base.hexsha)}"
|
|
139
|
-
)
|
|
140
|
-
# if branch is already an ancestor of "against", merge_base == branch ⇒ it’s been merged
|
|
141
|
-
if merge_base.hexsha == repo.commit(current_ref or repo.active_branch.name).hexsha:
|
|
142
|
-
# @todo: check case: reviewing working copy index in main branch #103
|
|
143
|
-
logging.info(
|
|
144
|
-
f"Branch is already merged. ({ui.green(current_ref)} vs {ui.yellow(against)})"
|
|
145
|
-
)
|
|
146
|
-
merge_sha = repo.git.log(
|
|
147
|
-
'--merges',
|
|
148
|
-
'--ancestry-path',
|
|
149
|
-
f'{current_ref}..{against}',
|
|
150
|
-
'-n',
|
|
151
|
-
'1',
|
|
152
|
-
'--pretty=format:%H'
|
|
153
|
-
).strip()
|
|
154
|
-
if merge_sha:
|
|
155
|
-
logging.info(f"Merge commit is {ui.cyan(merge_sha)}")
|
|
156
|
-
merge_commit = repo.commit(merge_sha)
|
|
157
|
-
|
|
158
|
-
other_merge_parent = None
|
|
159
|
-
for parent in merge_commit.parents:
|
|
160
|
-
logging.info(f"Checking merge parent: {parent.hexsha[:8]}")
|
|
161
|
-
if parent.hexsha == merge_base.hexsha:
|
|
162
|
-
logging.info(f"merge parent is {ui.cyan(parent.hexsha[:8])}, skipping")
|
|
163
|
-
continue
|
|
164
|
-
if not commit_in_branch(repo, parent, against):
|
|
165
|
-
logging.warning(f"merge parent is not in {against}, skipping")
|
|
166
|
-
continue
|
|
167
|
-
logging.info(f"Found other merge parent: {ui.cyan(parent.hexsha[:8])}")
|
|
168
|
-
other_merge_parent = parent
|
|
169
|
-
break
|
|
170
|
-
if other_merge_parent:
|
|
171
|
-
first_common_ancestor = repo.merge_base(other_merge_parent, merge_base)[0]
|
|
172
|
-
# for gito remote (feature_branch vs origin/main)
|
|
173
|
-
# the same merge base appears in first_common_ancestor again
|
|
174
|
-
if first_common_ancestor.hexsha == merge_base.hexsha:
|
|
175
|
-
if merge_base.parents:
|
|
176
|
-
first_common_ancestor = repo.merge_base(
|
|
177
|
-
other_merge_parent, merge_base.parents[0]
|
|
178
|
-
)[0]
|
|
179
|
-
else:
|
|
180
|
-
logging.error(
|
|
181
|
-
"merge_base has no parents, "
|
|
182
|
-
"using merge_base as first_common_ancestor"
|
|
183
|
-
)
|
|
132
|
+
try:
|
|
133
|
+
if review_subject_is_index(what):
|
|
134
|
+
try:
|
|
135
|
+
current_ref = repo.active_branch.name
|
|
136
|
+
except TypeError:
|
|
137
|
+
# In detached HEAD state, use HEAD directly
|
|
138
|
+
current_ref = "HEAD"
|
|
184
139
|
logging.info(
|
|
185
|
-
|
|
186
|
-
f"first common ancestor of {what} and {against}: "
|
|
187
|
-
f"{ui.cyan(first_common_ancestor.hexsha[:8])}"
|
|
140
|
+
"Detected detached HEAD state, using HEAD as current reference"
|
|
188
141
|
)
|
|
189
|
-
against = first_common_ancestor.hexsha
|
|
190
|
-
else:
|
|
191
|
-
logging.error(f"Can't find other merge parent for {merge_sha}")
|
|
192
142
|
else:
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
"falling back to merge‐base diff"
|
|
196
|
-
)
|
|
197
|
-
else:
|
|
198
|
-
# normal case: branch not yet merged
|
|
199
|
-
against = merge_base.hexsha
|
|
143
|
+
current_ref = what
|
|
144
|
+
merge_base = repo.merge_base(current_ref or repo.active_branch.name, against)[0]
|
|
200
145
|
logging.info(
|
|
201
|
-
f"
|
|
146
|
+
f"Merge base({ui.green(current_ref)},{ui.yellow(against)})"
|
|
147
|
+
f" --> {ui.cyan(merge_base.hexsha)}"
|
|
202
148
|
)
|
|
149
|
+
# if branch is already an ancestor of "against", merge_base == branch ⇒ it’s been merged
|
|
150
|
+
if merge_base.hexsha == repo.commit(current_ref or repo.active_branch.name).hexsha:
|
|
151
|
+
# @todo: check case: reviewing working copy index in main branch #103
|
|
152
|
+
logging.info(
|
|
153
|
+
f"Branch is already merged. ({ui.green(current_ref)} vs {ui.yellow(against)})"
|
|
154
|
+
)
|
|
155
|
+
merge_sha = repo.git.log(
|
|
156
|
+
'--merges',
|
|
157
|
+
'--ancestry-path',
|
|
158
|
+
f'{current_ref}..{against}',
|
|
159
|
+
'-n',
|
|
160
|
+
'1',
|
|
161
|
+
'--pretty=format:%H'
|
|
162
|
+
).strip()
|
|
163
|
+
if merge_sha:
|
|
164
|
+
logging.info(f"Merge commit is {ui.cyan(merge_sha)}")
|
|
165
|
+
merge_commit = repo.commit(merge_sha)
|
|
166
|
+
|
|
167
|
+
other_merge_parent = None
|
|
168
|
+
for parent in merge_commit.parents:
|
|
169
|
+
logging.info(f"Checking merge parent: {parent.hexsha[:8]}")
|
|
170
|
+
if parent.hexsha == merge_base.hexsha:
|
|
171
|
+
logging.info(f"merge parent is {ui.cyan(parent.hexsha[:8])}, skipping")
|
|
172
|
+
continue
|
|
173
|
+
if not commit_in_branch(repo, parent, against):
|
|
174
|
+
logging.warning(f"merge parent is not in {against}, skipping")
|
|
175
|
+
continue
|
|
176
|
+
logging.info(f"Found other merge parent: {ui.cyan(parent.hexsha[:8])}")
|
|
177
|
+
other_merge_parent = parent
|
|
178
|
+
break
|
|
179
|
+
if other_merge_parent:
|
|
180
|
+
first_common_ancestor = repo.merge_base(other_merge_parent, merge_base)[0]
|
|
181
|
+
# for gito remote (feature_branch vs origin/main)
|
|
182
|
+
# the same merge base appears in first_common_ancestor again
|
|
183
|
+
if first_common_ancestor.hexsha == merge_base.hexsha:
|
|
184
|
+
if merge_base.parents:
|
|
185
|
+
first_common_ancestor = repo.merge_base(
|
|
186
|
+
other_merge_parent, merge_base.parents[0]
|
|
187
|
+
)[0]
|
|
188
|
+
else:
|
|
189
|
+
logging.error(
|
|
190
|
+
"merge_base has no parents, "
|
|
191
|
+
"using merge_base as first_common_ancestor"
|
|
192
|
+
)
|
|
193
|
+
logging.info(
|
|
194
|
+
f"{what} will be compared to "
|
|
195
|
+
f"first common ancestor of {what} and {against}: "
|
|
196
|
+
f"{ui.cyan(first_common_ancestor.hexsha[:8])}"
|
|
197
|
+
)
|
|
198
|
+
against = first_common_ancestor.hexsha
|
|
199
|
+
else:
|
|
200
|
+
logging.error(f"Can't find other merge parent for {merge_sha}")
|
|
201
|
+
else:
|
|
202
|
+
logging.warning(
|
|
203
|
+
f"No merge‐commit found for {current_ref!r}→{against!r}; "
|
|
204
|
+
"falling back to merge‐base diff"
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
# normal case: branch not yet merged
|
|
208
|
+
against = merge_base.hexsha
|
|
209
|
+
logging.info(
|
|
210
|
+
f"Using merge base: {ui.cyan(merge_base.hexsha[:8])} ({merge_base.summary})"
|
|
211
|
+
)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logging.error(f"Error finding merge base: {e}")
|
|
203
214
|
logging.info(
|
|
204
215
|
f"Making diff: {ui.green(what or 'INDEX')} vs {ui.yellow(against)}"
|
|
205
216
|
)
|
|
@@ -225,12 +236,21 @@ def get_diff(
|
|
|
225
236
|
|
|
226
237
|
|
|
227
238
|
def filter_diff(
|
|
228
|
-
patch_set: PatchSet | Iterable[PatchedFile],
|
|
239
|
+
patch_set: PatchSet | Iterable[PatchedFile],
|
|
240
|
+
filters: str | list[str],
|
|
241
|
+
exclude: bool = False,
|
|
229
242
|
) -> PatchSet | Iterable[PatchedFile]:
|
|
230
243
|
"""
|
|
231
244
|
Filter the diff files by the given fnmatch filters.
|
|
245
|
+
Args:
|
|
246
|
+
patch_set (PatchSet | Iterable[PatchedFile]): The diff to filter.
|
|
247
|
+
filters (str | list[str]): The fnmatch patterns to filter by.
|
|
248
|
+
exclude (bool): If True, inverse logic (exclude files matching the filters).
|
|
249
|
+
Returns:
|
|
250
|
+
PatchSet | Iterable[PatchedFile]: The filtered diff.
|
|
232
251
|
"""
|
|
233
|
-
|
|
252
|
+
if not isinstance(filters, (list, str)):
|
|
253
|
+
raise ValueError("Filters must be a string or a list of strings")
|
|
234
254
|
if not isinstance(filters, list):
|
|
235
255
|
filters = [f.strip() for f in filters.split(",") if f.strip()]
|
|
236
256
|
if not filters:
|
|
@@ -238,7 +258,10 @@ def filter_diff(
|
|
|
238
258
|
files = [
|
|
239
259
|
file
|
|
240
260
|
for file in patch_set
|
|
241
|
-
if
|
|
261
|
+
if (
|
|
262
|
+
not any(fnmatch.fnmatch(file.path, pattern) for pattern in filters) if exclude
|
|
263
|
+
else any(fnmatch.fnmatch(file.path, pattern) for pattern in filters)
|
|
264
|
+
)
|
|
242
265
|
]
|
|
243
266
|
return files
|
|
244
267
|
|
|
@@ -319,6 +342,8 @@ def _prepare(
|
|
|
319
342
|
repo=repo, what=what, against=against, use_merge_base=use_merge_base, pr=pr,
|
|
320
343
|
)
|
|
321
344
|
diff = filter_diff(diff, filters)
|
|
345
|
+
if cfg.exclude_files:
|
|
346
|
+
diff = filter_diff(diff, cfg.exclude_files, exclude=True)
|
|
322
347
|
if not diff:
|
|
323
348
|
raise NoChangesInContextError()
|
|
324
349
|
lines = {
|
|
@@ -328,9 +353,9 @@ def _prepare(
|
|
|
328
353
|
file_diff.path,
|
|
329
354
|
cfg.max_code_tokens
|
|
330
355
|
- mc.tokenizing.num_tokens_from_string(str(file_diff)),
|
|
331
|
-
use_local_files=review_subject_is_index(what)
|
|
356
|
+
use_local_files=review_subject_is_index(what) or what == REFS_VALUE_ALL
|
|
332
357
|
)
|
|
333
|
-
if file_diff.target_file != DEV_NULL
|
|
358
|
+
if file_diff.target_file != DEV_NULL or what == REFS_VALUE_ALL
|
|
334
359
|
else ""
|
|
335
360
|
)
|
|
336
361
|
for file_diff in diff
|
|
@@ -373,6 +398,19 @@ def provide_affected_code_blocks(issues: dict, repo: Repo):
|
|
|
373
398
|
i["affected_code"] = block
|
|
374
399
|
|
|
375
400
|
|
|
401
|
+
def _llm_response_validator(parsed_response: list[dict]):
|
|
402
|
+
"""
|
|
403
|
+
Validate that the LLM response is a list of dicts that can be converted to RawIssue.
|
|
404
|
+
"""
|
|
405
|
+
if not isinstance(parsed_response, list):
|
|
406
|
+
raise ValueError("Response is not a list")
|
|
407
|
+
for item in parsed_response:
|
|
408
|
+
if not isinstance(item, dict):
|
|
409
|
+
raise ValueError("Response item is not a dict")
|
|
410
|
+
RawIssue(**item)
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
|
|
376
414
|
async def review(
|
|
377
415
|
repo: Repo = None,
|
|
378
416
|
what: str = None,
|
|
@@ -382,6 +420,11 @@ async def review(
|
|
|
382
420
|
out_folder: str | os.PathLike | None = None,
|
|
383
421
|
pr: str | int = None
|
|
384
422
|
):
|
|
423
|
+
"""
|
|
424
|
+
Conducts a code review.
|
|
425
|
+
Prints the review report to the console and saves it to a file.
|
|
426
|
+
"""
|
|
427
|
+
reviewing_all = what == REFS_VALUE_ALL
|
|
385
428
|
try:
|
|
386
429
|
repo, cfg, diff, lines = _prepare(
|
|
387
430
|
repo=repo,
|
|
@@ -394,25 +437,62 @@ async def review(
|
|
|
394
437
|
except NoChangesInContextError:
|
|
395
438
|
logging.error("No changes to review")
|
|
396
439
|
return
|
|
440
|
+
|
|
441
|
+
def input_is_diff(file_diff: PatchedFile) -> bool:
|
|
442
|
+
"""
|
|
443
|
+
In case of reviewing all changes, or added files,
|
|
444
|
+
we provide full file content as input.
|
|
445
|
+
Otherwise, we provide the diff and additional file lines separately.
|
|
446
|
+
"""
|
|
447
|
+
return not reviewing_all and not file_diff.is_added_file
|
|
448
|
+
|
|
397
449
|
responses = await mc.llm_parallel(
|
|
398
450
|
[
|
|
399
451
|
mc.prompt(
|
|
400
452
|
cfg.prompt,
|
|
401
|
-
input=
|
|
402
|
-
|
|
453
|
+
input=(
|
|
454
|
+
file_diff if input_is_diff(file_diff)
|
|
455
|
+
else str(file_diff.path) + ":\n" + lines[file_diff.path]
|
|
456
|
+
),
|
|
457
|
+
file_lines=lines[file_diff.path] if input_is_diff(file_diff) else None,
|
|
403
458
|
**cfg.prompt_vars,
|
|
404
459
|
)
|
|
405
460
|
for file_diff in diff
|
|
406
461
|
],
|
|
407
462
|
retries=cfg.retries,
|
|
408
|
-
parse_json=
|
|
463
|
+
parse_json={"validator": _llm_response_validator},
|
|
464
|
+
allow_failures=True,
|
|
409
465
|
)
|
|
466
|
+
processing_warnings = []
|
|
467
|
+
for i, (res_or_error, file) in enumerate(zip(responses, diff)):
|
|
468
|
+
if isinstance(res_or_error, Exception):
|
|
469
|
+
if isinstance(res_or_error, mc.LLMContextLengthExceededError):
|
|
470
|
+
message = f'File "{file.path}" was skipped due to large size: {str(res_or_error)}.'
|
|
471
|
+
else:
|
|
472
|
+
message = (
|
|
473
|
+
f"File {file.path} was skipped due to error: "
|
|
474
|
+
f"[{type(res_or_error).__name__}] {res_or_error}"
|
|
475
|
+
)
|
|
476
|
+
if not message.endswith('.'):
|
|
477
|
+
message += '.'
|
|
478
|
+
processing_warnings.append(
|
|
479
|
+
ProcessingWarning(
|
|
480
|
+
message=message,
|
|
481
|
+
file=file.path,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
responses[i] = []
|
|
485
|
+
|
|
410
486
|
issues = {file.path: issues for file, issues in zip(diff, responses) if issues}
|
|
411
487
|
provide_affected_code_blocks(issues, repo)
|
|
412
488
|
exec(cfg.post_process, {"mc": mc, **locals()})
|
|
413
489
|
out_folder = Path(out_folder or repo.working_tree_dir)
|
|
414
490
|
out_folder.mkdir(parents=True, exist_ok=True)
|
|
415
|
-
report = Report(
|
|
491
|
+
report = Report(
|
|
492
|
+
number_of_processed_files=len(diff),
|
|
493
|
+
processing_warnings=processing_warnings,
|
|
494
|
+
)
|
|
495
|
+
report.register_issues(issues)
|
|
416
496
|
ctx = Context(
|
|
417
497
|
report=report,
|
|
418
498
|
config=cfg,
|
|
@@ -448,6 +528,10 @@ def answer(
|
|
|
448
528
|
pr: str | int = None,
|
|
449
529
|
aux_files: list[str] = None,
|
|
450
530
|
) -> str | None:
|
|
531
|
+
"""
|
|
532
|
+
Answers a question about the code changes.
|
|
533
|
+
Returns the LLM response as a string.
|
|
534
|
+
"""
|
|
451
535
|
try:
|
|
452
536
|
repo, config, diff, lines = _prepare(
|
|
453
537
|
repo=repo,
|
|
@@ -477,11 +561,11 @@ def answer(
|
|
|
477
561
|
if aux_files or config.aux_files:
|
|
478
562
|
aux_files_dict = read_files(
|
|
479
563
|
repo,
|
|
480
|
-
(aux_files or
|
|
564
|
+
(aux_files or []) + config.aux_files,
|
|
481
565
|
config.max_code_tokens // 2
|
|
482
566
|
)
|
|
483
567
|
else:
|
|
484
|
-
aux_files_dict =
|
|
568
|
+
aux_files_dict = {}
|
|
485
569
|
|
|
486
570
|
if not prompt_file and config.answer_prompt.startswith("tpl:"):
|
|
487
571
|
prompt_file = str(config.answer_prompt)[4:]
|
|
@@ -67,9 +67,9 @@ def post_gh_comment(
|
|
|
67
67
|
if 200 <= resp.status_code < 300:
|
|
68
68
|
logging.info(f"Posted review comment to #{pr_or_issue_number} in {gh_repository}")
|
|
69
69
|
return True
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
|
|
71
|
+
logging.error(f"Failed to post comment: {resp.status_code} {resp.reason}\n{resp.text}")
|
|
72
|
+
return False
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def hide_gh_comment(
|
|
@@ -33,6 +33,10 @@ class ProjectConfig:
|
|
|
33
33
|
when referenced in code review comments.
|
|
34
34
|
"""
|
|
35
35
|
aux_files: list[str] = field(default_factory=list)
|
|
36
|
+
exclude_files: list[str] = field(default_factory=list)
|
|
37
|
+
"""
|
|
38
|
+
List of file patterns to exclude from analysis.
|
|
39
|
+
"""
|
|
36
40
|
pipeline_steps: dict[str, dict | PipelineStep] = field(default_factory=dict)
|
|
37
41
|
collapse_previous_code_review_comments: bool = field(default=True)
|
|
38
42
|
"""
|
|
@@ -1,48 +1,63 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
|
-
from dataclasses import
|
|
3
|
+
from dataclasses import field, asdict, is_dataclass
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from enum import StrEnum
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
import textwrap
|
|
8
9
|
import microcore as mc
|
|
9
|
-
from colorama import Fore, Style, Back
|
|
10
10
|
from microcore.utils import file_link
|
|
11
|
-
import
|
|
11
|
+
from colorama import Fore, Style, Back
|
|
12
|
+
from pydantic.dataclasses import dataclass
|
|
12
13
|
|
|
13
14
|
from .constants import JSON_REPORT_FILE_NAME, HTML_TEXT_ICON, HTML_CR_COMMENT_MARKER
|
|
14
15
|
from .project_config import ProjectConfig
|
|
15
|
-
from .utils import syntax_hint, block_wrap_lr, max_line_len, remove_html_comments
|
|
16
|
+
from .utils import syntax_hint, block_wrap_lr, max_line_len, remove_html_comments, filter_kwargs
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
@dataclass
|
|
19
|
-
class
|
|
20
|
+
class RawIssue:
|
|
20
21
|
@dataclass
|
|
21
22
|
class AffectedCode:
|
|
22
23
|
start_line: int = field()
|
|
23
24
|
end_line: int | None = field(default=None)
|
|
25
|
+
proposal: str | None = field(default="")
|
|
26
|
+
|
|
27
|
+
title: str = field()
|
|
28
|
+
details: str | None = field(default="")
|
|
29
|
+
severity: int | None = field(default=None)
|
|
30
|
+
confidence: int | None = field(default=None)
|
|
31
|
+
tags: list[str] = field(default_factory=list)
|
|
32
|
+
affected_lines: list[AffectedCode] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Issue(RawIssue):
|
|
37
|
+
@dataclass
|
|
38
|
+
class AffectedCode(RawIssue.AffectedCode):
|
|
24
39
|
file: str = field(default="")
|
|
25
|
-
proposal: str = field(default="")
|
|
26
40
|
affected_code: str = field(default="")
|
|
27
41
|
|
|
28
42
|
@property
|
|
29
43
|
def syntax_hint(self) -> str:
|
|
30
44
|
return syntax_hint(self.file)
|
|
31
45
|
|
|
32
|
-
id: str = field()
|
|
33
|
-
title: str = field()
|
|
34
|
-
details: str = field(default="")
|
|
35
|
-
severity: int | None = field(default=None)
|
|
36
|
-
confidence: int | None = field(default=None)
|
|
37
|
-
tags: list[str] = field(default_factory=list)
|
|
46
|
+
id: int | str = field(kw_only=True)
|
|
38
47
|
file: str = field(default="")
|
|
39
48
|
affected_lines: list[AffectedCode] = field(default_factory=list)
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
@staticmethod
|
|
51
|
+
def from_raw_issue(file: str, raw_issue: RawIssue | dict, issue_id: int | str) -> "Issue":
|
|
52
|
+
if is_dataclass(raw_issue):
|
|
53
|
+
raw_issue = asdict(raw_issue)
|
|
54
|
+
params = filter_kwargs(Issue, raw_issue | {"file": file, "id": issue_id})
|
|
55
|
+
for i, obj in enumerate(params.get("affected_lines") or []):
|
|
56
|
+
d = obj if isinstance(obj, dict) else asdict(obj)
|
|
57
|
+
params["affected_lines"][i] = Issue.AffectedCode(
|
|
58
|
+
**filter_kwargs(Issue.AffectedCode, {"file": file} | d)
|
|
59
|
+
)
|
|
60
|
+
return Issue(**params)
|
|
46
61
|
|
|
47
62
|
def github_code_link(self, github_env: dict) -> str:
|
|
48
63
|
url = (
|
|
@@ -57,6 +72,15 @@ class Issue:
|
|
|
57
72
|
return url
|
|
58
73
|
|
|
59
74
|
|
|
75
|
+
@dataclass
|
|
76
|
+
class ProcessingWarning:
|
|
77
|
+
"""
|
|
78
|
+
Warning generated during code review of files
|
|
79
|
+
"""
|
|
80
|
+
message: str = field()
|
|
81
|
+
file: str | None = field(default=None)
|
|
82
|
+
|
|
83
|
+
|
|
60
84
|
@dataclass
|
|
61
85
|
class Report:
|
|
62
86
|
class Format(StrEnum):
|
|
@@ -70,6 +94,7 @@ class Report:
|
|
|
70
94
|
created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
|
71
95
|
model: str = field(default_factory=lambda: mc.config().MODEL)
|
|
72
96
|
pipeline_out: dict = field(default_factory=dict)
|
|
97
|
+
processing_warnings: list[ProcessingWarning] = field(default_factory=list)
|
|
73
98
|
|
|
74
99
|
@property
|
|
75
100
|
def plain_issues(self):
|
|
@@ -79,19 +104,20 @@ class Report:
|
|
|
79
104
|
for issue in issues
|
|
80
105
|
]
|
|
81
106
|
|
|
107
|
+
def register_issues(self, issues: dict[str, list[RawIssue | dict]]):
|
|
108
|
+
for file, file_issues in issues.items():
|
|
109
|
+
for issue in file_issues:
|
|
110
|
+
self.register_issue(file, issue)
|
|
111
|
+
|
|
112
|
+
def register_issue(self, file: str, issue: RawIssue | dict):
|
|
113
|
+
if file not in self.issues:
|
|
114
|
+
self.issues[file] = []
|
|
115
|
+
total = len(self.plain_issues)
|
|
116
|
+
self.issues[file].append(Issue.from_raw_issue(file, issue, issue_id=total + 1))
|
|
117
|
+
self.total_issues = total + 1
|
|
118
|
+
|
|
82
119
|
def __post_init__(self):
|
|
83
|
-
|
|
84
|
-
for file in self.issues.keys():
|
|
85
|
-
self.issues[file] = [
|
|
86
|
-
Issue(
|
|
87
|
-
**{
|
|
88
|
-
"id": (issue_id := issue_id + 1),
|
|
89
|
-
"file": file,
|
|
90
|
-
} | issue
|
|
91
|
-
)
|
|
92
|
-
for issue in self.issues[file]
|
|
93
|
-
]
|
|
94
|
-
self.total_issues = issue_id
|
|
120
|
+
self.total_issues = len(self.plain_issues)
|
|
95
121
|
|
|
96
122
|
def save(self, file_name: str = ""):
|
|
97
123
|
file_name = file_name or JSON_REPORT_FILE_NAME
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import re
|
|
2
3
|
import sys
|
|
3
4
|
import os
|
|
5
|
+
from dataclasses import fields, is_dataclass
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
import importlib.metadata
|
|
6
8
|
from typing import Optional
|
|
@@ -8,7 +10,7 @@ from typing import Optional
|
|
|
8
10
|
import typer
|
|
9
11
|
import git
|
|
10
12
|
from git import Repo
|
|
11
|
-
|
|
13
|
+
from .env import Env
|
|
12
14
|
|
|
13
15
|
_EXT_TO_HINT: dict[str, str] = {
|
|
14
16
|
# scripting & languages
|
|
@@ -192,10 +194,10 @@ def detect_github_env() -> dict:
|
|
|
192
194
|
"github_ref": ref,
|
|
193
195
|
}
|
|
194
196
|
# Fallback for local usage: try to get from git
|
|
195
|
-
if not repo:
|
|
197
|
+
if not repo or repo == "octocat/Hello-World":
|
|
196
198
|
git_repo = None
|
|
197
199
|
try:
|
|
198
|
-
git_repo = Repo(
|
|
200
|
+
git_repo = Repo(Env.working_folder, search_parent_directories=True)
|
|
199
201
|
origin = git_repo.remotes.origin.url
|
|
200
202
|
# e.g. git@github.com:Nayjest/ai-code-review.git -> Nayjest/ai-code-review
|
|
201
203
|
match = re.search(r"[:/]([\w\-]+)/([\w\-\.]+?)(\.git)?$", origin)
|
|
@@ -240,3 +242,29 @@ def remove_html_comments(text):
|
|
|
240
242
|
Removes all HTML comments (<!-- ... -->) from the input text.
|
|
241
243
|
"""
|
|
242
244
|
return re.sub(r'<!--.*?-->\s*', '', text, flags=re.DOTALL)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def filter_kwargs(cls, kwargs, log_warnings=True):
|
|
248
|
+
"""
|
|
249
|
+
Filters the keyword arguments to only include those that are fields of the given dataclass.
|
|
250
|
+
Args:
|
|
251
|
+
cls: The dataclass type to filter against.
|
|
252
|
+
kwargs: A dictionary of keyword arguments.
|
|
253
|
+
log_warnings: If True, logs warnings for fields not in the dataclass.
|
|
254
|
+
Returns:
|
|
255
|
+
A dictionary containing only the fields that are defined in the dataclass.
|
|
256
|
+
"""
|
|
257
|
+
if not is_dataclass(cls):
|
|
258
|
+
raise TypeError(f"{cls.__name__} is not a dataclass or pydantic dataclass")
|
|
259
|
+
|
|
260
|
+
cls_fields = {f.name for f in fields(cls)}
|
|
261
|
+
filtered = {}
|
|
262
|
+
for k, v in kwargs.items():
|
|
263
|
+
if k in cls_fields:
|
|
264
|
+
filtered[k] = v
|
|
265
|
+
else:
|
|
266
|
+
if log_warnings:
|
|
267
|
+
logging.warning(
|
|
268
|
+
f"Warning: field '{k}' not in {cls.__name__}, dropping."
|
|
269
|
+
)
|
|
270
|
+
return filtered
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "github-code-review"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.4.1"
|
|
4
4
|
description = "AI code review tool that works with any language model provider. It detects issues in GitHub pull requests or local changes—instantly, reliably, and without vendor lock-in."
|
|
5
5
|
authors = ["Nayjest <mail@vitaliy.in>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -21,14 +21,15 @@ packages = [
|
|
|
21
21
|
|
|
22
22
|
[tool.poetry.dependencies]
|
|
23
23
|
python = "^3.11"
|
|
24
|
-
ai-microcore = "4.
|
|
24
|
+
ai-microcore = "~4.5"
|
|
25
25
|
GitPython = "^3.1.44"
|
|
26
26
|
unidiff = "^0.7.5"
|
|
27
27
|
google-generativeai = "^0.8.5"
|
|
28
|
-
anthropic = "
|
|
29
|
-
typer = "
|
|
28
|
+
anthropic = ">=0.57.1,<1"
|
|
29
|
+
typer = ">=0.16.0,<0.21"
|
|
30
30
|
ghapi = "~=1.0.6"
|
|
31
31
|
jira = "^3.8.0"
|
|
32
|
+
pydantic = "^2.12.3"
|
|
32
33
|
|
|
33
34
|
[tool.poetry.group.dev.dependencies]
|
|
34
35
|
flake8 = "*"
|
|
@@ -40,10 +41,10 @@ pyflakes = "*"
|
|
|
40
41
|
poetry = "*"
|
|
41
42
|
|
|
42
43
|
[tool.poetry.group.test.dependencies]
|
|
43
|
-
pytest = "
|
|
44
|
-
pytest-asyncio = ">=0.21.0"
|
|
44
|
+
pytest = ">=7.4.3,<10"
|
|
45
|
+
pytest-asyncio = ">=0.21.0,<2"
|
|
45
46
|
pytest-mock = "^3.12.0"
|
|
46
|
-
pytest-cov = "
|
|
47
|
+
pytest-cov = ">=4.1.0,<8"
|
|
47
48
|
|
|
48
49
|
[build-system]
|
|
49
50
|
requires = ["poetry-core"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{github_code_review-3.2.0 → github_code_review-3.4.1}/gito/commands/gh_post_review_comment.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|