ai-cr 2.0.2__py3-none-any.whl → 3.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.
- {ai_cr-2.0.2.dist-info → ai_cr-3.0.0.dist-info}/METADATA +4 -4
- ai_cr-3.0.0.dist-info/RECORD +36 -0
- gito/bootstrap.py +34 -13
- gito/cli.py +110 -117
- gito/cli_base.py +90 -0
- gito/commands/deploy.py +101 -0
- gito/commands/fix.py +1 -1
- gito/commands/gh_post_review_comment.py +76 -28
- gito/commands/gh_react_to_comment.py +3 -5
- gito/commands/linear_comment.py +53 -0
- gito/commands/repl.py +2 -1
- gito/config.toml +31 -12
- gito/constants.py +1 -0
- gito/context.py +19 -0
- gito/core.py +158 -31
- gito/env.py +3 -0
- gito/gh_api.py +50 -0
- gito/issue_trackers.py +3 -2
- gito/pipeline.py +8 -7
- gito/pipeline_steps/jira.py +6 -1
- gito/project_config.py +11 -0
- gito/report_struct.py +4 -2
- gito/tpl/github_workflows/components/env-vars.j2 +10 -0
- gito/tpl/github_workflows/components/installs.j2 +23 -0
- gito/tpl/github_workflows/gito-code-review.yml.j2 +33 -0
- gito/tpl/github_workflows/gito-react-to-comments.yml.j2 +70 -0
- gito/tpl/release_notes.j2 +24 -0
- gito/utils.py +19 -3
- ai_cr-2.0.2.dist-info/RECORD +0 -26
- {ai_cr-2.0.2.dist-info → ai_cr-3.0.0.dist-info}/LICENSE +0 -0
- {ai_cr-2.0.2.dist-info → ai_cr-3.0.0.dist-info}/WHEEL +0 -0
- {ai_cr-2.0.2.dist-info → ai_cr-3.0.0.dist-info}/entry_points.txt +0 -0
@@ -1,23 +1,33 @@
|
|
1
1
|
import logging
|
2
2
|
import os
|
3
|
+
from time import sleep
|
3
4
|
|
4
5
|
import typer
|
5
|
-
from
|
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
|
6
|
+
from ghapi.core import GhApi
|
9
7
|
|
8
|
+
from ..cli_base import app
|
9
|
+
from ..constants import GITHUB_MD_REPORT_FILE_NAME, HTML_CR_COMMENT_MARKER
|
10
|
+
from ..gh_api import (
|
11
|
+
post_gh_comment,
|
12
|
+
resolve_gh_token,
|
13
|
+
hide_gh_comment,
|
14
|
+
)
|
15
|
+
from ..project_config import ProjectConfig
|
10
16
|
|
11
|
-
|
12
|
-
|
17
|
+
|
18
|
+
@app.command(name="github-comment", help="Leave a GitHub PR comment with the review.")
|
19
|
+
def post_github_cr_comment(
|
20
|
+
md_report_file: str = typer.Option(default=None),
|
21
|
+
pr: int = typer.Option(default=None),
|
22
|
+
gh_repo: str = typer.Option(default=None, help="owner/repo"),
|
13
23
|
token: str = typer.Option(
|
14
|
-
|
24
|
+
"", help="GitHub token (or set GITHUB_TOKEN env var)"
|
15
25
|
),
|
16
26
|
):
|
17
27
|
"""
|
18
28
|
Leaves a comment with the review on the current GitHub pull request.
|
19
29
|
"""
|
20
|
-
file = GITHUB_MD_REPORT_FILE_NAME
|
30
|
+
file = md_report_file or GITHUB_MD_REPORT_FILE_NAME
|
21
31
|
if not os.path.exists(file):
|
22
32
|
logging.error(f"Review file not found: {file}, comment will not be posted.")
|
23
33
|
raise typer.Exit(4)
|
@@ -25,30 +35,31 @@ def github_comment(
|
|
25
35
|
with open(file, "r", encoding="utf-8") as f:
|
26
36
|
body = f.read()
|
27
37
|
|
38
|
+
token = resolve_gh_token(token)
|
28
39
|
if not token:
|
29
40
|
print("GitHub token is required (--token or GITHUB_TOKEN env var).")
|
30
41
|
raise typer.Exit(1)
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
pr_env_val =
|
42
|
+
config = ProjectConfig.load()
|
43
|
+
gh_env = config.prompt_vars["github_env"]
|
44
|
+
gh_repo = gh_repo or gh_env.get("github_repo", "")
|
45
|
+
pr_env_val = gh_env.get("github_pr_number", "")
|
35
46
|
logging.info(f"github_pr_number = {pr_env_val}")
|
36
47
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
48
|
+
if not pr:
|
49
|
+
# e.g. could be "refs/pull/123/merge" or a direct number
|
50
|
+
if "/" in pr_env_val and "pull" in pr_env_val:
|
51
|
+
# refs/pull/123/merge
|
52
|
+
try:
|
53
|
+
pr_num_candidate = pr_env_val.strip("/").split("/")
|
54
|
+
idx = pr_num_candidate.index("pull")
|
55
|
+
pr = int(pr_num_candidate[idx + 1])
|
56
|
+
except Exception:
|
57
|
+
pass
|
58
|
+
else:
|
59
|
+
try:
|
60
|
+
pr = int(pr_env_val)
|
61
|
+
except ValueError:
|
62
|
+
pass
|
52
63
|
if not pr:
|
53
64
|
if pr_str := os.getenv("PR_NUMBER_FROM_WORKFLOW_DISPATCH"):
|
54
65
|
try:
|
@@ -59,5 +70,42 @@ def github_comment(
|
|
59
70
|
logging.error("Could not resolve PR number from environment variables.")
|
60
71
|
raise typer.Exit(3)
|
61
72
|
|
62
|
-
if not post_gh_comment(
|
73
|
+
if not post_gh_comment(gh_repo, pr, token, body):
|
63
74
|
raise typer.Exit(5)
|
75
|
+
|
76
|
+
if config.collapse_previous_code_review_comments:
|
77
|
+
sleep(1)
|
78
|
+
collapse_gh_outdated_cr_comments(gh_repo, pr, token)
|
79
|
+
|
80
|
+
|
81
|
+
def collapse_gh_outdated_cr_comments(
|
82
|
+
gh_repository: str,
|
83
|
+
pr_or_issue_number: int,
|
84
|
+
token: str = None
|
85
|
+
):
|
86
|
+
"""
|
87
|
+
Collapse outdated code review comments in a GitHub pull request or issue.
|
88
|
+
"""
|
89
|
+
logging.info(f"Collapsing outdated comments in {gh_repository} #{pr_or_issue_number}...")
|
90
|
+
|
91
|
+
token = resolve_gh_token(token)
|
92
|
+
owner, repo = gh_repository.split('/')
|
93
|
+
api = GhApi(owner, repo, token=token)
|
94
|
+
|
95
|
+
comments = api.issues.list_comments(pr_or_issue_number)
|
96
|
+
review_marker = HTML_CR_COMMENT_MARKER
|
97
|
+
collapsed_title = "🗑️ Outdated Code Review by Gito"
|
98
|
+
collapsed_marker = f"<summary>{collapsed_title}</summary>"
|
99
|
+
outdated_comments = [
|
100
|
+
c for c in comments
|
101
|
+
if c.body and review_marker in c.body and collapsed_marker not in c.body
|
102
|
+
][:-1]
|
103
|
+
if not outdated_comments:
|
104
|
+
logging.info("No outdated comments found")
|
105
|
+
return
|
106
|
+
for comment in outdated_comments:
|
107
|
+
logging.info(f"Collapsing comment {comment.id}...")
|
108
|
+
new_body = f"<details>\n<summary>{collapsed_title}</summary>\n\n{comment.body}\n</details>"
|
109
|
+
api.issues.update_comment(comment.id, new_body)
|
110
|
+
hide_gh_comment(comment.node_id, token)
|
111
|
+
logging.info("All outdated comments collapsed successfully.")
|
@@ -16,10 +16,10 @@ from microcore import ui
|
|
16
16
|
from ghapi.all import GhApi
|
17
17
|
import git
|
18
18
|
|
19
|
-
from ..
|
19
|
+
from ..cli_base import app
|
20
20
|
from ..constants import JSON_REPORT_FILE_NAME, HTML_TEXT_ICON
|
21
21
|
from ..core import answer
|
22
|
-
from ..gh_api import post_gh_comment
|
22
|
+
from ..gh_api import post_gh_comment, resolve_gh_token
|
23
23
|
from ..project_config import ProjectConfig
|
24
24
|
from ..utils import extract_gh_owner_repo
|
25
25
|
from .fix import fix
|
@@ -51,9 +51,7 @@ def react_to_comment(
|
|
51
51
|
repo = git.Repo(".") # Current directory
|
52
52
|
owner, repo_name = extract_gh_owner_repo(repo)
|
53
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
|
-
)
|
54
|
+
gh_token = resolve_gh_token(gh_token)
|
57
55
|
api = GhApi(owner=owner, repo=repo_name, token=gh_token)
|
58
56
|
comment = api.issues.get_comment(comment_id=comment_id)
|
59
57
|
logging.info(
|
@@ -0,0 +1,53 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
import logging
|
4
|
+
|
5
|
+
import typer
|
6
|
+
from git import Repo
|
7
|
+
|
8
|
+
from ..cli_base import app, arg_refs
|
9
|
+
from ..issue_trackers import resolve_issue_key
|
10
|
+
|
11
|
+
import requests
|
12
|
+
|
13
|
+
|
14
|
+
def post_linear_comment(issue_key, text, api_key):
|
15
|
+
response = requests.post(
|
16
|
+
'https://api.linear.app/graphql',
|
17
|
+
headers={'Authorization': api_key, 'Content-Type': 'application/json'},
|
18
|
+
json={
|
19
|
+
'query': '''
|
20
|
+
mutation($issueId: String!, $body: String!) {
|
21
|
+
commentCreate(input: {issueId: $issueId, body: $body}) {
|
22
|
+
comment { id }
|
23
|
+
}
|
24
|
+
}
|
25
|
+
''',
|
26
|
+
'variables': {'issueId': issue_key, 'body': text}
|
27
|
+
}
|
28
|
+
)
|
29
|
+
return response.json()
|
30
|
+
|
31
|
+
|
32
|
+
@app.command()
|
33
|
+
def linear_comment(
|
34
|
+
text: str = typer.Argument(None),
|
35
|
+
refs: str = arg_refs(),
|
36
|
+
):
|
37
|
+
if text is None or text == "-":
|
38
|
+
# Read from stdin if no text provided
|
39
|
+
text = sys.stdin.read()
|
40
|
+
|
41
|
+
if not text or not text.strip():
|
42
|
+
typer.echo("Error: No comment text provided.", err=True)
|
43
|
+
raise typer.Exit(code=1)
|
44
|
+
|
45
|
+
api_key = os.getenv("LINEAR_API_KEY")
|
46
|
+
if not api_key:
|
47
|
+
logging.error("LINEAR_API_KEY environment variable is not set")
|
48
|
+
return
|
49
|
+
|
50
|
+
repo = Repo(".")
|
51
|
+
key = resolve_issue_key(repo)
|
52
|
+
post_linear_comment(key, text, api_key)
|
53
|
+
logging.info("Comment posted to Linear issue %s", key)
|
gito/commands/repl.py
CHANGED
@@ -16,10 +16,11 @@ from rich.pretty import pprint
|
|
16
16
|
import microcore as mc
|
17
17
|
from microcore import ui
|
18
18
|
|
19
|
-
from ..
|
19
|
+
from ..cli_base import app
|
20
20
|
from ..constants import *
|
21
21
|
from ..core import *
|
22
22
|
from ..utils import *
|
23
|
+
from ..gh_api import *
|
23
24
|
|
24
25
|
|
25
26
|
@app.command(help="python REPL")
|
gito/config.toml
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
# Defines the keyword or mention tag that triggers bot actions when referenced in code review comments.
|
4
4
|
# list of strings, case-insensitive
|
5
5
|
mention_triggers = ["gito", "bot", "ai", "/fix"]
|
6
|
+
collapse_previous_code_review_comments = true
|
6
7
|
report_template_md = """
|
7
8
|
<h2>{{ HTML_TEXT_ICON }}I've Reviewed the Code</h2>
|
8
9
|
|
@@ -36,7 +37,7 @@ report_template_md = """
|
|
36
37
|
{%- endfor -%}
|
37
38
|
{{ "\n" }}
|
38
39
|
{%- endfor -%}
|
39
|
-
|
40
|
+
{{- HTML_CR_COMMENT_MARKER -}}
|
40
41
|
"""
|
41
42
|
report_template_cli = """
|
42
43
|
{{ Back.BLUE }} + + + ---==<<[ CODE REVIEW{{Style.NORMAL}} ]>>==--- + + + {{Style.RESET_ALL}}
|
@@ -49,7 +50,7 @@ report_template_cli = """
|
|
49
50
|
{%- if report.summary -%}
|
50
51
|
{{- "\n" }}
|
51
52
|
{{- "\n" }}{{- Style.BRIGHT }}✨ SUMMARY {{ Style.RESET_ALL -}}
|
52
|
-
{{- "\n" }}{{- report.summary -}}
|
53
|
+
{{- "\n" }}{{- remove_html_comments(report.summary) -}}
|
53
54
|
{%- endif %}
|
54
55
|
{% for issue in report.plain_issues -%}
|
55
56
|
{{"\n"}}{{ Style.BRIGHT }}{{Back.RED}}[ {{ issue.id}} ]{{Back.RESET}} {{ issue.title -}}{{ Style.RESET_ALL -}}
|
@@ -134,18 +135,18 @@ Review the provided code diff carefully and identify *only* highly confident iss
|
|
134
135
|
{{ requirements -}}
|
135
136
|
{{ json_requirements }}
|
136
137
|
|
137
|
-
Respond with a valid JSON array of issues
|
138
|
+
Respond with a valid JSON array of issues following this schema:
|
138
139
|
[
|
139
140
|
{
|
140
141
|
"title": "<issue_title>",
|
141
142
|
"details": "<issue_description>",
|
142
|
-
"tags": ["<issue_tag1>", "<issue_tag2>"],
|
143
|
-
"severity": <issue_severity>,
|
144
|
-
"confidence": <confidence_score>,
|
145
|
-
"affected_lines": [ // optional;
|
143
|
+
"tags": ["<issue_tag1>", "<issue_tag2>", ...],
|
144
|
+
"severity": <issue_severity:int>,
|
145
|
+
"confidence": <confidence_score:int>,
|
146
|
+
"affected_lines": [ // optional;
|
146
147
|
{
|
147
|
-
"start_line": <
|
148
|
-
"end_line": <
|
148
|
+
"start_line": <int>,
|
149
|
+
"end_line": <int>,
|
149
150
|
"proposal": "<proposed code to replace the affected lines (optional)>"
|
150
151
|
},
|
151
152
|
...
|
@@ -153,6 +154,10 @@ Respond with a valid JSON array of issues in the following format:
|
|
153
154
|
},
|
154
155
|
...
|
155
156
|
]
|
157
|
+
|
158
|
+
- if present, `proposal` blocks must match the indentation of the original code
|
159
|
+
and apply cleanly to lines `start_line`..`end_line`. It is designed for programmatical substitution.
|
160
|
+
|
156
161
|
Available issue tags:
|
157
162
|
- bug
|
158
163
|
- security
|
@@ -200,14 +205,16 @@ Summarize the code review in one sentence.
|
|
200
205
|
{{ issues | tojson(indent=2) }}
|
201
206
|
---
|
202
207
|
If the code changes include exceptional achievements, you may also present an award to the author in the summary text.
|
203
|
-
|
208
|
+
- (!) Only give awards to initial codebase authors, NOT to reviewers.
|
209
|
+
- (!) If you give an award, place the hidden <!-- award --> HTML comment on its own line immediately before the award text.
|
204
210
|
--Available Awards--
|
205
211
|
{{ awards }}
|
206
212
|
---
|
207
213
|
{% if pipeline_out.associated_issue and pipeline_out.associated_issue.title %}
|
208
214
|
----SUBTASK----
|
209
215
|
Include one sentence about how the code changes address the requirements of the associated issue listed below.
|
210
|
-
|
216
|
+
- (!) Place the hidden <!-- issue_alignment --> comment on its own line immediately before the related text.
|
217
|
+
- Use ✅ or ⚠️ to indicate whether the implementation fully satisfies the issue requirements.
|
211
218
|
--Associated Issue--
|
212
219
|
# {{ pipeline_out.associated_issue.title }}
|
213
220
|
{{ pipeline_out.associated_issue.description }}
|
@@ -218,10 +225,12 @@ Examples:
|
|
218
225
|
|
219
226
|
If the implementation fully delivers the requested functionality:
|
220
227
|
```
|
228
|
+
<!-- issue_alignment -->
|
221
229
|
✅ Implementation Satisfies [<ISSUE_KEY>](<ISSUE_URL>).
|
222
230
|
```
|
223
231
|
If there are concerns about how thoroughly the code covers the requirements and technical description from the associated issue:
|
224
232
|
```
|
233
|
+
<!-- issue_alignment -->
|
225
234
|
⚠️ <Describe specific gap or concern>.
|
226
235
|
⚠️ <Describe additional limitation or missing feature>.
|
227
236
|
```
|
@@ -248,6 +257,14 @@ Answer the following user question:
|
|
248
257
|
--FILE: {{ file }}--
|
249
258
|
{{ file_lines }}
|
250
259
|
{% endfor %}
|
260
|
+
|
261
|
+
{%- if pipeline_out.associated_issue and pipeline_out.associated_issue.title %}
|
262
|
+
----ASSOCIATED ISSUE----
|
263
|
+
# {{ pipeline_out.associated_issue.title }}
|
264
|
+
{{ pipeline_out.associated_issue.description }}
|
265
|
+
URL: {{ pipeline_out.associated_issue.url }}
|
266
|
+
{%- endif -%}{{ '\n' }}
|
267
|
+
|
251
268
|
----ANSWERING INSTRUCTIONS----
|
252
269
|
{{ answering_instructions }}
|
253
270
|
"""
|
@@ -433,7 +450,9 @@ decorators add depth and texture, and observer masterfully completes the composi
|
|
433
450
|
The Gang of Four gives a standing ovation from the stalls."
|
434
451
|
```
|
435
452
|
"""
|
436
|
-
requirements = ""
|
453
|
+
requirements = """
|
454
|
+
- (!) Never report issues related to software versions, model names, or similar details that you believe have not yet been released—you cannot reliably determine this.
|
455
|
+
"""
|
437
456
|
summary_requirements = ""
|
438
457
|
answering_instructions = """
|
439
458
|
- (!) Provide a concise, direct answer in engaging speech.
|
gito/constants.py
CHANGED
@@ -10,3 +10,4 @@ GITHUB_MD_REPORT_FILE_NAME = "code-review-report.md"
|
|
10
10
|
EXECUTABLE = "gito"
|
11
11
|
TEXT_ICON_URL = 'https://raw.githubusercontent.com/Nayjest/Gito/main/press-kit/logo/gito-bot-1_64top.png' # noqa: E501
|
12
12
|
HTML_TEXT_ICON = f'<a href="https://github.com/Nayjest/Gito"><img src="{TEXT_ICON_URL}" align="left" width=64 height=50 /></a>' # noqa: E501
|
13
|
+
HTML_CR_COMMENT_MARKER = '<!-- GITO_COMMENT:CODE_REVIEW_REPORT -->'
|
gito/context.py
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
from dataclasses import dataclass, field
|
2
|
+
from typing import Iterable, TYPE_CHECKING
|
3
|
+
|
4
|
+
from unidiff.patch import PatchSet, PatchedFile
|
5
|
+
from git import Repo
|
6
|
+
|
7
|
+
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
from .project_config import ProjectConfig
|
10
|
+
from .report_struct import Report
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass
|
14
|
+
class Context:
|
15
|
+
report: "Report"
|
16
|
+
config: "ProjectConfig"
|
17
|
+
diff: PatchSet | Iterable[PatchedFile]
|
18
|
+
repo: Repo
|
19
|
+
pipeline_out: dict = field(default_factory=dict)
|
gito/core.py
CHANGED
@@ -3,17 +3,22 @@ import logging
|
|
3
3
|
from os import PathLike
|
4
4
|
from typing import Iterable
|
5
5
|
from pathlib import Path
|
6
|
+
from functools import partial
|
6
7
|
|
7
8
|
import microcore as mc
|
8
|
-
from
|
9
|
-
from
|
9
|
+
from microcore import ui
|
10
|
+
from git import Repo, Commit
|
11
|
+
from git.exc import GitCommandError
|
10
12
|
from unidiff import PatchSet, PatchedFile
|
11
13
|
from unidiff.constants import DEV_NULL
|
12
14
|
|
15
|
+
from .context import Context
|
13
16
|
from .project_config import ProjectConfig
|
14
17
|
from .report_struct import Report
|
15
18
|
from .constants import JSON_REPORT_FILE_NAME
|
16
|
-
from .utils import
|
19
|
+
from .utils import make_streaming_function
|
20
|
+
from .pipeline import Pipeline
|
21
|
+
from .env import Env
|
17
22
|
|
18
23
|
|
19
24
|
def review_subject_is_index(what):
|
@@ -51,6 +56,16 @@ def is_binary_file(repo: Repo, file_path: str) -> bool:
|
|
51
56
|
return True # Conservatively treat errors as binary to avoid issues
|
52
57
|
|
53
58
|
|
59
|
+
def commit_in_branch(repo: Repo, commit: Commit, target_branch: str) -> bool:
|
60
|
+
try:
|
61
|
+
# exit code 0 if commit is ancestor of branch
|
62
|
+
repo.git.merge_base('--is-ancestor', commit.hexsha, target_branch)
|
63
|
+
return True
|
64
|
+
except GitCommandError:
|
65
|
+
pass
|
66
|
+
return False
|
67
|
+
|
68
|
+
|
54
69
|
def get_diff(
|
55
70
|
repo: Repo = None,
|
56
71
|
what: str = None,
|
@@ -76,12 +91,75 @@ def get_diff(
|
|
76
91
|
else:
|
77
92
|
current_ref = what
|
78
93
|
merge_base = repo.merge_base(current_ref or repo.active_branch.name, against)[0]
|
79
|
-
against = merge_base.hexsha
|
80
94
|
logging.info(
|
81
|
-
f"
|
95
|
+
f"Merge base({ui.green(current_ref)},{ui.yellow(against)})"
|
96
|
+
f" --> {ui.cyan(merge_base.hexsha)}"
|
82
97
|
)
|
98
|
+
# if branch is already an ancestor of "against", merge_base == branch ⇒ it’s been merged
|
99
|
+
if merge_base.hexsha == repo.commit(current_ref or repo.active_branch.name).hexsha:
|
100
|
+
# @todo: check case: reviewing working copy index in main branch #103
|
101
|
+
logging.info(
|
102
|
+
f"Branch is already merged. ({ui.green(current_ref)} vs {ui.yellow(against)})"
|
103
|
+
)
|
104
|
+
merge_sha = repo.git.log(
|
105
|
+
'--merges',
|
106
|
+
'--ancestry-path',
|
107
|
+
f'{current_ref}..{against}',
|
108
|
+
'-n',
|
109
|
+
'1',
|
110
|
+
'--pretty=format:%H'
|
111
|
+
).strip()
|
112
|
+
if merge_sha:
|
113
|
+
logging.info(f"Merge commit is {ui.cyan(merge_sha)}")
|
114
|
+
merge_commit = repo.commit(merge_sha)
|
115
|
+
|
116
|
+
other_merge_parent = None
|
117
|
+
for parent in merge_commit.parents:
|
118
|
+
logging.info(f"Checking merge parent: {parent.hexsha[:8]}")
|
119
|
+
if parent.hexsha == merge_base.hexsha:
|
120
|
+
logging.info(f"merge parent is {ui.cyan(parent.hexsha[:8])}, skipping")
|
121
|
+
continue
|
122
|
+
if not commit_in_branch(repo, parent, against):
|
123
|
+
logging.warning(f"merge parent is not in {against}, skipping")
|
124
|
+
continue
|
125
|
+
logging.info(f"Found other merge parent: {ui.cyan(parent.hexsha[:8])}")
|
126
|
+
other_merge_parent = parent
|
127
|
+
break
|
128
|
+
if other_merge_parent:
|
129
|
+
first_common_ancestor = repo.merge_base(other_merge_parent, merge_base)[0]
|
130
|
+
# for gito remote (feature_branch vs origin/main)
|
131
|
+
# the same merge base appears in first_common_ancestor again
|
132
|
+
if first_common_ancestor.hexsha == merge_base.hexsha:
|
133
|
+
if merge_base.parents:
|
134
|
+
first_common_ancestor = repo.merge_base(
|
135
|
+
other_merge_parent, merge_base.parents[0]
|
136
|
+
)[0]
|
137
|
+
else:
|
138
|
+
logging.error(
|
139
|
+
"merge_base has no parents, "
|
140
|
+
"using merge_base as first_common_ancestor"
|
141
|
+
)
|
142
|
+
logging.info(
|
143
|
+
f"{what} will be compared to "
|
144
|
+
f"first common ancestor of {what} and {against}: "
|
145
|
+
f"{ui.cyan(first_common_ancestor.hexsha[:8])}"
|
146
|
+
)
|
147
|
+
against = first_common_ancestor.hexsha
|
148
|
+
else:
|
149
|
+
logging.error(f"Can't find other merge parent for {merge_sha}")
|
150
|
+
else:
|
151
|
+
logging.error(
|
152
|
+
f"No merge‐commit found for {current_ref!r}→{against!r}; "
|
153
|
+
"falling back to merge‐base diff"
|
154
|
+
)
|
155
|
+
else:
|
156
|
+
# normal case: branch not yet merged
|
157
|
+
against = merge_base.hexsha
|
158
|
+
logging.info(
|
159
|
+
f"Using merge base: {ui.cyan(merge_base.hexsha[:8])} ({merge_base.summary})"
|
160
|
+
)
|
83
161
|
logging.info(
|
84
|
-
f"Making diff: {
|
162
|
+
f"Making diff: {ui.green(what or 'INDEX')} vs {ui.yellow(against)}"
|
85
163
|
)
|
86
164
|
diff_content = repo.git.diff(against, what)
|
87
165
|
diff = PatchSet.from_string(diff_content)
|
@@ -145,16 +223,17 @@ def file_lines(repo: Repo, file: str, max_tokens: int = None, use_local_files: b
|
|
145
223
|
return "".join(lines)
|
146
224
|
|
147
225
|
|
148
|
-
def make_cr_summary(
|
226
|
+
def make_cr_summary(ctx: Context, **kwargs) -> str:
|
149
227
|
return (
|
150
228
|
mc.prompt(
|
151
|
-
config.summary_prompt,
|
152
|
-
diff=mc.tokenizing.fit_to_token_size(diff, config.max_code_tokens)[0],
|
153
|
-
issues=report.issues,
|
154
|
-
|
229
|
+
ctx.config.summary_prompt,
|
230
|
+
diff=mc.tokenizing.fit_to_token_size(ctx.diff, ctx.config.max_code_tokens)[0],
|
231
|
+
issues=ctx.report.issues,
|
232
|
+
pipeline_out=ctx.pipeline_out,
|
233
|
+
**ctx.config.prompt_vars,
|
155
234
|
**kwargs,
|
156
235
|
).to_llm()
|
157
|
-
if config.summary_prompt
|
236
|
+
if ctx.config.summary_prompt
|
158
237
|
else ""
|
159
238
|
)
|
160
239
|
|
@@ -197,6 +276,41 @@ def _prepare(
|
|
197
276
|
return repo, cfg, diff, lines
|
198
277
|
|
199
278
|
|
279
|
+
def get_affected_code_block(repo: Repo, file: str, start_line: int, end_line: int) -> str | None:
|
280
|
+
if not start_line or not end_line:
|
281
|
+
return None
|
282
|
+
try:
|
283
|
+
if isinstance(start_line, str):
|
284
|
+
start_line = int(start_line)
|
285
|
+
if isinstance(end_line, str):
|
286
|
+
end_line = int(end_line)
|
287
|
+
lines = file_lines(repo, file, max_tokens=None, use_local_files=True)
|
288
|
+
if lines:
|
289
|
+
lines = [""] + lines.splitlines()
|
290
|
+
return "\n".join(
|
291
|
+
lines[start_line: end_line + 1]
|
292
|
+
)
|
293
|
+
except Exception as e:
|
294
|
+
logging.error(
|
295
|
+
f"Error getting affected code block for {file} from {start_line} to {end_line}: {e}"
|
296
|
+
)
|
297
|
+
return None
|
298
|
+
|
299
|
+
|
300
|
+
def provide_affected_code_blocks(issues: dict, repo: Repo):
|
301
|
+
for file, file_issues in issues.items():
|
302
|
+
for issue in file_issues:
|
303
|
+
for i in issue.get("affected_lines", []):
|
304
|
+
file_name = i.get("file", issue.get("file", file))
|
305
|
+
if block := get_affected_code_block(
|
306
|
+
repo,
|
307
|
+
file_name,
|
308
|
+
i.get("start_line"),
|
309
|
+
i.get("end_line")
|
310
|
+
):
|
311
|
+
i["affected_code"] = block
|
312
|
+
|
313
|
+
|
200
314
|
async def review(
|
201
315
|
repo: Repo = None,
|
202
316
|
what: str = None,
|
@@ -226,24 +340,16 @@ async def review(
|
|
226
340
|
parse_json=True,
|
227
341
|
)
|
228
342
|
issues = {file.path: issues for file, issues in zip(diff, responses) if issues}
|
229
|
-
|
230
|
-
for issue in file_issues:
|
231
|
-
for i in issue.get("affected_lines", []):
|
232
|
-
if lines[file]:
|
233
|
-
f_lines = [""] + lines[file].splitlines()
|
234
|
-
i["affected_code"] = "\n".join(
|
235
|
-
f_lines[i["start_line"]: i["end_line"] + 1]
|
236
|
-
)
|
343
|
+
provide_affected_code_blocks(issues, repo)
|
237
344
|
exec(cfg.post_process, {"mc": mc, **locals()})
|
238
345
|
out_folder = Path(out_folder or repo.working_tree_dir)
|
239
346
|
out_folder.mkdir(parents=True, exist_ok=True)
|
240
347
|
report = Report(issues=issues, number_of_processed_files=len(diff))
|
241
|
-
ctx =
|
348
|
+
ctx = Context(
|
242
349
|
report=report,
|
243
350
|
config=cfg,
|
244
351
|
diff=diff,
|
245
352
|
repo=repo,
|
246
|
-
pipeline_out={},
|
247
353
|
)
|
248
354
|
if cfg.pipeline_steps:
|
249
355
|
pipe = Pipeline(
|
@@ -254,7 +360,7 @@ async def review(
|
|
254
360
|
else:
|
255
361
|
logging.info("No pipeline steps defined, skipping pipeline execution")
|
256
362
|
|
257
|
-
report.summary = make_cr_summary(
|
363
|
+
report.summary = make_cr_summary(ctx)
|
258
364
|
report.save(file_name=out_folder / JSON_REPORT_FILE_NAME)
|
259
365
|
report_text = report.render(cfg, Report.Format.MARKDOWN)
|
260
366
|
text_report_path = out_folder / "code-review-report.md"
|
@@ -269,20 +375,41 @@ def answer(
|
|
269
375
|
against: str = None,
|
270
376
|
filters: str | list[str] = "",
|
271
377
|
use_merge_base: bool = True,
|
378
|
+
use_pipeline: bool = True,
|
379
|
+
prompt_file: str = None,
|
272
380
|
) -> str | None:
|
273
381
|
try:
|
274
|
-
repo,
|
382
|
+
repo, config, diff, lines = _prepare(
|
275
383
|
repo=repo, what=what, against=against, filters=filters, use_merge_base=use_merge_base
|
276
384
|
)
|
277
385
|
except NoChangesInContextError:
|
278
386
|
logging.error("No changes to review")
|
279
387
|
return
|
280
|
-
|
281
|
-
|
282
|
-
|
388
|
+
|
389
|
+
ctx = Context(
|
390
|
+
repo=repo,
|
283
391
|
diff=diff,
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
392
|
+
config=config,
|
393
|
+
report=Report()
|
394
|
+
)
|
395
|
+
if use_pipeline:
|
396
|
+
pipe = Pipeline(
|
397
|
+
ctx=ctx,
|
398
|
+
steps=config.pipeline_steps
|
399
|
+
)
|
400
|
+
pipe.run()
|
401
|
+
if prompt_file:
|
402
|
+
prompt_func = partial(mc.tpl, prompt_file)
|
403
|
+
else:
|
404
|
+
prompt_func = partial(mc.prompt, config.answer_prompt)
|
405
|
+
response = mc.llm(
|
406
|
+
prompt_func(
|
407
|
+
question=question,
|
408
|
+
diff=diff,
|
409
|
+
all_file_lines=lines,
|
410
|
+
pipeline_out=ctx.pipeline_out,
|
411
|
+
**config.prompt_vars,
|
412
|
+
),
|
413
|
+
callback=make_streaming_function() if Env.verbosity == 0 else None,
|
414
|
+
)
|
288
415
|
return response
|
gito/env.py
ADDED