ai-cr 2.0.1__py3-none-any.whl → 2.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ai-cr
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: AI code review tool that works with any language model provider. It detects issues in GitHub pull requests or local changes—instantly, reliably, and without vendor lock-in.
5
5
  License: MIT
6
6
  Keywords: static code analysis,code review,code quality,ai,coding,assistant,llm,github,automation,devops,developer tools,github actions,workflows,git
@@ -32,7 +32,7 @@ Description-Content-Type: text/markdown
32
32
  <a href="https://pypi.org/project/gito.bot/" target="_blank"><img src="https://img.shields.io/pypi/v/gito.bot" alt="PYPI Release"></a>
33
33
  <a href="https://github.com/Nayjest/Gito/actions/workflows/code-style.yml" target="_blank"><img src="https://github.com/Nayjest/Gito/actions/workflows/code-style.yml/badge.svg" alt="PyLint"></a>
34
34
  <a href="https://github.com/Nayjest/Gito/actions/workflows/tests.yml" target="_blank"><img src="https://github.com/Nayjest/Gito/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
35
- <img src="https://github.com/Nayjest/Gito/blob/main/coverage.svg" alt="Code Coverage">
35
+ <img src="https://raw.githubusercontent.com/Nayjest/Gito/main/coverage.svg" alt="Code Coverage">
36
36
  <a href="https://github.com/Nayjest/Gito/blob/main/LICENSE" target="_blank"><img src="https://img.shields.io/static/v1?label=license&message=MIT&color=d08aff" alt="License"></a>
37
37
  </p>
38
38
 
@@ -58,7 +58,7 @@ Get consistent, thorough code reviews in seconds—no waiting for human availabi
58
58
  - Open source projects maintaining high code quality at scale
59
59
  - CI/CD pipelines requiring automated quality gates
60
60
 
61
- ✨ See [code review in action](https://github.com/Nayjest/Gito/pull/39#issuecomment-2906968729) ✨
61
+ ✨ See [code review in action](https://github.com/Nayjest/Gito/pull/99) ✨
62
62
 
63
63
  ## 🚀 Quickstart
64
64
 
@@ -0,0 +1,31 @@
1
+ gito/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ gito/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ gito/bootstrap.py,sha256=vGI19asH0-GFj3_w_wqWucSAWPwgCP4-FkTgVuvyIGY,2517
4
+ gito/cli.py,sha256=_OwUeOQr3seMVp6ZIOfHHvKBJIKWb3-kQic5nZ2tmgE,6563
5
+ gito/commands/__init__.py,sha256=B2uUQsLMEsHfNT1N3lWYm38WSuQIHFmjiGs2tdBuDBA,55
6
+ gito/commands/deploy.py,sha256=1199AisP3RIpz5v48JTPmQEcA7KIWqYF5lLkU2HEiJ4,3758
7
+ gito/commands/fix.py,sha256=OS829xVrLMLQqlol9AoxJMZ9qaAWRCSbUkVNszK96ws,5305
8
+ gito/commands/gh_post_review_comment.py,sha256=BTLcS5-pfmGtVjpQEG77jexFVvzme94_DMU51icaCpQ,3517
9
+ gito/commands/gh_react_to_comment.py,sha256=Pu7T-rBB-jTJg1oUO34_FfpJsVgEIw-7rZRkTAL72tc,6396
10
+ gito/commands/repl.py,sha256=wiM5vbGNIgAeyjOKt9QhrRdGYB13Md-QYx41dMIPvaU,544
11
+ gito/config.toml,sha256=2gYU8R6UvYZg3ZniN4s2-kECmbC9k_K0nrk38-I1bKo,17596
12
+ gito/constants.py,sha256=_G40SAMfuYjYsBzEVUIxKT8Vo0dAWIQqCWWxdaBAltY,766
13
+ gito/core.py,sha256=tpPTMtjBCFtXuVshdbmL3fO6gc-bG-zlIPmnXCRdOhc,12648
14
+ gito/gh_api.py,sha256=FlqENZo0nmdtAozaKYj582w05gfs3PpEevS48hyup3M,2925
15
+ gito/issue_trackers.py,sha256=XYspyaIuf0ANQSvDUea5_oOdo9tQvpZZsapI5S9g78U,1551
16
+ gito/pipeline.py,sha256=Nq5VUVrXVDXVVYX6nVSMX1CyhoWsNtSeTOQyNgQAsdE,2672
17
+ gito/pipeline_steps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ gito/pipeline_steps/jira.py,sha256=cgRSHoU6j0lcK6hQfhg950PEmIu3uWLGMKZbW3-H3mg,1683
19
+ gito/pipeline_steps/linear.py,sha256=6UDc8nGKGpwHruPq8VItE2QBWshWxaTapoMhu_qjN_g,2445
20
+ gito/project_config.py,sha256=jOPVMgN01_krELlNXzZlrZOKmXZP-RLDS7iOgVSUyic,3182
21
+ gito/report_struct.py,sha256=HuvetI4XWTNEm-afS9Fx2doTP0ybn0P22UbH-55ambs,4243
22
+ gito/tpl/github_workflows/components/env-vars.jinja2,sha256=GPlIyhspqJFQLLjTAKz8yKeU563bsUs_n-Ao1MGmh_Y,355
23
+ gito/tpl/github_workflows/components/installs.jinja2,sha256=fQJAici1KiF3C35leB8ovO7zAvwBvcZy9tSCHKyIdkI,186
24
+ gito/tpl/github_workflows/gito-code-review.yml.jinja2,sha256=KpZdOsjINPT0TuL8FYA9TxK84hRqbGIkFjOI1XNew2g,1007
25
+ gito/tpl/github_workflows/gito-react-to-comments.yml.jinja2,sha256=yPKa285eQZBHSbmSknmcQjfVqBzgUHR3SvBJg7u1Jsc,2173
26
+ gito/utils.py,sha256=SOD89s5qpr0fToumUl7hYAPsOKfix_8MjoxeUks5s8I,6518
27
+ ai_cr-2.0.3.dist-info/LICENSE,sha256=VbdF_GbbDK24JvdTfnsxa2M6jmhsxmRSFeHCx-lICGE,1075
28
+ ai_cr-2.0.3.dist-info/METADATA,sha256=f6L-_hbYBYIdoYdigM_pdw4oUuzrgyMj_IGslT5-A-o,7989
29
+ ai_cr-2.0.3.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
30
+ ai_cr-2.0.3.dist-info/entry_points.txt,sha256=Ua1DxkhJJ8TZuLgnH-IlWCkrre_0S0dq_GtYRaYupWk,38
31
+ ai_cr-2.0.3.dist-info/RECORD,,
gito/bootstrap.py CHANGED
@@ -1,12 +1,13 @@
1
1
  import logging
2
2
  import os
3
3
  from datetime import datetime
4
+ from pathlib import Path
4
5
 
5
6
  import microcore as mc
6
7
  import typer
7
8
 
8
9
  from .utils import is_running_in_github_action
9
- from .constants import HOME_ENV_PATH, EXECUTABLE
10
+ from .constants import HOME_ENV_PATH, EXECUTABLE, PROJECT_GITO_FOLDER
10
11
 
11
12
 
12
13
  def setup_logging():
@@ -36,6 +37,10 @@ def bootstrap():
36
37
  DOT_ENV_FILE=HOME_ENV_PATH,
37
38
  USE_LOGGING=True,
38
39
  EMBEDDING_DB_TYPE=mc.EmbeddingDbType.NONE,
40
+ PROMPT_TEMPLATES_PATH=[
41
+ PROJECT_GITO_FOLDER,
42
+ Path(__file__).parent / "tpl"
43
+ ],
39
44
  )
40
45
  except mc.LLMConfigError as e:
41
46
  msg = str(e)
gito/cli.py CHANGED
@@ -15,7 +15,7 @@ from .bootstrap import bootstrap, app
15
15
  from .utils import no_subcommand, parse_refs_pair
16
16
 
17
17
  # Import fix command to register it
18
- from .commands import fix, gh_post_review_comment, gh_react_to_comment, repl # noqa
18
+ from .commands import fix, gh_post_review_comment, gh_react_to_comment, repl, deploy # noqa
19
19
 
20
20
 
21
21
  app_no_subcommand = typer.Typer(pretty_exceptions_show_locals=False)
@@ -0,0 +1,101 @@
1
+ from pathlib import Path
2
+
3
+ import microcore as mc
4
+ from microcore import ApiType, ui, utils
5
+ from git import Repo
6
+
7
+ from ..utils import version, extract_gh_owner_repo
8
+ from ..bootstrap import app
9
+
10
+
11
+ @app.command(name="deploy", help="Deploy Gito workflows to GitHub Actions")
12
+ @app.command(name="init", hidden=True)
13
+ def deploy(api_type: ApiType = None, commit: bool = None, rewrite: bool = False):
14
+ repo = Repo(".")
15
+ workflow_files = dict(
16
+ code_review=Path(".github/workflows/gito-code-review.yml"),
17
+ react_to_comments=Path(".github/workflows/gito-react-to-comments.yml")
18
+ )
19
+ for file in workflow_files.values():
20
+ if file.exists():
21
+ message = f"Gito workflow already exists at {utils.file_link(file)}."
22
+ if rewrite:
23
+ ui.warning(message)
24
+ else:
25
+ message += "\nUse --rewrite to overwrite it."
26
+ ui.error(message)
27
+ return False
28
+
29
+ api_types = [ApiType.ANTHROPIC, ApiType.OPEN_AI, ApiType.GOOGLE_AI_STUDIO]
30
+ default_models = {
31
+ ApiType.ANTHROPIC: "claude-sonnet-4-20250514",
32
+ ApiType.OPEN_AI: "gpt-4.1",
33
+ ApiType.GOOGLE_AI_STUDIO: "gemini-2.5-pro",
34
+ }
35
+ secret_names = {
36
+ ApiType.ANTHROPIC: "ANTHROPIC_API_KEY",
37
+ ApiType.OPEN_AI: "OPENAI_API_KEY",
38
+ ApiType.GOOGLE_AI_STUDIO: "GOOGLE_AI_API_KEY",
39
+ }
40
+ if not api_type:
41
+ api_type = mc.ui.ask_choose(
42
+ "Choose your LLM API type",
43
+ api_types,
44
+ )
45
+ elif api_type not in api_types:
46
+ mc.ui.error(f"Unsupported API type: {api_type}")
47
+ return False
48
+ major, minor, *_ = version().split(".")
49
+ template_vars = dict(
50
+ model=default_models[api_type],
51
+ api_type=api_type,
52
+ secret_name=secret_names[api_type],
53
+ major=major,
54
+ minor=minor,
55
+ ApiType=ApiType,
56
+ remove_indent=True,
57
+ )
58
+ gito_code_review_yml = mc.tpl(
59
+ "github_workflows/gito-code-review.yml.jinja2",
60
+ **template_vars
61
+ )
62
+ gito_react_to_comments_yml = mc.tpl(
63
+ "github_workflows/gito-react-to-comments.yml.jinja2",
64
+ **template_vars
65
+ )
66
+
67
+ workflow_files["code_review"].parent.mkdir(parents=True, exist_ok=True)
68
+ workflow_files["code_review"].write_text(gito_code_review_yml)
69
+ workflow_files["react_to_comments"].write_text(gito_react_to_comments_yml)
70
+ print(
71
+ mc.ui.green("Gito workflows have been created.\n")
72
+ + f" - {mc.utils.file_link(workflow_files['code_review'])}\n"
73
+ + f" - {mc.utils.file_link(workflow_files['react_to_comments'])}\n"
74
+ )
75
+ owner, repo_name = extract_gh_owner_repo(repo)
76
+ if commit is True or commit is None and mc.ui.ask_yn(
77
+ "Do you want to commit and push created GitHub workflows to a new branch?"
78
+ ):
79
+ repo.git.add([str(file) for file in workflow_files.values()])
80
+ branch_name = "gito_deploy"
81
+ if not repo.active_branch.name.startswith(branch_name):
82
+ repo.git.checkout("-b", branch_name)
83
+ repo.git.commit("-m", "Deploy Gito workflows")
84
+ repo.git.push("origin", branch_name)
85
+ print(f"Changes pushed to {branch_name} branch.")
86
+ print(
87
+ f"Please create a PR from {branch_name} to your main branch and merge it:\n"
88
+ f"https://github.com/{owner}/{repo_name}/compare/gito_deploy?expand=1"
89
+ )
90
+ else:
91
+ print(
92
+ "Now you can commit and push created GitHub workflows to your main repository branch.\n"
93
+ )
94
+
95
+ print(
96
+ "(!IMPORTANT):\n"
97
+ f"Add {mc.ui.cyan(secret_names[api_type])} with actual API_KEY "
98
+ "to your repository secrets here:\n"
99
+ f"https://github.com/{owner}/{repo_name}/settings/secrets/actions"
100
+ )
101
+ return True
@@ -1,17 +1,24 @@
1
1
  import logging
2
2
  import os
3
+ from time import sleep
3
4
 
4
5
  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
6
+ from ghapi.core import GhApi
7
+
8
+ from ..bootstrap 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
9
16
 
10
17
 
11
18
  @app.command(help="Leave a GitHub PR comment with the review.")
12
19
  def github_comment(
13
20
  token: str = typer.Option(
14
- os.environ.get("GITHUB_TOKEN", ""), help="GitHub token (or set GITHUB_TOKEN env var)"
21
+ "", help="GitHub token (or set GITHUB_TOKEN env var)"
15
22
  ),
16
23
  ):
17
24
  """
@@ -25,13 +32,14 @@ def github_comment(
25
32
  with open(file, "r", encoding="utf-8") as f:
26
33
  body = f.read()
27
34
 
35
+ token = resolve_gh_token(token)
28
36
  if not token:
29
37
  print("GitHub token is required (--token or GITHUB_TOKEN env var).")
30
38
  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", "")
39
+ config = ProjectConfig.load()
40
+ gh_env = config.prompt_vars["github_env"]
41
+ gh_repo = gh_env.get("github_repo", "")
42
+ pr_env_val = gh_env.get("github_pr_number", "")
35
43
  logging.info(f"github_pr_number = {pr_env_val}")
36
44
 
37
45
  pr = None
@@ -59,5 +67,42 @@ def github_comment(
59
67
  logging.error("Could not resolve PR number from environment variables.")
60
68
  raise typer.Exit(3)
61
69
 
62
- if not post_gh_comment(repo, pr, token, body):
70
+ if not post_gh_comment(gh_repo, pr, token, body):
63
71
  raise typer.Exit(5)
72
+
73
+ if config.collapse_previous_code_review_comments:
74
+ sleep(1)
75
+ collapse_gh_outdated_cr_comments(gh_repo, pr, token)
76
+
77
+
78
+ def collapse_gh_outdated_cr_comments(
79
+ gh_repository: str,
80
+ pr_or_issue_number: int,
81
+ token: str = None
82
+ ):
83
+ """
84
+ Collapse outdated code review comments in a GitHub pull request or issue.
85
+ """
86
+ logging.info(f"Collapsing outdated comments in {gh_repository} #{pr_or_issue_number}...")
87
+
88
+ token = resolve_gh_token(token)
89
+ owner, repo = gh_repository.split('/')
90
+ api = GhApi(owner, repo, token=token)
91
+
92
+ comments = api.issues.list_comments(pr_or_issue_number)
93
+ review_marker = HTML_CR_COMMENT_MARKER
94
+ collapsed_title = "🗑️ Outdated Code Review by Gito"
95
+ collapsed_marker = f"<summary>{collapsed_title}</summary>"
96
+ outdated_comments = [
97
+ c for c in comments
98
+ if c.body and review_marker in c.body and collapsed_marker not in c.body
99
+ ][:-1]
100
+ if not outdated_comments:
101
+ logging.info("No outdated comments found")
102
+ return
103
+ for comment in outdated_comments:
104
+ logging.info(f"Collapsing comment {comment.id}...")
105
+ new_body = f"<details>\n<summary>{collapsed_title}</summary>\n\n{comment.body}\n</details>"
106
+ api.issues.update_comment(comment.id, new_body)
107
+ hide_gh_comment(comment.node_id, token)
108
+ logging.info("All outdated comments collapsed successfully.")
@@ -19,7 +19,7 @@ import git
19
19
  from ..bootstrap 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(
gito/commands/repl.py CHANGED
@@ -20,6 +20,7 @@ from ..cli 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}}
@@ -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 in the following format:
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; list of affected lines
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": <start_line:int>,
148
- "end_line": <end_line:int>,
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
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/core.py CHANGED
@@ -5,8 +5,9 @@ from typing import Iterable
5
5
  from pathlib import Path
6
6
 
7
7
  import microcore as mc
8
- from git import Repo
9
- from gito.pipeline import Pipeline
8
+ from microcore import ui
9
+ from git import Repo, Commit
10
+ from git.exc import GitCommandError
10
11
  from unidiff import PatchSet, PatchedFile
11
12
  from unidiff.constants import DEV_NULL
12
13
 
@@ -14,6 +15,7 @@ from .project_config import ProjectConfig
14
15
  from .report_struct import Report
15
16
  from .constants import JSON_REPORT_FILE_NAME
16
17
  from .utils import stream_to_cli
18
+ from .pipeline import Pipeline
17
19
 
18
20
 
19
21
  def review_subject_is_index(what):
@@ -51,6 +53,16 @@ def is_binary_file(repo: Repo, file_path: str) -> bool:
51
53
  return True # Conservatively treat errors as binary to avoid issues
52
54
 
53
55
 
56
+ def commit_in_branch(repo: Repo, commit: Commit, target_branch: str) -> bool:
57
+ try:
58
+ # exit code 0 if commit is ancestor of branch
59
+ repo.git.merge_base('--is-ancestor', commit.hexsha, target_branch)
60
+ return True
61
+ except GitCommandError:
62
+ pass
63
+ return False
64
+
65
+
54
66
  def get_diff(
55
67
  repo: Repo = None,
56
68
  what: str = None,
@@ -76,12 +88,56 @@ def get_diff(
76
88
  else:
77
89
  current_ref = what
78
90
  merge_base = repo.merge_base(current_ref or repo.active_branch.name, against)[0]
79
- against = merge_base.hexsha
80
- logging.info(
81
- f"Using merge base: {mc.ui.cyan(merge_base.hexsha[:8])} ({merge_base.summary})"
82
- )
91
+
92
+ # if branch is already an ancestor of "against", merge_base == branch ⇒ it’s been merged
93
+ if merge_base.hexsha == repo.commit(current_ref or repo.active_branch.name).hexsha:
94
+ # @todo: check case: reviewing working copy index in main branch #103
95
+ logging.info(
96
+ f"Branch is already merged. ({ui.green(current_ref)} vs {ui.yellow(against)})"
97
+ )
98
+ merge_sha = repo.git.log(
99
+ '--merges',
100
+ '--ancestry-path',
101
+ f'{current_ref}..{against}',
102
+ '-n',
103
+ '1',
104
+ '--pretty=format:%H'
105
+ ).strip()
106
+ if merge_sha:
107
+ merge_commit = repo.commit(merge_sha)
108
+
109
+ other_merge_parent = None
110
+ for parent in merge_commit.parents:
111
+ if parent.hexsha == merge_base.hexsha:
112
+ continue
113
+ if not commit_in_branch(repo, parent, against):
114
+ logging.warning(f"merge parent is not in {against}, skipping")
115
+ continue
116
+ other_merge_parent = parent
117
+ break
118
+ if other_merge_parent:
119
+ first_common_ancestor = repo.merge_base(other_merge_parent, merge_base)[0]
120
+ logging.info(
121
+ f"{what} will be compared to "
122
+ f"first common ancestor of {what} and {against}: "
123
+ f"{ui.cyan(first_common_ancestor.hexsha[:8])}"
124
+ )
125
+ against = first_common_ancestor.hexsha
126
+ else:
127
+ logging.error(f"Can't find other merge parent for {merge_sha}")
128
+ else:
129
+ logging.error(
130
+ f"No merge‐commit found for {current_ref!r}→{against!r}; "
131
+ "falling back to merge‐base diff"
132
+ )
133
+ else:
134
+ # normal case: branch not yet merged
135
+ against = merge_base.hexsha
136
+ logging.info(
137
+ f"Using merge base: {ui.cyan(merge_base.hexsha[:8])} ({merge_base.summary})"
138
+ )
83
139
  logging.info(
84
- f"Making diff: {mc.ui.green(what or 'INDEX')} vs {mc.ui.yellow(against)}"
140
+ f"Making diff: {ui.green(what or 'INDEX')} vs {ui.yellow(against)}"
85
141
  )
86
142
  diff_content = repo.git.diff(against, what)
87
143
  diff = PatchSet.from_string(diff_content)
@@ -197,6 +253,41 @@ def _prepare(
197
253
  return repo, cfg, diff, lines
198
254
 
199
255
 
256
+ def get_affected_code_block(repo: Repo, file: str, start_line: int, end_line: int) -> str | None:
257
+ if not start_line or not end_line:
258
+ return None
259
+ try:
260
+ if isinstance(start_line, str):
261
+ start_line = int(start_line)
262
+ if isinstance(end_line, str):
263
+ end_line = int(end_line)
264
+ lines = file_lines(repo, file, max_tokens=None, use_local_files=True)
265
+ if lines:
266
+ lines = [""] + lines.splitlines()
267
+ return "\n".join(
268
+ lines[start_line: end_line + 1]
269
+ )
270
+ except Exception as e:
271
+ logging.error(
272
+ f"Error getting affected code block for {file} from {start_line} to {end_line}: {e}"
273
+ )
274
+ return None
275
+
276
+
277
+ def provide_affected_code_blocks(issues: dict, repo: Repo):
278
+ for file, file_issues in issues.items():
279
+ for issue in file_issues:
280
+ for i in issue.get("affected_lines", []):
281
+ file_name = i.get("file", issue.get("file", file))
282
+ if block := get_affected_code_block(
283
+ repo,
284
+ file_name,
285
+ i.get("start_line"),
286
+ i.get("end_line")
287
+ ):
288
+ i["affected_code"] = block
289
+
290
+
200
291
  async def review(
201
292
  repo: Repo = None,
202
293
  what: str = None,
@@ -226,14 +317,7 @@ async def review(
226
317
  parse_json=True,
227
318
  )
228
319
  issues = {file.path: issues for file, issues in zip(diff, responses) if issues}
229
- for file, file_issues in issues.items():
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
- )
320
+ provide_affected_code_blocks(issues, repo)
237
321
  exec(cfg.post_process, {"mc": mc, **locals()})
238
322
  out_folder = Path(out_folder or repo.working_tree_dir)
239
323
  out_folder.mkdir(parents=True, exist_ok=True)
gito/gh_api.py CHANGED
@@ -1,6 +1,12 @@
1
+ import os
1
2
  import logging
2
3
 
3
4
  import requests
5
+ from fastcore.basics import AttrDict # objects returned by ghapi
6
+
7
+
8
+ def resolve_gh_token(token_or_none):
9
+ return token_or_none or os.getenv("GITHUB_TOKEN", None) or os.getenv("GH_TOKEN", None)
4
10
 
5
11
 
6
12
  def post_gh_comment(
@@ -33,3 +39,47 @@ def post_gh_comment(
33
39
  else:
34
40
  logging.error(f"Failed to post comment: {resp.status_code} {resp.reason}\n{resp.text}")
35
41
  return False
42
+
43
+
44
+ def hide_gh_comment(
45
+ comment: dict | str,
46
+ token: str = None,
47
+ reason: str = "OUTDATED"
48
+ ) -> bool:
49
+ """
50
+ Hide a GitHub comment using GraphQL API with specified reason.
51
+ Args:
52
+ comment (dict | str):
53
+ The comment to hide,
54
+ either as a object returned from ghapi or a string node ID.
55
+ note: comment.id is not the same as node_id.
56
+ token (str): GitHub personal access token with permissions to minimize comments.
57
+ reason (str): The reason for hiding the comment, e.g., "OUTDATED".
58
+ """
59
+ comment_node_id = comment.node_id if isinstance(comment, AttrDict) else comment
60
+ token = resolve_gh_token(token)
61
+ mutation = """
62
+ mutation($commentId: ID!, $reason: ReportedContentClassifiers!) {
63
+ minimizeComment(input: {subjectId: $commentId, classifier: $reason}) {
64
+ minimizedComment { isMinimized }
65
+ }
66
+ }"""
67
+
68
+ response = requests.post(
69
+ "https://api.github.com/graphql",
70
+ headers={"Authorization": f"Bearer {token}"},
71
+ json={
72
+ "query": mutation,
73
+ "variables": {"commentId": comment_node_id, "reason": reason}
74
+ }
75
+ )
76
+ success = (
77
+ response.status_code == 200
78
+ and response.json().get("data", {}).get("minimizeComment") is not None
79
+ )
80
+ if not success:
81
+ logging.error(
82
+ f"Failed to hide comment {comment_node_id}: "
83
+ f"{response.status_code} {response.reason}\n{response.text}"
84
+ )
85
+ return success
gito/issue_trackers.py CHANGED
@@ -15,9 +15,10 @@ class IssueTrackerIssue:
15
15
 
16
16
 
17
17
  def extract_issue_key(branch_name: str, min_len=2, max_len=10) -> str | None:
18
- pattern = fr"\b[A-Z][A-Z0-9]{{{min_len - 1},{max_len - 1}}}-\d+\b"
18
+ boundary = r'\b|_|-|/|\\'
19
+ pattern = fr"(?:{boundary})([A-Z][A-Z0-9]{{{min_len - 1},{max_len - 1}}}-\d+)(?:{boundary})"
19
20
  match = re.search(pattern, branch_name)
20
- return match.group(0) if match else None
21
+ return match.group(1) if match else None
21
22
 
22
23
 
23
24
  def get_branch(repo: git.Repo):
@@ -7,10 +7,11 @@ import git
7
7
  from gito.issue_trackers import IssueTrackerIssue, resolve_issue_key
8
8
 
9
9
 
10
- def fetch_issue(issue_key, api_key) -> IssueTrackerIssue | None:
10
+ def fetch_issue(issue_key: str, api_key: str = None) -> IssueTrackerIssue | None:
11
11
  """
12
12
  Fetch a Linear issue using GraphQL API.
13
13
  """
14
+ api_key = api_key or os.getenv("LINEAR_API_KEY")
14
15
  try:
15
16
  url = "https://api.linear.app/graphql"
16
17
  headers = {
@@ -36,7 +37,7 @@ def fetch_issue(issue_key, api_key) -> IssueTrackerIssue | None:
36
37
  url,
37
38
  json={
38
39
  "query": query,
39
- "variables": {'teamKey': team_key, 'issueNumber': int(issue_number)}
40
+ "variables": {'teamKey': team_key, 'issueNumber': int(issue_number)}
40
41
  },
41
42
  headers=headers
42
43
  )
gito/project_config.py CHANGED
@@ -33,6 +33,11 @@ class ProjectConfig:
33
33
  when referenced in code review comments.
34
34
  """
35
35
  pipeline_steps: dict[str, dict | PipelineStep] = field(default_factory=dict)
36
+ collapse_previous_code_review_comments: bool = field(default=True)
37
+ """
38
+ If True, previously added code review comments in the pull request
39
+ will be collapsed automatically when a new comment is added.
40
+ """
36
41
 
37
42
  def __post_init__(self):
38
43
  self.pipeline_steps = {
@@ -61,10 +66,16 @@ class ProjectConfig:
61
66
  logging.info(
62
67
  f"Loading project-specific configuration from {mc.utils.file_link(config_path)}...")
63
68
  default_prompt_vars = config["prompt_vars"]
69
+ default_pipeline_steps = config["pipeline_steps"]
64
70
  with open(config_path, "rb") as f:
65
71
  config.update(tomllib.load(f))
66
72
  # overriding prompt_vars config section will not empty default values
67
73
  config["prompt_vars"] = default_prompt_vars | config["prompt_vars"]
74
+ # merge individual pipeline steps
75
+ for k, v in config["pipeline_steps"].items():
76
+ config["pipeline_steps"][k] = default_pipeline_steps.get(k, {}) | v
77
+ # merge pipeline steps dict
78
+ config["pipeline_steps"] = default_pipeline_steps | config["pipeline_steps"]
68
79
  else:
69
80
  logging.info(
70
81
  f"No project config found at {ui.blue(config_path)}, using defaults"
gito/report_struct.py CHANGED
@@ -10,7 +10,7 @@ from colorama import Fore, Style, Back
10
10
  from microcore.utils import file_link
11
11
  import textwrap
12
12
 
13
- from .constants import JSON_REPORT_FILE_NAME, HTML_TEXT_ICON
13
+ from .constants import JSON_REPORT_FILE_NAME, HTML_TEXT_ICON, HTML_CR_COMMENT_MARKER
14
14
  from .project_config import ProjectConfig
15
15
  from .utils import syntax_hint, block_wrap_lr, max_line_len
16
16
 
@@ -125,6 +125,7 @@ class Report:
125
125
  block_wrap_lr=block_wrap_lr,
126
126
  max_line_len=max_line_len,
127
127
  HTML_TEXT_ICON=HTML_TEXT_ICON,
128
+ HTML_CR_COMMENT_MARKER=HTML_CR_COMMENT_MARKER,
128
129
  **config.prompt_vars
129
130
  )
130
131
 
@@ -0,0 +1,10 @@
1
+
2
+ LLM_API_TYPE: {{ api_type }}
3
+ LLM_API_KEY: {{ "${{ secrets." + secret_name + "}}" }}
4
+ MODEL: {{ model }}
5
+ {% raw -%}
6
+ JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }}
7
+ JIRA_URL: ${{ secrets.JIRA_URL }}
8
+ JIRA_USER: ${{ secrets.JIRA_USER }}
9
+ LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
10
+ {%- endraw %}
@@ -0,0 +1,8 @@
1
+
2
+
3
+ - name: Set up Python
4
+ uses: actions/setup-python@v5
5
+ with: { python-version: "3.13" }
6
+
7
+ - name: Install Gito
8
+ run: pip install gito.bot~={{ major }}.{{ minor }}
@@ -0,0 +1,33 @@
1
+ name: "Gito: AI Code Reviewer"
2
+ on:
3
+ pull_request:
4
+ types: [opened, synchronize, reopened]
5
+ workflow_dispatch:
6
+ inputs:
7
+ pr_number:
8
+ description: "Pull Request number"
9
+ required: true
10
+ jobs:
11
+ review:
12
+ runs-on: ubuntu-latest
13
+ permissions: { contents: read, pull-requests: write } # 'write' for leaving the summary comment
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with: { fetch-depth: 0 }
17
+
18
+ {%- include("github_workflows/components/installs.jinja2") %}
19
+
20
+ - name: Run AI code review
21
+ env:
22
+ {%- include("github_workflows/components/env-vars.jinja2") %}
23
+ PR_NUMBER_FROM_WORKFLOW_DISPATCH: {% raw %}${{ github.event.inputs.pr_number }}{% endraw %}
24
+ run: |
25
+ gito --verbose review
26
+ gito github-comment --token {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %}
27
+
28
+ - uses: actions/upload-artifact@v4
29
+ with:
30
+ name: gito-code-review-results
31
+ path: |
32
+ code-review-report.md
33
+ code-review-report.json
@@ -0,0 +1,70 @@
1
+ name: "Gito: React to GitHub comment"
2
+
3
+ on:
4
+ issue_comment:
5
+ types: [created]
6
+
7
+ permissions:
8
+ contents: write # to make PR
9
+ issues: write
10
+ pull-requests: write
11
+ # read: to download the code review artifact
12
+ # write: to trigger other actions
13
+ actions: write
14
+
15
+ jobs:
16
+ process-comment:
17
+ if: |
18
+ github.event.issue.pull_request &&
19
+ (
20
+ github.event.comment.author_association == 'OWNER' ||
21
+ github.event.comment.author_association == 'MEMBER' ||
22
+ github.event.comment.author_association == 'COLLABORATOR'
23
+ ) &&
24
+ (
25
+ startsWith(github.event.comment.body, '/') ||
26
+ startsWith(github.event.comment.body, 'gito') ||
27
+ startsWith(github.event.comment.body, 'ai') ||
28
+ startsWith(github.event.comment.body, 'bot') ||
29
+ contains(github.event.comment.body, '@gito') ||
30
+ contains(github.event.comment.body, '@ai') ||
31
+ contains(github.event.comment.body, '@bot')
32
+ )
33
+ runs-on: ubuntu-latest
34
+
35
+ steps:
36
+ - name: Get PR details
37
+ id: pr
38
+ uses: actions/github-script@v7
39
+ with:
40
+ script: |
41
+ const pr = await github.rest.pulls.get({
42
+ owner: context.repo.owner,
43
+ repo: context.repo.repo,
44
+ pull_number: context.issue.number
45
+ });
46
+ return {
47
+ head_ref: pr.data.head.ref,
48
+ head_sha: pr.data.head.sha,
49
+ base_ref: pr.data.base.ref
50
+ };
51
+
52
+ - name: Checkout repository
53
+ uses: actions/checkout@v4
54
+ with:
55
+ {% raw -%}
56
+ repository: ${{ github.repository }}
57
+ token: ${{ secrets.GITHUB_TOKEN }}
58
+ ref: ${{ fromJson(steps.pr.outputs.result).head_ref }}
59
+ fetch-depth: 0
60
+ {%- endraw %}
61
+
62
+ {%- include("github_workflows/components/installs.jinja2") %}
63
+
64
+ - name: Run Gito react
65
+ env:
66
+ # LLM config is needed only if answer_github_comments = true in .gito/config.toml
67
+ # Otherwise, use LLM_API_TYPE: none
68
+ {%- include("github_workflows/components/env-vars.jinja2") %}
69
+ run: |
70
+ {% raw %}gito react-to-comment ${{ github.event.comment.id }} --token ${{ secrets.GITHUB_TOKEN }}{%- endraw %}
gito/utils.py CHANGED
@@ -2,6 +2,7 @@ import re
2
2
  import sys
3
3
  import os
4
4
  from pathlib import Path
5
+ import importlib.metadata
5
6
 
6
7
  import typer
7
8
  import git
@@ -224,3 +225,7 @@ def detect_github_env() -> dict:
224
225
 
225
226
  def stream_to_cli(text):
226
227
  print(ui.blue(text), end='')
228
+
229
+
230
+ def version() -> str:
231
+ return importlib.metadata.version("gito.bot")
@@ -1,26 +0,0 @@
1
- gito/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- gito/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
- gito/bootstrap.py,sha256=ceeoSCD8RX4xKxWri_n0xx00aTFS6xPUHhAs3rKVP5o,2337
4
- gito/cli.py,sha256=qFY1ObA0-Ep19bNSEcwjn6KIu2OOSWDj6QGe_o7Ujoc,6555
5
- gito/commands/__init__.py,sha256=B2uUQsLMEsHfNT1N3lWYm38WSuQIHFmjiGs2tdBuDBA,55
6
- gito/commands/fix.py,sha256=OS829xVrLMLQqlol9AoxJMZ9qaAWRCSbUkVNszK96ws,5305
7
- gito/commands/gh_post_review_comment.py,sha256=oZh3kSRBx3cSMozI2llDEOVZ2WeT_5msXMLLCiLina4,1984
8
- gito/commands/gh_react_to_comment.py,sha256=4uUb39FnM45DO-wESQq6pUlO5AFvh0FSluCCTxkl8_g,6442
9
- gito/commands/repl.py,sha256=wDHWl2VyRK32hLZHImrhqpQNkWV3u3358fwDiA6D3iA,521
10
- gito/config.toml,sha256=iK0CeP3mynTnWIEsCi_jA9Gtb_UdAqwLC5nWHSTc-zc,17367
11
- gito/constants.py,sha256=9G4sZbqLBg13FYieJqkq_hzek4-smmL4KKYF64tovQQ,698
12
- gito/core.py,sha256=tp-eB_FIsZ2wffTiMrRLc_9MaFaGIky_ExWHWTf1DTk,9341
13
- gito/gh_api.py,sha256=syhM8sbs_lLNG53bfRHQpusO818nwXCl4jDxVY__IK0,1204
14
- gito/issue_trackers.py,sha256=nfib6zhvmL_zjRDCdZ0d6rMT4ZFJ3PxO5UORaJzb6gk,1495
15
- gito/pipeline.py,sha256=Nq5VUVrXVDXVVYX6nVSMX1CyhoWsNtSeTOQyNgQAsdE,2672
16
- gito/pipeline_steps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- gito/pipeline_steps/jira.py,sha256=cgRSHoU6j0lcK6hQfhg950PEmIu3uWLGMKZbW3-H3mg,1683
18
- gito/pipeline_steps/linear.py,sha256=stCuTWgVF3SG1XNmIdNwelTDQzKE9PnJLIOS27wKavw,2376
19
- gito/project_config.py,sha256=XdpXsRDFWBNNpktbZDp9p-nbijyKjG_oD6UWzGZSLjM,2580
20
- gito/report_struct.py,sha256=tLhdmCPse3Jbo56y752vuGlndWY2f2g_mNFQ-BmJZGw,4160
21
- gito/utils.py,sha256=HpterYtjceVhklJe0w1n7PIQJk8MJFUYi4FiG-CzMqg,6418
22
- ai_cr-2.0.1.dist-info/LICENSE,sha256=VbdF_GbbDK24JvdTfnsxa2M6jmhsxmRSFeHCx-lICGE,1075
23
- ai_cr-2.0.1.dist-info/METADATA,sha256=0-kY4AU4Af24ElJj6n4bLLHVxfcpwmZpdbUFkpNur8w,8003
24
- ai_cr-2.0.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
25
- ai_cr-2.0.1.dist-info/entry_points.txt,sha256=Ua1DxkhJJ8TZuLgnH-IlWCkrre_0S0dq_GtYRaYupWk,38
26
- ai_cr-2.0.1.dist-info/RECORD,,
File without changes
File without changes