ai-cr 1.0.1__py3-none-any.whl → 2.0.0.dev2__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.0.dev2.dist-info/METADATA +207 -0
- ai_cr-2.0.0.dev2.dist-info/RECORD +23 -0
- ai_cr-2.0.0.dev2.dist-info/entry_points.txt +3 -0
- {ai_code_review → gito}/bootstrap.py +8 -4
- {ai_code_review → gito}/cli.py +29 -12
- gito/commands/__init__.py +1 -0
- gito/commands/fix.py +157 -0
- gito/commands/gh_comment.py +157 -0
- {ai_code_review → gito}/commands/repl.py +3 -1
- ai_code_review/.ai-code-review.toml → gito/config.toml +113 -9
- gito/constants.py +9 -0
- {ai_code_review → gito}/core.py +64 -16
- gito/issue_trackers.py +15 -0
- gito/pipeline.py +70 -0
- gito/pipeline_steps/__init__.py +0 -0
- gito/pipeline_steps/jira.py +83 -0
- gito/project_config.py +71 -0
- {ai_code_review → gito}/report_struct.py +32 -7
- gito/utils.py +214 -0
- ai_code_review/constants.py +0 -7
- ai_code_review/project_config.py +0 -99
- ai_code_review/utils.py +0 -116
- ai_cr-1.0.1.dist-info/METADATA +0 -197
- ai_cr-1.0.1.dist-info/RECORD +0 -16
- ai_cr-1.0.1.dist-info/entry_points.txt +0 -3
- {ai_cr-1.0.1.dist-info → ai_cr-2.0.0.dev2.dist-info}/LICENSE +0 -0
- {ai_cr-1.0.1.dist-info → ai_cr-2.0.0.dev2.dist-info}/WHEEL +0 -0
- {ai_code_review → gito}/__init__.py +0 -0
- {ai_code_review → gito}/__main__.py +0 -0
@@ -3,7 +3,7 @@ Python REPL
|
|
3
3
|
"""
|
4
4
|
# flake8: noqa: F401
|
5
5
|
import code
|
6
|
-
|
6
|
+
|
7
7
|
|
8
8
|
# Imports for usage in REPL
|
9
9
|
import os
|
@@ -17,6 +17,8 @@ from rich.pretty import pprint
|
|
17
17
|
import microcore as mc
|
18
18
|
from microcore import ui
|
19
19
|
|
20
|
+
from ..cli import app
|
21
|
+
|
20
22
|
@app.command(help="python REPL")
|
21
23
|
def repl():
|
22
24
|
code.interact(local=globals())
|
@@ -1,14 +1,20 @@
|
|
1
|
+
# :class: gito.project_config.ProjectConfig
|
2
|
+
|
3
|
+
# Defines the keyword or mention tag that triggers bot actions when referenced in code review comments.
|
4
|
+
# list of strings, case-insensitive
|
5
|
+
mention_triggers = ["gito", "bot", "ai", "/fix"]
|
1
6
|
report_template_md = """
|
2
|
-
|
7
|
+
<h2><a href="https://github.com/Nayjest/Gito"><img src="https://raw.githubusercontent.com/Nayjest/Gito/main/press-kit/logo/gito-bot-1_64top.png" align="left" width=64 height=50></a>I've Reviewed the Code</h2>
|
3
8
|
|
4
9
|
{% if report.summary -%}
|
5
10
|
{{ report.summary }}
|
6
11
|
{%- endif %}
|
7
12
|
|
8
|
-
|
9
|
-
|
10
|
-
{
|
11
|
-
in
|
13
|
+
{% if report.total_issues > 0 -%}
|
14
|
+
**⚠️ {{ report.total_issues }} issue{{ 's' if report.total_issues != 1 else '' }} found** across {{ report.number_of_processed_files }} file{{ 's' if report.number_of_processed_files != 1 else '' }}
|
15
|
+
{%- else -%}
|
16
|
+
**✅ No issues found** in {{ report.number_of_processed_files }} file{{ 's' if report.number_of_processed_files != 1 else '' }}
|
17
|
+
{%- endif -%}
|
12
18
|
|
13
19
|
{%- for issue in report.plain_issues -%}
|
14
20
|
{{"\n"}}## `#{{ issue.id}}` {{ issue.title -}}
|
@@ -31,6 +37,77 @@ in `{{ report.number_of_processed_files }}` files**
|
|
31
37
|
{{ "\n" }}
|
32
38
|
{%- endfor -%}
|
33
39
|
|
40
|
+
"""
|
41
|
+
report_template_cli = """
|
42
|
+
{{ Back.BLUE }} + + + ---==<<[ CODE REVIEW{{Style.NORMAL}} ]>>==--- + + + {{Style.RESET_ALL}}
|
43
|
+
{% if report.total_issues > 0 -%}
|
44
|
+
{{ Style.BRIGHT }}{{Back.RED}} ⚠️ {{ report.total_issues }} issue{{ 's' if report.total_issues != 1 else '' }} {{Back.RESET}} found across {{Back.BLUE}} {{ report.number_of_processed_files }} {{Back.RESET}} file{{ 's' if report.number_of_processed_files != 1 else '' }}{{ Style.RESET_ALL }}
|
45
|
+
{%- else -%}
|
46
|
+
{{ Style.BRIGHT }}{{Back.GREEN}} ✅ No issues found {{Back.RESET}} in {{Back.BLUE}} {{ report.number_of_processed_files }} {{Back.RESET}} file{{ 's' if report.number_of_processed_files != 1 else '' }}{{ Style.RESET_ALL }}
|
47
|
+
{%- endif -%}
|
48
|
+
|
49
|
+
{%- if report.summary -%}
|
50
|
+
{{- "\n" }}
|
51
|
+
{{- "\n" }}{{- Style.BRIGHT }}✨ SUMMARY {{ Style.RESET_ALL -}}
|
52
|
+
{{- "\n" }}{{- report.summary -}}
|
53
|
+
{%- endif %}
|
54
|
+
{% for issue in report.plain_issues -%}
|
55
|
+
{{"\n"}}{{ Style.BRIGHT }}{{Back.RED}}[ {{ issue.id}} ]{{Back.RESET}} {{ issue.title -}}{{ Style.RESET_ALL -}}
|
56
|
+
{{ "\n"}}{{ file_link(issue.file) -}}
|
57
|
+
{%- if issue.affected_lines -%}:{{issue.affected_lines[0].start_line}}{%- endif -%}
|
58
|
+
{{' '}}
|
59
|
+
|
60
|
+
{%- if issue.affected_lines -%}
|
61
|
+
{% if issue.affected_lines[0].end_line != issue.affected_lines[0].start_line or issue.affected_lines|length > 1 -%}
|
62
|
+
{{ ui.gray }}Lines{{' '}}
|
63
|
+
{{- Fore.RESET -}}
|
64
|
+
{%- for i in issue.affected_lines -%}
|
65
|
+
{{ i.start_line }}{%- if i.end_line != i.start_line -%}{{ ui.gray }}–{{Fore.RESET}}{{ i.end_line }}{%- endif -%}
|
66
|
+
{%- if loop.last == false -%}
|
67
|
+
{{ ui.gray(', ') }}
|
68
|
+
{%- endif -%}
|
69
|
+
{%- endfor -%}
|
70
|
+
{%- endif -%}
|
71
|
+
{%- endif -%}
|
72
|
+
{{-"\n"-}}
|
73
|
+
|
74
|
+
{% if issue.details -%}
|
75
|
+
{{- "\n" -}}
|
76
|
+
{{- issue.details.strip() -}}
|
77
|
+
{{-"\n" -}}
|
78
|
+
{%- endif -%}
|
79
|
+
|
80
|
+
{%- for tag in issue.tags -%}
|
81
|
+
{{Back.YELLOW}}{{Fore.BLACK}} {{tag}} {{Style.RESET_ALL}}{{ ' ' }}
|
82
|
+
{%- endfor -%}
|
83
|
+
{%- if issue.tags %}{{ "\n" }}{% endif -%}
|
84
|
+
|
85
|
+
{%- for i in issue.affected_lines -%}
|
86
|
+
{%- if i.affected_code -%}
|
87
|
+
{{- "\n"+Fore.RED + " ╭─" + "─"*4 + "[ 💥 Affected Code ]" + "─"*4 + " ─── ── ─\n" -}}
|
88
|
+
{{- textwrap.indent(i.affected_code.strip(), Fore.RED+' │ ') -}}
|
89
|
+
{{- "\n ╰─"+"─"*2+Style.RESET_ALL -}}
|
90
|
+
{%- endif -%}
|
91
|
+
{%- if i.proposal -%}
|
92
|
+
{%- set maxlen = 100 -%}
|
93
|
+
{%- if not i.affected_code %}{{ Fore.GREEN }} ╭────{% endif -%}
|
94
|
+
{#- Wrap right for one-liner, doesn't prevent copying code -#}
|
95
|
+
{%- if i.proposal.splitlines() | length == 1 and max_line_len(i.proposal)<80 -%}
|
96
|
+
{{- Fore.GREEN + "─"*2 + "[ 💡 Proposed Change ]" + "─"*(max_line_len(i.proposal)-29) + "─╮" +"\n" -}}
|
97
|
+
{{- block_wrap_lr(i.proposal, '', ' │', 60) -}}
|
98
|
+
{{- "\n" + " ╰──"+"─"*(max_line_len(i.proposal)-5+1)+"─╯" -}}
|
99
|
+
{#- Open right side to not prevent multiline code copying -#}
|
100
|
+
{%- else -%}
|
101
|
+
{{- Fore.GREEN + "─"*2 + "[ 💡 Proposed Change ]" + "─"*([max_line_len(i.proposal)-29+2,maxlen-26-2]|min -2) + "─╮" +"\n" -}}
|
102
|
+
{{- i.proposal -}}
|
103
|
+
{{- "\n" + " ╰───"+"─"*([[max_line_len(i.proposal)-29+2,maxlen-29+1]|min - 2 + 29 - 5,29-7+2]|max)+"─╯" -}}
|
104
|
+
{%- endif -%}
|
105
|
+
|
106
|
+
{{- Style.RESET_ALL -}}
|
107
|
+
{% endif -%}
|
108
|
+
{%- endfor -%}
|
109
|
+
{{ "\n" }}
|
110
|
+
{%- endfor -%}
|
34
111
|
"""
|
35
112
|
retries = 3
|
36
113
|
prompt = """
|
@@ -64,7 +141,7 @@ Respond with a valid JSON array of issues in the following format:
|
|
64
141
|
"details": "<issue_description>",
|
65
142
|
"tags": ["<issue_tag1>", "<issue_tag2>"],
|
66
143
|
"severity": <issue_severity>,
|
67
|
-
"confidence": <confidence_score
|
144
|
+
"confidence": <confidence_score>,
|
68
145
|
"affected_lines": [ // optional; list of affected lines
|
69
146
|
{
|
70
147
|
"start_line": <start_line:int>,
|
@@ -119,18 +196,45 @@ summary_prompt = """
|
|
119
196
|
Summarize the code review in one sentence.
|
120
197
|
--Reviewed Changes--
|
121
198
|
{% for part in diff %}{{ part }}\n{% endfor %}
|
122
|
-
--Detected
|
199
|
+
--Issues Detected by You--
|
123
200
|
{{ issues | tojson(indent=2) }}
|
124
201
|
---
|
125
|
-
If code changes
|
202
|
+
If the code changes include exceptional achievements, you may also present an award to the author in the summary text.
|
203
|
+
Note: Awards should only be given to authors of initial codebase changes, not to code reviewers.
|
126
204
|
--Available Awards--
|
127
205
|
{{ awards }}
|
128
206
|
---
|
207
|
+
{% if pipeline_out.associated_issue and pipeline_out.associated_issue.title %}
|
208
|
+
----SUBTASK----
|
209
|
+
Include one sentence about how the code changes address the requirements of the associated issue listed below.
|
210
|
+
|
211
|
+
--Associated Issue--
|
212
|
+
# {{ pipeline_out.associated_issue.title }}
|
213
|
+
{{ pipeline_out.associated_issue.description }}
|
214
|
+
URL: {{ pipeline_out.associated_issue.url }}
|
215
|
+
---
|
216
|
+
|
217
|
+
Examples:
|
218
|
+
|
219
|
+
In case if the implementation delivers what was requested:
|
220
|
+
```
|
221
|
+
✅ Implementation Satisfies [<ISSUE_KEY>](<ISSUE_URL>).
|
222
|
+
```
|
223
|
+
In case of any critical concerns:
|
224
|
+
```
|
225
|
+
⚠️ <Your concern 1 here>.
|
226
|
+
⚠️ <Your concern 2 here>.
|
227
|
+
```
|
228
|
+
--------
|
229
|
+
{% endif -%}
|
129
230
|
- Your response will be parsed programmatically, so do not include any additional text.
|
231
|
+
- Do not include the issues by itself to the summary, they are already provided in the context.
|
130
232
|
- Use Markdown formatting in your response.
|
131
233
|
{{ summary_requirements -}}
|
132
234
|
"""
|
133
|
-
|
235
|
+
[pipeline_steps.jira]
|
236
|
+
call="gito.pipeline_steps.jira.fetch_associated_issue"
|
237
|
+
envs=["local","gh-action"]
|
134
238
|
[prompt_vars]
|
135
239
|
self_id = """
|
136
240
|
You are a subsystem of an AI-powered software platform, specifically tasked with performing expert code reviews.
|
gito/constants.py
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
|
3
|
+
PROJECT_GITO_FOLDER = ".gito"
|
4
|
+
PROJECT_CONFIG_FILE_NAME = "config.toml"
|
5
|
+
PROJECT_CONFIG_FILE_PATH = Path(".gito") / PROJECT_CONFIG_FILE_NAME
|
6
|
+
PROJECT_CONFIG_BUNDLED_DEFAULTS_FILE = Path(__file__).resolve().parent / PROJECT_CONFIG_FILE_NAME
|
7
|
+
HOME_ENV_PATH = Path("~/.gito/.env").expanduser()
|
8
|
+
JSON_REPORT_FILE_NAME = "code-review-report.json"
|
9
|
+
EXECUTABLE = "gito"
|
{ai_code_review → gito}/core.py
RENAMED
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
6
6
|
|
7
7
|
import microcore as mc
|
8
8
|
from git import Repo
|
9
|
+
from gito.pipeline import Pipeline
|
9
10
|
from unidiff import PatchSet, PatchedFile
|
10
11
|
from unidiff.constants import DEV_NULL
|
11
12
|
|
@@ -14,6 +15,10 @@ from .report_struct import Report
|
|
14
15
|
from .constants import JSON_REPORT_FILE_NAME
|
15
16
|
|
16
17
|
|
18
|
+
def review_subject_is_index(what):
|
19
|
+
return not what or what == 'INDEX'
|
20
|
+
|
21
|
+
|
17
22
|
def is_binary_file(repo: Repo, file_path: str) -> bool:
|
18
23
|
"""
|
19
24
|
Check if a file is binary by attempting to read it as text.
|
@@ -25,7 +30,20 @@ def is_binary_file(repo: Repo, file_path: str) -> bool:
|
|
25
30
|
# Try decoding as UTF-8; if it fails, it's likely binary
|
26
31
|
content.decode("utf-8")
|
27
32
|
return False
|
28
|
-
except
|
33
|
+
except KeyError:
|
34
|
+
try:
|
35
|
+
fs_path = Path(repo.working_tree_dir) / file_path
|
36
|
+
fs_path.read_text(encoding='utf-8')
|
37
|
+
return False
|
38
|
+
except FileNotFoundError:
|
39
|
+
logging.error(f"File {file_path} not found in the repository.")
|
40
|
+
return True
|
41
|
+
except UnicodeDecodeError:
|
42
|
+
return True
|
43
|
+
except Exception as e:
|
44
|
+
logging.error(f"Error reading file {file_path}: {e}")
|
45
|
+
return True
|
46
|
+
except UnicodeDecodeError:
|
29
47
|
return True
|
30
48
|
except Exception as e:
|
31
49
|
logging.warning(f"Error checking if file {file_path} is binary: {e}")
|
@@ -40,11 +58,12 @@ def get_diff(
|
|
40
58
|
) -> PatchSet | list[PatchedFile]:
|
41
59
|
repo = repo or Repo(".")
|
42
60
|
if not against:
|
43
|
-
|
44
|
-
|
61
|
+
# 'origin/main', 'origin/master', etc
|
62
|
+
against = repo.remotes.origin.refs.HEAD.reference.name
|
63
|
+
if review_subject_is_index(what):
|
45
64
|
what = None # working copy
|
46
65
|
if use_merge_base:
|
47
|
-
if what
|
66
|
+
if review_subject_is_index(what):
|
48
67
|
try:
|
49
68
|
current_ref = repo.active_branch.name
|
50
69
|
except TypeError:
|
@@ -65,7 +84,6 @@ def get_diff(
|
|
65
84
|
)
|
66
85
|
diff_content = repo.git.diff(against, what)
|
67
86
|
diff = PatchSet.from_string(diff_content)
|
68
|
-
diff = PatchSet.from_string(diff_content)
|
69
87
|
|
70
88
|
# Filter out binary files
|
71
89
|
non_binary_diff = PatchSet([])
|
@@ -76,7 +94,9 @@ def get_diff(
|
|
76
94
|
if patched_file.target_file != DEV_NULL
|
77
95
|
else patched_file.source_file
|
78
96
|
)
|
79
|
-
if file_path == DEV_NULL
|
97
|
+
if file_path == DEV_NULL:
|
98
|
+
continue
|
99
|
+
if is_binary_file(repo, file_path.lstrip("b/")):
|
80
100
|
logging.info(f"Skipping binary file: {patched_file.path}")
|
81
101
|
continue
|
82
102
|
non_binary_diff.append(patched_file)
|
@@ -102,8 +122,18 @@ def filter_diff(
|
|
102
122
|
return files
|
103
123
|
|
104
124
|
|
105
|
-
def file_lines(repo: Repo, file: str, max_tokens: int = None) -> str:
|
106
|
-
|
125
|
+
def file_lines(repo: Repo, file: str, max_tokens: int = None, use_local_files: bool = False) -> str:
|
126
|
+
if use_local_files:
|
127
|
+
file_path = Path(repo.working_tree_dir) / file
|
128
|
+
try:
|
129
|
+
text = file_path.read_text(encoding='utf-8')
|
130
|
+
except (FileNotFoundError, UnicodeDecodeError) as e:
|
131
|
+
logging.warning(f"Could not read file {file} from working directory: {e}")
|
132
|
+
text = repo.tree()[file].data_stream.read().decode('utf-8')
|
133
|
+
else:
|
134
|
+
# Read from HEAD (committed version)
|
135
|
+
text = repo.tree()[file].data_stream.read().decode('utf-8')
|
136
|
+
|
107
137
|
lines = [f"{i + 1}: {line}\n" for i, line in enumerate(text.splitlines())]
|
108
138
|
if max_tokens:
|
109
139
|
lines, removed_qty = mc.tokenizing.fit_to_token_size(lines, max_tokens)
|
@@ -114,15 +144,16 @@ def file_lines(repo: Repo, file: str, max_tokens: int = None) -> str:
|
|
114
144
|
return "".join(lines)
|
115
145
|
|
116
146
|
|
117
|
-
def make_cr_summary(
|
147
|
+
def make_cr_summary(config: ProjectConfig, report: Report, diff, **kwargs) -> str:
|
118
148
|
return (
|
119
149
|
mc.prompt(
|
120
|
-
|
121
|
-
diff=mc.tokenizing.fit_to_token_size(diff,
|
150
|
+
config.summary_prompt,
|
151
|
+
diff=mc.tokenizing.fit_to_token_size(diff, config.max_code_tokens)[0],
|
122
152
|
issues=report.issues,
|
123
|
-
**
|
153
|
+
**config.prompt_vars,
|
154
|
+
**kwargs,
|
124
155
|
).to_llm()
|
125
|
-
if
|
156
|
+
if config.summary_prompt
|
126
157
|
else ""
|
127
158
|
)
|
128
159
|
|
@@ -135,8 +166,8 @@ async def review(
|
|
135
166
|
use_merge_base: bool = True,
|
136
167
|
out_folder: str | PathLike | None = None,
|
137
168
|
):
|
138
|
-
cfg = ProjectConfig.load()
|
139
169
|
repo = repo or Repo(".")
|
170
|
+
cfg = ProjectConfig.load_for_repo(repo)
|
140
171
|
out_folder = Path(out_folder or repo.working_tree_dir)
|
141
172
|
diff = get_diff(
|
142
173
|
repo=repo, what=what, against=against, use_merge_base=use_merge_base
|
@@ -152,6 +183,7 @@ async def review(
|
|
152
183
|
file_diff.path,
|
153
184
|
cfg.max_code_tokens
|
154
185
|
- mc.tokenizing.num_tokens_from_string(str(file_diff)),
|
186
|
+
use_local_files=review_subject_is_index(what)
|
155
187
|
)
|
156
188
|
if file_diff.target_file != DEV_NULL and not file_diff.is_added_file
|
157
189
|
else ""
|
@@ -183,9 +215,25 @@ async def review(
|
|
183
215
|
exec(cfg.post_process, {"mc": mc, **locals()})
|
184
216
|
out_folder.mkdir(parents=True, exist_ok=True)
|
185
217
|
report = Report(issues=issues, number_of_processed_files=len(diff))
|
186
|
-
|
218
|
+
ctx = dict(
|
219
|
+
report=report,
|
220
|
+
config=cfg,
|
221
|
+
diff=diff,
|
222
|
+
repo=repo,
|
223
|
+
pipeline_out={},
|
224
|
+
)
|
225
|
+
if cfg.pipeline_steps:
|
226
|
+
pipe = Pipeline(
|
227
|
+
ctx=ctx,
|
228
|
+
steps=cfg.pipeline_steps
|
229
|
+
)
|
230
|
+
pipe.run()
|
231
|
+
else:
|
232
|
+
logging.info("No pipeline steps defined, skipping pipeline execution")
|
233
|
+
|
234
|
+
report.summary = make_cr_summary(**ctx)
|
187
235
|
report.save(file_name=out_folder / JSON_REPORT_FILE_NAME)
|
188
236
|
report_text = report.render(cfg, Report.Format.MARKDOWN)
|
189
|
-
print(mc.ui.yellow(report_text))
|
190
237
|
text_report_path = out_folder / "code-review-report.md"
|
191
238
|
text_report_path.write_text(report_text, encoding="utf-8")
|
239
|
+
report.to_cli()
|
gito/issue_trackers.py
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
import re
|
2
|
+
from dataclasses import dataclass, field
|
3
|
+
|
4
|
+
|
5
|
+
def extract_issue_key(branch_name: str, min_len=2, max_len=10) -> str | None:
|
6
|
+
pattern = fr"\b[A-Z][A-Z0-9]{{{min_len - 1},{max_len - 1}}}-\d+\b"
|
7
|
+
match = re.search(pattern, branch_name)
|
8
|
+
return match.group(0) if match else None
|
9
|
+
|
10
|
+
|
11
|
+
@dataclass
|
12
|
+
class IssueTrackerIssue:
|
13
|
+
title: str = field(default="")
|
14
|
+
description: str = field(default="")
|
15
|
+
url: str = field(default="")
|
gito/pipeline.py
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
import logging
|
2
|
+
from enum import StrEnum
|
3
|
+
from dataclasses import dataclass, field
|
4
|
+
|
5
|
+
from gito.utils import is_running_in_github_action
|
6
|
+
from microcore import ui
|
7
|
+
from microcore.utils import resolve_callable
|
8
|
+
|
9
|
+
|
10
|
+
class PipelineEnv(StrEnum):
|
11
|
+
LOCAL = "local"
|
12
|
+
GH_ACTION = "gh-action"
|
13
|
+
|
14
|
+
@staticmethod
|
15
|
+
def all():
|
16
|
+
return [PipelineEnv.LOCAL, PipelineEnv.GH_ACTION]
|
17
|
+
|
18
|
+
@staticmethod
|
19
|
+
def current():
|
20
|
+
return (
|
21
|
+
PipelineEnv.GH_ACTION
|
22
|
+
if is_running_in_github_action()
|
23
|
+
else PipelineEnv.LOCAL
|
24
|
+
)
|
25
|
+
|
26
|
+
|
27
|
+
@dataclass
|
28
|
+
class PipelineStep:
|
29
|
+
call: str
|
30
|
+
envs: list[PipelineEnv] = field(default_factory=PipelineEnv.all)
|
31
|
+
|
32
|
+
def get_callable(self):
|
33
|
+
"""
|
34
|
+
Resolve the callable from the string representation.
|
35
|
+
"""
|
36
|
+
return resolve_callable(self.call)
|
37
|
+
|
38
|
+
def run(self, *args, **kwargs):
|
39
|
+
return self.get_callable()(*args, **kwargs)
|
40
|
+
|
41
|
+
|
42
|
+
@dataclass
|
43
|
+
class Pipeline:
|
44
|
+
ctx: dict = field(default_factory=dict)
|
45
|
+
steps: dict[str, PipelineStep] = field(default_factory=dict)
|
46
|
+
|
47
|
+
def run(self, *args, **kwargs):
|
48
|
+
cur_env = PipelineEnv.current()
|
49
|
+
logging.info("Running pipeline... [env: %s]", ui.yellow(cur_env))
|
50
|
+
self.ctx["pipeline_out"] = self.ctx.get("pipeline_out", {})
|
51
|
+
for step_name, step in self.steps.items():
|
52
|
+
if cur_env in step.envs:
|
53
|
+
logging.info(f"Running pipeline step: {step_name}")
|
54
|
+
try:
|
55
|
+
step_output = step.run(*args, **kwargs, **self.ctx)
|
56
|
+
if isinstance(step_output, dict):
|
57
|
+
self.ctx["pipeline_out"].update(step_output)
|
58
|
+
self.ctx["pipeline_out"][step_name] = step_output
|
59
|
+
if not step_output:
|
60
|
+
logging.warning(
|
61
|
+
f'Pipeline step "{step_name}" returned {repr(step_output)}.'
|
62
|
+
)
|
63
|
+
except Exception as e:
|
64
|
+
logging.error(f'Error in pipeline step "{step_name}": {e}')
|
65
|
+
else:
|
66
|
+
logging.info(
|
67
|
+
f"Skipping pipeline step: {step_name}"
|
68
|
+
f" [env: {ui.yellow(cur_env)} not in {step.envs}]"
|
69
|
+
)
|
70
|
+
return self.ctx["pipeline_out"]
|
File without changes
|
@@ -0,0 +1,83 @@
|
|
1
|
+
import logging
|
2
|
+
import os
|
3
|
+
|
4
|
+
import git
|
5
|
+
from jira import JIRA
|
6
|
+
|
7
|
+
from gito.issue_trackers import extract_issue_key, IssueTrackerIssue
|
8
|
+
from gito.utils import is_running_in_github_action
|
9
|
+
|
10
|
+
|
11
|
+
def fetch_issue(issue_key, jira_url, username, api_token) -> IssueTrackerIssue | None:
|
12
|
+
try:
|
13
|
+
jira = JIRA(jira_url, basic_auth=(username, api_token))
|
14
|
+
issue = jira.issue(issue_key)
|
15
|
+
return IssueTrackerIssue(
|
16
|
+
title=issue.fields.summary,
|
17
|
+
description=issue.fields.description or "",
|
18
|
+
url=f"{jira_url.rstrip('/')}/browse/{issue_key}"
|
19
|
+
)
|
20
|
+
except Exception as e:
|
21
|
+
logging.error(f"Failed to fetch Jira issue {issue_key}: {e}")
|
22
|
+
return None
|
23
|
+
|
24
|
+
|
25
|
+
def get_branch(repo: git.Repo):
|
26
|
+
if is_running_in_github_action():
|
27
|
+
branch_name = os.getenv('GITHUB_HEAD_REF')
|
28
|
+
if branch_name:
|
29
|
+
return branch_name
|
30
|
+
|
31
|
+
github_ref = os.getenv('GITHUB_REF', '')
|
32
|
+
if github_ref.startswith('refs/heads/'):
|
33
|
+
return github_ref.replace('refs/heads/', '')
|
34
|
+
try:
|
35
|
+
branch_name = repo.active_branch.name
|
36
|
+
return branch_name
|
37
|
+
except Exception as e: # @todo: specify more precise exception
|
38
|
+
logging.error("Could not determine the active branch name: %s", e)
|
39
|
+
return None
|
40
|
+
|
41
|
+
|
42
|
+
def fetch_associated_issue(
|
43
|
+
repo: git.Repo,
|
44
|
+
jira_url=None,
|
45
|
+
jira_username=None,
|
46
|
+
jira_api_token=None,
|
47
|
+
**kwargs
|
48
|
+
):
|
49
|
+
"""
|
50
|
+
Pipeline step to fetch a Jira issue based on the current branch name.
|
51
|
+
"""
|
52
|
+
branch_name = get_branch(repo)
|
53
|
+
if not branch_name:
|
54
|
+
logging.error("No active branch found in the repository, cannot determine Jira issue key.")
|
55
|
+
return None
|
56
|
+
|
57
|
+
if not (issue_key := extract_issue_key(branch_name)):
|
58
|
+
logging.error(f"No Jira issue key found in branch name: {branch_name}")
|
59
|
+
return None
|
60
|
+
|
61
|
+
jira_url = jira_url or os.getenv("JIRA_URL")
|
62
|
+
jira_username = (
|
63
|
+
jira_username
|
64
|
+
or os.getenv("JIRA_USERNAME")
|
65
|
+
or os.getenv("JIRA_USER")
|
66
|
+
or os.getenv("JIRA_EMAIL")
|
67
|
+
)
|
68
|
+
jira_token = (
|
69
|
+
jira_api_token
|
70
|
+
or os.getenv("JIRA_API_TOKEN")
|
71
|
+
or os.getenv("JIRA_API_KEY")
|
72
|
+
or os.getenv("JIRA_TOKEN")
|
73
|
+
)
|
74
|
+
try:
|
75
|
+
assert jira_url, "JIRA_URL is not set"
|
76
|
+
assert jira_username, "JIRA_USERNAME is not set"
|
77
|
+
assert jira_token, "JIRA_API_TOKEN is not set"
|
78
|
+
except AssertionError as e:
|
79
|
+
logging.error(f"Jira configuration error: {e}")
|
80
|
+
return None
|
81
|
+
return dict(
|
82
|
+
associated_issue=fetch_issue(issue_key, jira_url, jira_username, jira_token)
|
83
|
+
)
|
gito/project_config.py
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
import logging
|
2
|
+
import tomllib
|
3
|
+
from dataclasses import dataclass, field
|
4
|
+
from pathlib import Path
|
5
|
+
|
6
|
+
import microcore as mc
|
7
|
+
from gito.utils import detect_github_env
|
8
|
+
from microcore import ui
|
9
|
+
from git import Repo
|
10
|
+
|
11
|
+
from .constants import PROJECT_CONFIG_BUNDLED_DEFAULTS_FILE, PROJECT_CONFIG_FILE_PATH
|
12
|
+
from .pipeline import PipelineStep
|
13
|
+
|
14
|
+
|
15
|
+
@dataclass
|
16
|
+
class ProjectConfig:
|
17
|
+
prompt: str = ""
|
18
|
+
summary_prompt: str = ""
|
19
|
+
report_template_md: str = ""
|
20
|
+
"""Markdown report template"""
|
21
|
+
report_template_cli: str = ""
|
22
|
+
"""Report template for CLI output"""
|
23
|
+
post_process: str = ""
|
24
|
+
retries: int = 3
|
25
|
+
"""LLM retries for one request"""
|
26
|
+
max_code_tokens: int = 32000
|
27
|
+
prompt_vars: dict = field(default_factory=dict)
|
28
|
+
mention_triggers: list[str] = field(default_factory=list)
|
29
|
+
"""
|
30
|
+
Defines the keyword or mention tag that triggers bot actions
|
31
|
+
when referenced in code review comments.
|
32
|
+
"""
|
33
|
+
pipeline_steps: dict[str, dict | PipelineStep] = field(default_factory=dict)
|
34
|
+
|
35
|
+
def __post_init__(self):
|
36
|
+
self.pipeline_steps = {
|
37
|
+
k: PipelineStep(**v) if isinstance(v, dict) else v
|
38
|
+
for k, v in self.pipeline_steps.items()
|
39
|
+
}
|
40
|
+
|
41
|
+
@staticmethod
|
42
|
+
def _read_bundled_defaults() -> dict:
|
43
|
+
with open(PROJECT_CONFIG_BUNDLED_DEFAULTS_FILE, "rb") as f:
|
44
|
+
config = tomllib.load(f)
|
45
|
+
return config
|
46
|
+
|
47
|
+
@staticmethod
|
48
|
+
def load_for_repo(repo: Repo):
|
49
|
+
return ProjectConfig.load(Path(repo.working_tree_dir) / PROJECT_CONFIG_FILE_PATH)
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def load(config_path: str | Path | None = None) -> "ProjectConfig":
|
53
|
+
config = ProjectConfig._read_bundled_defaults()
|
54
|
+
github_env = detect_github_env()
|
55
|
+
config["prompt_vars"] |= github_env | dict(github_env=github_env)
|
56
|
+
|
57
|
+
config_path = Path(config_path or PROJECT_CONFIG_FILE_PATH)
|
58
|
+
if config_path.exists():
|
59
|
+
logging.info(
|
60
|
+
f"Loading project-specific configuration from {mc.utils.file_link(config_path)}...")
|
61
|
+
default_prompt_vars = config["prompt_vars"]
|
62
|
+
with open(config_path, "rb") as f:
|
63
|
+
config.update(tomllib.load(f))
|
64
|
+
# overriding prompt_vars config section will not empty default values
|
65
|
+
config["prompt_vars"] = default_prompt_vars | config["prompt_vars"]
|
66
|
+
else:
|
67
|
+
logging.info(
|
68
|
+
f"No project config found at {ui.blue(config_path)}, using defaults"
|
69
|
+
)
|
70
|
+
|
71
|
+
return ProjectConfig(**config)
|
@@ -3,12 +3,16 @@ import logging
|
|
3
3
|
from dataclasses import dataclass, field, asdict
|
4
4
|
from datetime import datetime
|
5
5
|
from enum import StrEnum
|
6
|
+
from pathlib import Path
|
6
7
|
|
7
8
|
import microcore as mc
|
9
|
+
from colorama import Fore, Style, Back
|
10
|
+
from microcore.utils import file_link
|
11
|
+
import textwrap
|
8
12
|
|
9
13
|
from .constants import JSON_REPORT_FILE_NAME
|
10
14
|
from .project_config import ProjectConfig
|
11
|
-
from .utils import syntax_hint
|
15
|
+
from .utils import syntax_hint, block_wrap_lr, max_line_len
|
12
16
|
|
13
17
|
|
14
18
|
@dataclass
|
@@ -57,13 +61,15 @@ class Issue:
|
|
57
61
|
class Report:
|
58
62
|
class Format(StrEnum):
|
59
63
|
MARKDOWN = "md"
|
64
|
+
CLI = "cli"
|
60
65
|
|
61
|
-
issues: dict = field(default_factory=dict)
|
66
|
+
issues: dict[str, list[Issue]] = field(default_factory=dict)
|
62
67
|
summary: str = field(default="")
|
63
68
|
number_of_processed_files: int = field(default=0)
|
64
69
|
total_issues: int = field(init=False)
|
65
70
|
created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
66
71
|
model: str = field(default_factory=lambda: mc.config().MODEL)
|
72
|
+
pipeline_out: dict = field(default_factory=dict)
|
67
73
|
|
68
74
|
@property
|
69
75
|
def plain_issues(self):
|
@@ -94,15 +100,34 @@ class Report:
|
|
94
100
|
logging.info(f"Report saved to {mc.utils.file_link(file_name)}")
|
95
101
|
|
96
102
|
@staticmethod
|
97
|
-
def load(file_name: str = ""):
|
103
|
+
def load(file_name: str | Path = ""):
|
98
104
|
with open(file_name or JSON_REPORT_FILE_NAME, "r") as f:
|
99
105
|
data = json.load(f)
|
100
106
|
data.pop("total_issues", None)
|
101
107
|
return Report(**data)
|
102
108
|
|
103
109
|
def render(
|
104
|
-
self,
|
110
|
+
self,
|
111
|
+
config: ProjectConfig = None,
|
112
|
+
report_format: Format = Format.MARKDOWN,
|
105
113
|
) -> str:
|
106
|
-
|
107
|
-
template = getattr(
|
108
|
-
return mc.prompt(
|
114
|
+
config = config or ProjectConfig.load()
|
115
|
+
template = getattr(config, f"report_template_{report_format}")
|
116
|
+
return mc.prompt(
|
117
|
+
template,
|
118
|
+
report=self,
|
119
|
+
ui=mc.ui,
|
120
|
+
Fore=Fore,
|
121
|
+
Style=Style,
|
122
|
+
Back=Back,
|
123
|
+
file_link=file_link,
|
124
|
+
textwrap=textwrap,
|
125
|
+
block_wrap_lr=block_wrap_lr,
|
126
|
+
max_line_len=max_line_len,
|
127
|
+
**config.prompt_vars
|
128
|
+
)
|
129
|
+
|
130
|
+
def to_cli(self, report_format=Format.CLI):
|
131
|
+
output = self.render(report_format=report_format)
|
132
|
+
print("")
|
133
|
+
print(output)
|