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.
@@ -3,7 +3,7 @@ Python REPL
3
3
  """
4
4
  # flake8: noqa: F401
5
5
  import code
6
- from ema.cli import app
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
- ## 🤖 I've Reviewed the Code
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
- **{%- if report.total_issues > 0 %}⚠️{% endif -%}
9
- Total issues: `{{ report.total_issues }}`
10
- {{- ' ' -}}
11
- in `{{ report.number_of_processed_files }}` files**
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 Issues--
199
+ --Issues Detected by You--
123
200
  {{ issues | tojson(indent=2) }}
124
201
  ---
125
- If code changes contains exceptional achievements, you may additionally present the award in summary text.
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"
@@ -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 (UnicodeDecodeError, KeyError):
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
- against = repo.remotes.origin.refs.HEAD.reference.name # origin/main
44
- if not what:
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 is None:
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 or is_binary_file(repo, file_path.lstrip("b/")):
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
- text = repo.tree()[file].data_stream.read().decode()
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(cfg: ProjectConfig, report: Report, diff):
147
+ def make_cr_summary(config: ProjectConfig, report: Report, diff, **kwargs) -> str:
118
148
  return (
119
149
  mc.prompt(
120
- cfg.summary_prompt,
121
- diff=mc.tokenizing.fit_to_token_size(diff, cfg.max_code_tokens)[0],
150
+ config.summary_prompt,
151
+ diff=mc.tokenizing.fit_to_token_size(diff, config.max_code_tokens)[0],
122
152
  issues=report.issues,
123
- **cfg.prompt_vars,
153
+ **config.prompt_vars,
154
+ **kwargs,
124
155
  ).to_llm()
125
- if cfg.summary_prompt
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
- report.summary = make_cr_summary(cfg, report, diff)
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, cfg: ProjectConfig = None, format: Format = Format.MARKDOWN
110
+ self,
111
+ config: ProjectConfig = None,
112
+ report_format: Format = Format.MARKDOWN,
105
113
  ) -> str:
106
- cfg = cfg or ProjectConfig.load()
107
- template = getattr(cfg, f"report_template_{format}")
108
- return mc.prompt(template, report=self, **cfg.prompt_vars)
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)