ai-cr 1.0.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

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,238 +1,217 @@
1
- import asyncio
2
- import logging
3
- import sys
4
- import os
5
- import textwrap
6
- import tempfile
7
- import requests
8
-
9
- import microcore as mc
10
- import typer
11
- from git import Repo
12
-
13
- from .core import review, get_diff, filter_diff
14
- from .report_struct import Report
15
- from .constants import ENV_CONFIG_FILE
16
- from .bootstrap import bootstrap
17
- from .project_config import ProjectConfig
18
- from .utils import no_subcommand, parse_refs_pair
19
-
20
- app = typer.Typer(pretty_exceptions_show_locals=False)
21
- app_no_subcommand = typer.Typer(pretty_exceptions_show_locals=False)
22
-
23
-
24
- def main():
25
- if sys.platform == "win32":
26
- asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
27
- if no_subcommand(app):
28
- bootstrap()
29
- app_no_subcommand()
30
- else:
31
- app()
32
-
33
-
34
- @app.callback(invoke_without_command=True)
35
- def cli(ctx: typer.Context, verbose: bool = typer.Option(default=False)):
36
- if verbose:
37
- mc.logging.LoggingConfig.STRIP_REQUEST_LINES = None
38
- if ctx.invoked_subcommand != "setup":
39
- bootstrap()
40
-
41
-
42
- def args_to_target(refs, what, against) -> tuple[str | None, str | None]:
43
- _what, _against = parse_refs_pair(refs)
44
- if _what:
45
- if what:
46
- raise typer.BadParameter(
47
- "You cannot specify both 'refs' <WHAT>..<AGAINST> and '--what'. Use one of them."
48
- )
49
- else:
50
- _what = what
51
- if _against:
52
- if against:
53
- raise typer.BadParameter(
54
- "You cannot specify both 'refs' <WHAT>..<AGAINST> and '--against'. Use one of them."
55
- )
56
- else:
57
- _against = against
58
- return _what, _against
59
-
60
-
61
- def arg_refs() -> typer.Argument:
62
- return typer.Argument(
63
- default=None,
64
- help="Git refs to review, [what]..[against] e.g. 'HEAD..HEAD~1'"
65
- )
66
-
67
-
68
- def arg_what() -> typer.Option:
69
- return typer.Option(None, "--what", "-w", help="Git ref to review")
70
-
71
-
72
- def arg_filters() -> typer.Option:
73
- return typer.Option(
74
- "", "--filter", "-f", "--filters",
75
- help="""
76
- filter reviewed files by glob / fnmatch pattern(s),
77
- e.g. 'src/**/*.py', may be comma-separated
78
- """,
79
- )
80
-
81
-
82
- def arg_out() -> typer.Option:
83
- return typer.Option(
84
- None,
85
- "--out", "-o", "--output",
86
- help="Output folder for the code review report"
87
- )
88
-
89
-
90
- def arg_against() -> typer.Option:
91
- return typer.Option(
92
- None,
93
- "--against", "-vs", "--vs",
94
- help="Git ref to compare against"
95
- )
96
-
97
-
98
- @app_no_subcommand.command(name="review", help="Perform code review")
99
- @app.command(name="review", help="Perform code review")
100
- def cmd_review(
101
- refs: str = arg_refs(),
102
- what: str = arg_what(),
103
- against: str = arg_against(),
104
- filters: str = arg_filters(),
105
- merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
106
- out: str = arg_out()
107
- ):
108
- _what, _against = args_to_target(refs, what, against)
109
- asyncio.run(review(
110
- what=_what,
111
- against=_against,
112
- filters=filters,
113
- use_merge_base=merge_base,
114
- out_folder=out,
115
- ))
116
-
117
-
118
- @app.command(help="Configure LLM for local usage interactively")
119
- def setup():
120
- mc.interactive_setup(ENV_CONFIG_FILE)
121
-
122
-
123
- @app.command()
124
- def render(format: str = Report.Format.MARKDOWN):
125
- print(Report.load().render(format=format))
126
-
127
-
128
- @app.command(help="Review remote code")
129
- def remote(
130
- url: str = typer.Argument(..., help="Git repository URL"),
131
- refs: str = arg_refs(),
132
- what: str = arg_what(),
133
- against: str = arg_against(),
134
- filters: str = arg_filters(),
135
- merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
136
- out: str = arg_out()
137
- ):
138
- _what, _against = args_to_target(refs, what, against)
139
- with tempfile.TemporaryDirectory() as temp_dir:
140
- logging.info(f"Cloning [{mc.ui.green(url)}] to {mc.utils.file_link(temp_dir)} ...")
141
- repo = Repo.clone_from(url, branch=_what, to_path=temp_dir)
142
- asyncio.run(review(
143
- repo=repo,
144
- what=_what,
145
- against=_against,
146
- filters=filters,
147
- use_merge_base=merge_base,
148
- out_folder=out or '.',
149
- ))
150
- repo.close()
151
-
152
-
153
- @app.command(help="Leave a GitHub PR comment with the review.")
154
- def github_comment(
155
- token: str = typer.Option(
156
- os.environ.get("GITHUB_TOKEN", ""), help="GitHub token (or set GITHUB_TOKEN env var)"
157
- ),
158
- ):
159
- """
160
- Leaves a comment with the review on the current GitHub pull request.
161
- """
162
- file = "code-review-report.md"
163
- if not os.path.exists(file):
164
- print(f"Review file not found: {file}")
165
- raise typer.Exit(4)
166
-
167
- with open(file, "r", encoding="utf-8") as f:
168
- body = f.read()
169
-
170
- if not token:
171
- print("GitHub token is required (--token or GITHUB_TOKEN env var).")
172
- raise typer.Exit(1)
173
-
174
- github_env = ProjectConfig.load().prompt_vars["github_env"]
175
- repo = github_env.get("github_repo", "")
176
- pr_env_val = github_env.get("github_pr_number", "")
177
- logging.info(f"github_pr_number = {pr_env_val}")
178
-
179
- # e.g. could be "refs/pull/123/merge" or a direct number
180
- if "/" in pr_env_val and "pull" in pr_env_val:
181
- # refs/pull/123/merge
182
- try:
183
- pr_num_candidate = pr_env_val.strip("/").split("/")
184
- idx = pr_num_candidate.index("pull")
185
- pr = int(pr_num_candidate[idx + 1])
186
- except Exception:
187
- pr = 0
188
- else:
189
- try:
190
- pr = int(pr_env_val)
191
- except Exception:
192
- pr = 0
193
-
194
- api_url = f"https://api.github.com/repos/{repo}/issues/{pr}/comments"
195
- headers = {
196
- "Authorization": f"token {token}",
197
- "Accept": "application/vnd.github+json",
198
- }
199
- data = {"body": body}
200
-
201
- resp = requests.post(api_url, headers=headers, json=data)
202
- if 200 <= resp.status_code < 300:
203
- logging.info(f"Posted review comment to PR #{pr} in {repo}")
204
- else:
205
- logging.error(f"Failed to post comment: {resp.status_code} {resp.reason}\n{resp.text}")
206
- raise typer.Exit(5)
207
-
208
-
209
- @app.command(help="List files in the diff. Might be useful to check what will be reviewed.")
210
- def files(
211
- refs: str = arg_refs(),
212
- what: str = arg_what(),
213
- against: str = arg_against(),
214
- filters: str = arg_filters(),
215
- merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
216
- diff: bool = typer.Option(default=False, help="Show diff content")
217
- ):
218
- _what, _against = args_to_target(refs, what, against)
219
- repo = Repo(".")
220
- patch_set = get_diff(repo=repo, what=_what, against=_against, use_merge_base=merge_base)
221
- patch_set = filter_diff(patch_set, filters)
222
- print(
223
- f"Changed files: "
224
- f"{mc.ui.green(_what or 'INDEX')} vs "
225
- f"{mc.ui.yellow(_against or repo.remotes.origin.refs.HEAD.reference.name)}"
226
- f"{' filtered by '+mc.ui.cyan(filters) if filters else ''}"
227
- )
228
-
229
- for patch in patch_set:
230
- if patch.is_added_file:
231
- color = mc.ui.green
232
- elif patch.is_removed_file:
233
- color = mc.ui.red
234
- else:
235
- color = mc.ui.blue
236
- print(f"- {color(patch.path)}")
237
- if diff:
238
- print(mc.ui.gray(textwrap.indent(str(patch), " ")))
1
+ import asyncio
2
+ import logging
3
+ import sys
4
+ import textwrap
5
+ import tempfile
6
+
7
+ import microcore as mc
8
+ import typer
9
+ from git import Repo
10
+
11
+ from .core import review, get_diff, filter_diff, answer
12
+ from .report_struct import Report
13
+ from .constants import HOME_ENV_PATH
14
+ from .bootstrap import bootstrap, app
15
+ from .utils import no_subcommand, parse_refs_pair
16
+
17
+ # Import fix command to register it
18
+ from .commands import fix, gh_post_review_comment, gh_react_to_comment, repl # noqa
19
+
20
+
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
+ # Help subcommand alias: if 'help' appears as first non-option arg, replace it with '--help'
28
+ if len(sys.argv) > 1 and sys.argv[1] == "help":
29
+ sys.argv = [sys.argv[0]] + sys.argv[2:] + ["--help"]
30
+
31
+ if no_subcommand(app):
32
+ bootstrap()
33
+ app_no_subcommand()
34
+ else:
35
+ app()
36
+
37
+
38
+ @app.callback(invoke_without_command=True)
39
+ def cli(ctx: typer.Context, verbose: bool = typer.Option(default=False)):
40
+ if ctx.invoked_subcommand != "setup":
41
+ bootstrap()
42
+ if verbose:
43
+ mc.logging.LoggingConfig.STRIP_REQUEST_LINES = None
44
+
45
+
46
+ def args_to_target(refs, what, against) -> tuple[str | None, str | None]:
47
+ _what, _against = parse_refs_pair(refs)
48
+ if _what:
49
+ if what:
50
+ raise typer.BadParameter(
51
+ "You cannot specify both 'refs' <WHAT>..<AGAINST> and '--what'. Use one of them."
52
+ )
53
+ else:
54
+ _what = what
55
+ if _against:
56
+ if against:
57
+ raise typer.BadParameter(
58
+ "You cannot specify both 'refs' <WHAT>..<AGAINST> and '--against'. Use one of them."
59
+ )
60
+ else:
61
+ _against = against
62
+ return _what, _against
63
+
64
+
65
+ def arg_refs() -> typer.Argument:
66
+ return typer.Argument(
67
+ default=None,
68
+ help="Git refs to review, [what]..[against] e.g. 'HEAD..HEAD~1'"
69
+ )
70
+
71
+
72
+ def arg_what() -> typer.Option:
73
+ return typer.Option(None, "--what", "-w", help="Git ref to review")
74
+
75
+
76
+ def arg_filters() -> typer.Option:
77
+ return typer.Option(
78
+ "", "--filter", "-f", "--filters",
79
+ help="""
80
+ filter reviewed files by glob / fnmatch pattern(s),
81
+ e.g. 'src/**/*.py', may be comma-separated
82
+ """,
83
+ )
84
+
85
+
86
+ def arg_out() -> typer.Option:
87
+ return typer.Option(
88
+ None,
89
+ "--out", "-o", "--output",
90
+ help="Output folder for the code review report"
91
+ )
92
+
93
+
94
+ def arg_against() -> typer.Option:
95
+ return typer.Option(
96
+ None,
97
+ "--against", "-vs", "--vs",
98
+ help="Git ref to compare against"
99
+ )
100
+
101
+
102
+ @app_no_subcommand.command(name="review", help="Perform code review")
103
+ @app.command(name="review", help="Perform code review")
104
+ @app.command(name="run", hidden=True)
105
+ def cmd_review(
106
+ refs: str = arg_refs(),
107
+ what: str = arg_what(),
108
+ against: str = arg_against(),
109
+ filters: str = arg_filters(),
110
+ merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
111
+ out: str = arg_out()
112
+ ):
113
+ _what, _against = args_to_target(refs, what, against)
114
+ asyncio.run(review(
115
+ what=_what,
116
+ against=_against,
117
+ filters=filters,
118
+ use_merge_base=merge_base,
119
+ out_folder=out,
120
+ ))
121
+
122
+
123
+ @app.command(name="ask", help="Answer questions about codebase changes")
124
+ @app.command(name="answer", hidden=True)
125
+ @app.command(name="talk", hidden=True)
126
+ def cmd_answer(
127
+ question: str = typer.Argument(help="Question to ask about the codebase changes"),
128
+ refs: str = arg_refs(),
129
+ what: str = arg_what(),
130
+ against: str = arg_against(),
131
+ filters: str = arg_filters(),
132
+ merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
133
+ ):
134
+ _what, _against = args_to_target(refs, what, against)
135
+ return answer(
136
+ question=question,
137
+ what=_what,
138
+ against=_against,
139
+ filters=filters,
140
+ use_merge_base=merge_base,
141
+ )
142
+
143
+
144
+ @app.command(help="Configure LLM for local usage interactively")
145
+ def setup():
146
+ mc.interactive_setup(HOME_ENV_PATH)
147
+
148
+
149
+ @app.command(name="render")
150
+ @app.command(name="report", hidden=True)
151
+ def render(
152
+ format: str = typer.Argument(default=Report.Format.CLI),
153
+ source: str = typer.Option(
154
+ "",
155
+ "--src",
156
+ "--source",
157
+ help="Source file (json) to load the report from"
158
+ )
159
+ ):
160
+ Report.load(file_name=source).to_cli(report_format=format)
161
+
162
+
163
+ @app.command(help="Review remote code")
164
+ def remote(
165
+ url: str = typer.Argument(..., help="Git repository URL"),
166
+ refs: str = arg_refs(),
167
+ what: str = arg_what(),
168
+ against: str = arg_against(),
169
+ filters: str = arg_filters(),
170
+ merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
171
+ out: str = arg_out()
172
+ ):
173
+ _what, _against = args_to_target(refs, what, against)
174
+ with tempfile.TemporaryDirectory() as temp_dir:
175
+ logging.info(f"Cloning [{mc.ui.green(url)}] to {mc.utils.file_link(temp_dir)} ...")
176
+ repo = Repo.clone_from(url, branch=_what, to_path=temp_dir)
177
+ asyncio.run(review(
178
+ repo=repo,
179
+ what=_what,
180
+ against=_against,
181
+ filters=filters,
182
+ use_merge_base=merge_base,
183
+ out_folder=out or '.',
184
+ ))
185
+ repo.close()
186
+
187
+
188
+ @app.command(help="List files in the diff. Might be useful to check what will be reviewed.")
189
+ def files(
190
+ refs: str = arg_refs(),
191
+ what: str = arg_what(),
192
+ against: str = arg_against(),
193
+ filters: str = arg_filters(),
194
+ merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
195
+ diff: bool = typer.Option(default=False, help="Show diff content")
196
+ ):
197
+ _what, _against = args_to_target(refs, what, against)
198
+ repo = Repo(".")
199
+ patch_set = get_diff(repo=repo, what=_what, against=_against, use_merge_base=merge_base)
200
+ patch_set = filter_diff(patch_set, filters)
201
+ print(
202
+ f"Changed files: "
203
+ f"{mc.ui.green(_what or 'INDEX')} vs "
204
+ f"{mc.ui.yellow(_against or repo.remotes.origin.refs.HEAD.reference.name)}"
205
+ f"{' filtered by '+mc.ui.cyan(filters) if filters else ''}"
206
+ )
207
+ repo.close()
208
+ for patch in patch_set:
209
+ if patch.is_added_file:
210
+ color = mc.ui.green
211
+ elif patch.is_removed_file:
212
+ color = mc.ui.red
213
+ else:
214
+ color = mc.ui.blue
215
+ print(f"- {color(patch.path)}")
216
+ if diff:
217
+ print(mc.ui.gray(textwrap.indent(str(patch), " ")))
@@ -0,0 +1 @@
1
+ # Command modules register themselves with the CLI app
gito/commands/fix.py ADDED
@@ -0,0 +1,157 @@
1
+ """
2
+ Fix issues from code review report
3
+ """
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import git
10
+ import typer
11
+ from microcore import ui
12
+
13
+ from ..bootstrap import app
14
+ from ..constants import JSON_REPORT_FILE_NAME
15
+ from ..report_struct import Report, Issue
16
+
17
+
18
+ @app.command(help="Fix an issue from the code review report")
19
+ def fix(
20
+ issue_number: int = typer.Argument(..., help="Issue number to fix"),
21
+ report_path: Optional[str] = typer.Option(
22
+ None,
23
+ "--report",
24
+ "-r",
25
+ help="Path to the code review report (default: code-review-report.json)"
26
+ ),
27
+ dry_run: bool = typer.Option(
28
+ False, "--dry-run", "-d", help="Only print changes without applying them"
29
+ ),
30
+ commit: bool = typer.Option(default=False, help="Commit changes after applying them"),
31
+ push: bool = typer.Option(default=False, help="Push changes to the remote repository"),
32
+ ) -> list[str]:
33
+ """
34
+ Applies the proposed change for the specified issue number from the code review report.
35
+ """
36
+ # Load the report
37
+ report_path = report_path or JSON_REPORT_FILE_NAME
38
+ try:
39
+ report = Report.load(report_path)
40
+ except (FileNotFoundError, json.JSONDecodeError) as e:
41
+ logging.error(f"Failed to load report from {report_path}: {e}")
42
+ raise typer.Exit(code=1)
43
+
44
+ # Find the issue by number
45
+ issue: Optional[Issue] = None
46
+ for file_issues in report.issues.values():
47
+ for i in file_issues:
48
+ if i.id == issue_number:
49
+ issue = i
50
+ break
51
+ if issue:
52
+ break
53
+
54
+ if not issue:
55
+ logging.error(f"Issue #{issue_number} not found in the report")
56
+ raise typer.Exit(code=1)
57
+
58
+ if not issue.affected_lines:
59
+ logging.error(f"Issue #{issue_number} has no affected lines specified")
60
+ raise typer.Exit(code=1)
61
+
62
+ if not any(affected_line.proposal for affected_line in issue.affected_lines):
63
+ logging.error(f"Issue #{issue_number} has no proposal for fixing")
64
+ raise typer.Exit(code=1)
65
+
66
+ # Apply the fix
67
+ logging.info(f"Fixing issue #{issue_number}: {ui.cyan(issue.title)}")
68
+
69
+ for affected_line in issue.affected_lines:
70
+ if not affected_line.proposal:
71
+ continue
72
+
73
+ file_path = Path(issue.file)
74
+ if not file_path.exists():
75
+ logging.error(f"File {file_path} not found")
76
+ continue
77
+
78
+ try:
79
+ with open(file_path, "r", encoding="utf-8") as f:
80
+ lines = f.readlines()
81
+ except Exception as e:
82
+ logging.error(f"Failed to read file {file_path}: {e}")
83
+ continue
84
+
85
+ # Check if line numbers are valid
86
+ if affected_line.start_line < 1 or affected_line.end_line > len(lines):
87
+ logging.error(
88
+ f"Invalid line range: {affected_line.start_line}-{affected_line.end_line} "
89
+ f"(file has {len(lines)} lines)"
90
+ )
91
+ continue
92
+
93
+ # Get the affected line content for display
94
+ affected_content = "".join(lines[affected_line.start_line - 1:affected_line.end_line])
95
+ print(f"\nFile: {ui.blue(issue.file)}")
96
+ print(f"Lines: {affected_line.start_line}-{affected_line.end_line}")
97
+ print(f"Current content:\n{ui.red(affected_content)}")
98
+ print(f"Proposed change:\n{ui.green(affected_line.proposal)}")
99
+
100
+ if dry_run:
101
+ print(f"{ui.yellow('Dry run')}: Changes not applied")
102
+ continue
103
+
104
+ # Apply the change
105
+ proposal_lines = affected_line.proposal.splitlines(keepends=True)
106
+ if not proposal_lines:
107
+ proposal_lines = [""]
108
+ elif not proposal_lines[-1].endswith(("\n", "\r")):
109
+ # Ensure the last line has a newline if the original does
110
+ if (
111
+ affected_line.end_line < len(lines)
112
+ and lines[affected_line.end_line - 1].endswith(("\n", "\r"))
113
+ ):
114
+ proposal_lines[-1] += "\n"
115
+
116
+ lines[affected_line.start_line - 1:affected_line.end_line] = proposal_lines
117
+
118
+ # Write changes back to the file
119
+ try:
120
+ with open(file_path, "w", encoding="utf-8") as f:
121
+ f.writelines(lines)
122
+ print(f"{ui.green('Success')}: Changes applied to {file_path}")
123
+ except Exception as e:
124
+ logging.error(f"Failed to write changes to {file_path}: {e}")
125
+ raise typer.Exit(code=1)
126
+
127
+ print(f"\n{ui.green('✓')} Issue #{issue_number} fixed successfully")
128
+
129
+ changed_files = [file_path.as_posix()]
130
+ if commit:
131
+ commit_changes(
132
+ changed_files,
133
+ commit_message=f"[AI] Fix issue {issue_number}:{issue.title}",
134
+ push=push
135
+ )
136
+ return changed_files
137
+
138
+
139
+ def commit_changes(
140
+ files: list[str],
141
+ repo: git.Repo = None,
142
+ commit_message: str = "fix by AI",
143
+ push: bool = True
144
+ ) -> None:
145
+ if opened_repo := not repo:
146
+ repo = git.Repo(".")
147
+ for i in files:
148
+ repo.index.add(i)
149
+ repo.index.commit(commit_message)
150
+ if push:
151
+ origin = repo.remotes.origin
152
+ origin.push()
153
+ logging.info(f"Changes pushed to {origin.name}")
154
+ else:
155
+ logging.info("Changes committed but not pushed to remote")
156
+ if opened_repo:
157
+ repo.close()