ai-cr 2.0.0.dev1__py3-none-any.whl → 2.0.1__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.
gito/commands/fix.py CHANGED
@@ -1,124 +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 typer
10
- from microcore import ui
11
-
12
- from ..bootstrap import app
13
- from ..constants import JSON_REPORT_FILE_NAME
14
- from ..report_struct import Report
15
-
16
-
17
- @app.command(help="Fix an issue from the code review report")
18
- def fix(
19
- issue_number: int = typer.Argument(..., help="Issue number to fix"),
20
- report_path: Optional[str] = typer.Option(
21
- None,
22
- "--report",
23
- "-r",
24
- help="Path to the code review report (default: code-review-report.json)"
25
- ),
26
- dry_run: bool = typer.Option(
27
- False, "--dry-run", "-d", help="Only print changes without applying them"
28
- ),
29
- ):
30
- """
31
- Applies the proposed change for the specified issue number from the code review report.
32
- """
33
- # Load the report
34
- report_path = report_path or JSON_REPORT_FILE_NAME
35
- try:
36
- report = Report.load(report_path)
37
- except (FileNotFoundError, json.JSONDecodeError) as e:
38
- logging.error(f"Failed to load report from {report_path}: {e}")
39
- raise typer.Exit(code=1)
40
-
41
- # Find the issue by number
42
- issue = None
43
- for file_issues in report.issues.values():
44
- for i in file_issues:
45
- if i.id == issue_number:
46
- issue = i
47
- break
48
- if issue:
49
- break
50
-
51
- if not issue:
52
- logging.error(f"Issue #{issue_number} not found in the report")
53
- raise typer.Exit(code=1)
54
-
55
- if not issue.affected_lines:
56
- logging.error(f"Issue #{issue_number} has no affected lines specified")
57
- raise typer.Exit(code=1)
58
-
59
- if not any(affected_line.proposal for affected_line in issue.affected_lines):
60
- logging.error(f"Issue #{issue_number} has no proposal for fixing")
61
- raise typer.Exit(code=1)
62
-
63
- # Apply the fix
64
- logging.info(f"Fixing issue #{issue_number}: {ui.cyan(issue.title)}")
65
-
66
- for affected_line in issue.affected_lines:
67
- if not affected_line.proposal:
68
- continue
69
-
70
- file_path = Path(issue.file)
71
- if not file_path.exists():
72
- logging.error(f"File {file_path} not found")
73
- continue
74
-
75
- try:
76
- with open(file_path, "r", encoding="utf-8") as f:
77
- lines = f.readlines()
78
- except Exception as e:
79
- logging.error(f"Failed to read file {file_path}: {e}")
80
- continue
81
-
82
- # Check if line numbers are valid
83
- if affected_line.start_line < 1 or affected_line.end_line > len(lines):
84
- logging.error(
85
- f"Invalid line range: {affected_line.start_line}-{affected_line.end_line} "
86
- f"(file has {len(lines)} lines)"
87
- )
88
- continue
89
-
90
- # Get the affected line content for display
91
- affected_content = "".join(lines[affected_line.start_line - 1:affected_line.end_line])
92
- print(f"\nFile: {ui.blue(issue.file)}")
93
- print(f"Lines: {affected_line.start_line}-{affected_line.end_line}")
94
- print(f"Current content:\n{ui.red(affected_content)}")
95
- print(f"Proposed change:\n{ui.green(affected_line.proposal)}")
96
-
97
- if dry_run:
98
- print(f"{ui.yellow('Dry run')}: Changes not applied")
99
- continue
100
-
101
- # Apply the change
102
- proposal_lines = affected_line.proposal.splitlines(keepends=True)
103
- if not proposal_lines:
104
- proposal_lines = [""]
105
- elif not proposal_lines[-1].endswith(("\n", "\r")):
106
- # Ensure the last line has a newline if the original does
107
- if (
108
- affected_line.end_line < len(lines)
109
- and lines[affected_line.end_line - 1].endswith(("\n", "\r"))
110
- ):
111
- proposal_lines[-1] += "\n"
112
-
113
- lines[affected_line.start_line - 1:affected_line.end_line] = proposal_lines
114
-
115
- # Write changes back to the file
116
- try:
117
- with open(file_path, "w", encoding="utf-8") as f:
118
- f.writelines(lines)
119
- print(f"{ui.green('Success')}: Changes applied to {file_path}")
120
- except Exception as e:
121
- logging.error(f"Failed to write changes to {file_path}: {e}")
122
- raise typer.Exit(code=1)
123
-
124
- print(f"\n{ui.green('✓')} Issue #{issue_number} fixed successfully")
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()
@@ -0,0 +1,63 @@
1
+ import logging
2
+ import os
3
+
4
+ import typer
5
+ from gito.bootstrap import app
6
+ from gito.constants import GITHUB_MD_REPORT_FILE_NAME
7
+ from gito.gh_api import post_gh_comment
8
+ from gito.project_config import ProjectConfig
9
+
10
+
11
+ @app.command(help="Leave a GitHub PR comment with the review.")
12
+ def github_comment(
13
+ token: str = typer.Option(
14
+ os.environ.get("GITHUB_TOKEN", ""), help="GitHub token (or set GITHUB_TOKEN env var)"
15
+ ),
16
+ ):
17
+ """
18
+ Leaves a comment with the review on the current GitHub pull request.
19
+ """
20
+ file = GITHUB_MD_REPORT_FILE_NAME
21
+ if not os.path.exists(file):
22
+ logging.error(f"Review file not found: {file}, comment will not be posted.")
23
+ raise typer.Exit(4)
24
+
25
+ with open(file, "r", encoding="utf-8") as f:
26
+ body = f.read()
27
+
28
+ if not token:
29
+ print("GitHub token is required (--token or GITHUB_TOKEN env var).")
30
+ raise typer.Exit(1)
31
+
32
+ github_env = ProjectConfig.load().prompt_vars["github_env"]
33
+ repo = github_env.get("github_repo", "")
34
+ pr_env_val = github_env.get("github_pr_number", "")
35
+ logging.info(f"github_pr_number = {pr_env_val}")
36
+
37
+ pr = None
38
+ # e.g. could be "refs/pull/123/merge" or a direct number
39
+ if "/" in pr_env_val and "pull" in pr_env_val:
40
+ # refs/pull/123/merge
41
+ try:
42
+ pr_num_candidate = pr_env_val.strip("/").split("/")
43
+ idx = pr_num_candidate.index("pull")
44
+ pr = int(pr_num_candidate[idx + 1])
45
+ except Exception:
46
+ pass
47
+ else:
48
+ try:
49
+ pr = int(pr_env_val)
50
+ except ValueError:
51
+ pass
52
+ if not pr:
53
+ if pr_str := os.getenv("PR_NUMBER_FROM_WORKFLOW_DISPATCH"):
54
+ try:
55
+ pr = int(pr_str)
56
+ except ValueError:
57
+ pass
58
+ if not pr:
59
+ logging.error("Could not resolve PR number from environment variables.")
60
+ raise typer.Exit(3)
61
+
62
+ if not post_gh_comment(repo, pr, token, body):
63
+ raise typer.Exit(5)
@@ -0,0 +1,194 @@
1
+ """
2
+ Fix issues from code review report
3
+ """
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Optional
10
+ import zipfile
11
+
12
+ import requests
13
+ import typer
14
+ from fastcore.basics import AttrDict
15
+ from microcore import ui
16
+ from ghapi.all import GhApi
17
+ import git
18
+
19
+ from ..bootstrap import app
20
+ from ..constants import JSON_REPORT_FILE_NAME, HTML_TEXT_ICON
21
+ from ..core import answer
22
+ from ..gh_api import post_gh_comment
23
+ from ..project_config import ProjectConfig
24
+ from ..utils import extract_gh_owner_repo
25
+ from .fix import fix
26
+
27
+
28
+ @app.command(hidden=True)
29
+ def react_to_comment(
30
+ comment_id: int = typer.Argument(),
31
+ gh_token: str = typer.Option(
32
+ "",
33
+ "--gh-token",
34
+ "--token",
35
+ "-t",
36
+ "--github-token",
37
+ help="GitHub token for authentication",
38
+ ),
39
+ dry_run: bool = typer.Option(
40
+ False, "--dry-run", "-d", help="Only print changes without applying them"
41
+ ),
42
+ ):
43
+ """
44
+ Handles direct agent instructions from pull request comments.
45
+
46
+ Note: Not for local usage. Designed for execution within GitHub Actions workflows.
47
+
48
+ Fetches the PR comment by ID, parses agent directives, and executes the requested
49
+ actions automatically to enable seamless code review workflow integration.
50
+ """
51
+ repo = git.Repo(".") # Current directory
52
+ owner, repo_name = extract_gh_owner_repo(repo)
53
+ logging.info(f"Using repository: {ui.yellow}{owner}/{repo_name}{ui.reset}")
54
+ gh_token = (
55
+ gh_token or os.getenv("GITHUB_TOKEN", None) or os.getenv("GH_TOKEN", None)
56
+ )
57
+ api = GhApi(owner=owner, repo=repo_name, token=gh_token)
58
+ comment = api.issues.get_comment(comment_id=comment_id)
59
+ logging.info(
60
+ f"Comment by {ui.yellow('@' + comment.user.login)}: "
61
+ f"{ui.green(comment.body)}\n"
62
+ f"url: {comment.html_url}"
63
+ )
64
+
65
+ cfg = ProjectConfig.load_for_repo(repo)
66
+ if not any(
67
+ trigger.lower() in comment.body.lower() for trigger in cfg.mention_triggers
68
+ ):
69
+ ui.error("No mention trigger found in comment, no reaction added.")
70
+ return
71
+ try:
72
+ logging.info("Comment contains mention trigger, reacting with 'eyes'.")
73
+ api.reactions.create_for_issue_comment(comment_id=comment_id, content="eyes")
74
+ except Exception as e:
75
+ logging.error("Error reacting to comment with emoji: %s", str(e))
76
+ pr = int(comment.issue_url.split("/")[-1])
77
+ print(f"Processing comment for PR #{pr}...")
78
+
79
+ issue_ids = extract_fix_args(comment.body)
80
+ if issue_ids:
81
+ logging.info(f"Extracted issue IDs: {ui.yellow(str(issue_ids))}")
82
+ out_folder = "artifact"
83
+ download_latest_code_review_artifact(
84
+ api, pr_number=pr, gh_token=gh_token, out_folder=out_folder
85
+ )
86
+ fix(
87
+ issue_ids[0], # @todo: support multiple IDs
88
+ report_path=Path(out_folder) / JSON_REPORT_FILE_NAME,
89
+ dry_run=dry_run,
90
+ commit=not dry_run,
91
+ push=not dry_run,
92
+ )
93
+ logging.info("Fix applied successfully.")
94
+ elif is_review_request(comment.body):
95
+ ref = repo.active_branch.name
96
+ logging.info(f"Triggering code-review workflow, ref='{ref}'")
97
+ api.actions.create_workflow_dispatch(
98
+ workflow_id="gito-code-review.yml",
99
+ ref=ref,
100
+ inputs={"pr_number": str(pr)},
101
+ )
102
+ else:
103
+ if cfg.answer_github_comments:
104
+ response = answer(comment.body, repo=repo)
105
+ post_gh_comment(
106
+ gh_repository=f"{owner}/{repo_name}",
107
+ pr_or_issue_number=pr,
108
+ gh_token=gh_token,
109
+ text=HTML_TEXT_ICON+response,
110
+ )
111
+ else:
112
+ ui.error("Can't identify target command in the text.")
113
+ return
114
+
115
+
116
+ def last_code_review_run(api: GhApi, pr_number: int) -> AttrDict | None:
117
+ pr = api.pulls.get(pr_number)
118
+ sha = pr["head"]["sha"] # noqa
119
+ branch = pr["head"]["ref"]
120
+
121
+ runs = api.actions.list_workflow_runs_for_repo(branch=branch)["workflow_runs"]
122
+ # Find the run for this SHA
123
+ run = next(
124
+ (
125
+ r
126
+ for r in runs # r['head_sha'] == sha and
127
+ if (
128
+ any(
129
+ marker in r["path"].lower()
130
+ for marker in ["code-review", "code_review", "cr"]
131
+ )
132
+ or "gito.yml" in r["name"].lower()
133
+ )
134
+ and r["status"] == "completed"
135
+ ),
136
+ None,
137
+ )
138
+ return run
139
+
140
+
141
+ def download_latest_code_review_artifact(
142
+ api: GhApi, pr_number: int, gh_token: str, out_folder: Optional[str] = "artifact"
143
+ ) -> tuple[str, dict] | None:
144
+ run = last_code_review_run(api, pr_number)
145
+ if not run:
146
+ raise Exception("No workflow run found for this PR/SHA")
147
+
148
+ artifacts = api.actions.list_workflow_run_artifacts(run["id"])["artifacts"]
149
+ if not artifacts:
150
+ raise Exception("No artifacts found for this workflow run")
151
+
152
+ latest_artifact = artifacts[0]
153
+ url = latest_artifact["archive_download_url"]
154
+ print(f"Artifact: {latest_artifact['name']}, Download URL: {url}")
155
+ headers = {"Authorization": f"token {gh_token}"} if gh_token else {}
156
+ zip_path = "artifact.zip"
157
+ try:
158
+ with requests.get(url, headers=headers, stream=True) as r:
159
+ r.raise_for_status()
160
+ with open(zip_path, "wb") as f:
161
+ for chunk in r.iter_content(chunk_size=8192):
162
+ f.write(chunk)
163
+
164
+ # Unpack to ./artifact
165
+ os.makedirs("artifact", exist_ok=True)
166
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
167
+ zip_ref.extractall("artifact")
168
+ finally:
169
+ if os.path.exists(zip_path):
170
+ os.remove(zip_path)
171
+
172
+ print("Artifact unpacked to ./artifact")
173
+
174
+
175
+ def extract_fix_args(text: str) -> list[int]:
176
+ pattern1 = r"fix\s+(?:issues?)?(?:\s+)?#?(\d+(?:\s*,\s*#?\d+)*)"
177
+ match = re.search(pattern1, text)
178
+ if match:
179
+ numbers_str = match.group(1)
180
+ numbers = re.findall(r"\d+", numbers_str)
181
+ issue_numbers = [int(num) for num in numbers]
182
+ return issue_numbers
183
+ return []
184
+
185
+
186
+ def is_review_request(text: str) -> bool:
187
+ text = text.lower().strip()
188
+ trigger_words = ['review', 'run', 'code-review']
189
+ if any(f"/{word}" in text for word in trigger_words):
190
+ return True
191
+ parts = text.split()
192
+ if len(parts) == 2 and parts[1] in trigger_words:
193
+ return True
194
+ return False
gito/commands/repl.py CHANGED
@@ -4,8 +4,7 @@ Python REPL
4
4
  # flake8: noqa: F401
5
5
  import code
6
6
 
7
-
8
- # Imports for usage in REPL
7
+ # Wildcard imports are preferred to capture most of functionality for usage in REPL
9
8
  import os
10
9
  import sys
11
10
  from dataclasses import dataclass
@@ -18,6 +17,10 @@ import microcore as mc
18
17
  from microcore import ui
19
18
 
20
19
  from ..cli import app
20
+ from ..constants import *
21
+ from ..core import *
22
+ from ..utils import *
23
+
21
24
 
22
25
  @app.command(help="python REPL")
23
26
  def repl():