├── gito
├── __init__.py
├── pipeline_steps
│ ├── __init__.py
│ ├── jira.py
│ └── linear.py
├── commands
│ ├── __init__.py
│ ├── version.py
│ ├── repl.py
│ ├── linear_comment.py
│ ├── gh_post_review_comment.py
│ ├── deploy.py
│ ├── fix.py
│ └── gh_react_to_comment.py
├── __main__.py
├── env.py
├── tpl
│ ├── partial
│ │ └── aux_files.j2
│ ├── github_workflows
│ │ ├── components
│ │ │ ├── env-vars.j2
│ │ │ └── installs.j2
│ │ ├── gito-code-review.yml.j2
│ │ └── gito-react-to-comments.yml.j2
│ ├── answer.j2
│ └── questions
│ │ ├── release_notes.j2
│ │ ├── test_cases.j2
│ │ └── changes_summary.j2
├── context.py
├── constants.py
├── issue_trackers.py
├── pipeline.py
├── cli_base.py
├── project_config.py
├── bootstrap.py
├── gh_api.py
├── report_struct.py
├── utils.py
├── cli.py
├── config.toml
└── core.py
├── .flake8
├── tests
├── fixtures
│ ├── config-disable-jira.toml
│ └── cr-report-1.json
├── test_version.py
├── test_issue_trackers.py
├── test_render.py
├── test_syntax_hint.py
├── test_raw_issue.py
├── test_run.py
├── test_project_config.py
├── test_cleanup_comment.py
├── test_utils.py
├── test_pipeline.py
└── test_report_struct.py
├── documentation
├── img.png
├── troubleshooting.md
├── config_cookbook.md
├── github_setup.md
└── command_line_reference.md
├── press-kit
├── logo
│ ├── gito-bot-1_64top.png
│ └── gito-ai-code-reviewer_logo-180.png
└── gito-social-preview-1_1280x640.png
├── .gito
└── config.toml
├── .github
└── workflows
│ ├── code-style.yml
│ ├── pypi-release.yml
│ ├── gito-code-review.yml
│ ├── tests.yml
│ └── gito-react-to-comments.yml
├── .aico
└── project.json
├── Makefile
├── coverage.svg
├── .gitignore
├── LICENSE
├── multi-build.py
├── CONTRIBUTING.md
├── pyproject.toml
└── README.md
/gito/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gito/pipeline_steps/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 100
--------------------------------------------------------------------------------
/tests/fixtures/config-disable-jira.toml:
--------------------------------------------------------------------------------
1 | [pipeline_steps.jira]
2 | enabled=false
--------------------------------------------------------------------------------
/gito/commands/__init__.py:
--------------------------------------------------------------------------------
1 | # Command modules register themselves with the CLI app
2 |
--------------------------------------------------------------------------------
/documentation/img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nayjest/Gito/HEAD/documentation/img.png
--------------------------------------------------------------------------------
/gito/__main__.py:
--------------------------------------------------------------------------------
1 | from .cli import main
2 |
3 | if __name__ == "__main__":
4 | main()
5 |
--------------------------------------------------------------------------------
/press-kit/logo/gito-bot-1_64top.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nayjest/Gito/HEAD/press-kit/logo/gito-bot-1_64top.png
--------------------------------------------------------------------------------
/press-kit/gito-social-preview-1_1280x640.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nayjest/Gito/HEAD/press-kit/gito-social-preview-1_1280x640.png
--------------------------------------------------------------------------------
/press-kit/logo/gito-ai-code-reviewer_logo-180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nayjest/Gito/HEAD/press-kit/logo/gito-ai-code-reviewer_logo-180.png
--------------------------------------------------------------------------------
/.gito/config.toml:
--------------------------------------------------------------------------------
1 | aux_files=[
2 | 'documentation/command_line_reference.md'
3 | ]
4 | exclude_files=[
5 | 'poetry.lock',
6 | ]
7 | [prompt_vars]
8 | # Disable awards
9 | awards = ""
10 |
--------------------------------------------------------------------------------
/gito/env.py:
--------------------------------------------------------------------------------
1 | from importlib.metadata import version
2 |
3 |
4 | class Env:
5 | logging_level: int = 1
6 | verbosity: int = 1
7 | gito_version: str = version("gito.bot")
8 | working_folder = "."
9 |
--------------------------------------------------------------------------------
/gito/commands/version.py:
--------------------------------------------------------------------------------
1 | from ..cli_base import app
2 | from ..env import Env
3 |
4 |
5 | @app.command(name='version', help='Show Gito version.')
6 | def version():
7 | print(Env.gito_version)
8 | return Env.gito_version
9 |
--------------------------------------------------------------------------------
/gito/tpl/partial/aux_files.j2:
--------------------------------------------------------------------------------
1 | {% if aux_files %}
2 | ----AUXILIARY INFORMATION----
3 | * Use if helpful for the task.
4 | {% for file, text in aux_files.items() %}
5 | --FILE: {{ file }}--
6 | {{ text.strip() }}
7 | {%- endfor -%}
8 | {%- endif -%}
9 |
--------------------------------------------------------------------------------
/documentation/troubleshooting.md:
--------------------------------------------------------------------------------
1 | # Troubleshooting
2 |
3 | ## 1. LLM API Rate Limit / "Overloaded" Errors
4 |
5 | You may decrease parallelization by setting the `MAX_CONCURRENT_TASKS` environment variable in the GitHub workflow files or in the local environment.
6 |
7 | See [microcore configuration options](https://ai-microcore.github.io/api-reference/microcore/configuration.html#Config.MAX_CONCURRENT_TASKS) for more details.
--------------------------------------------------------------------------------
/.github/workflows/code-style.yml:
--------------------------------------------------------------------------------
1 | name: Code Style
2 |
3 | on: [push]
4 |
5 | jobs:
6 | cs:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v3
10 | - uses: actions/setup-python@v3
11 | with:
12 | python-version: 3.12
13 | - name: Install dependencies
14 | run: pip install --upgrade pip flake8 pylint
15 | - name: Run flake8
16 | run: flake8 .
17 |
--------------------------------------------------------------------------------
/gito/tpl/github_workflows/components/env-vars.j2:
--------------------------------------------------------------------------------
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 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
11 | {%- endraw %}
12 |
--------------------------------------------------------------------------------
/gito/context.py:
--------------------------------------------------------------------------------
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)
20 |
--------------------------------------------------------------------------------
/.aico/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "src_folder": ".",
3 | "work_folder": ".aico",
4 | "ignore": [
5 | ".aico",
6 | ".git",
7 | "__pycache__",
8 | ".idea",
9 | "venv",
10 | ".pytest_cache",
11 | ".coverage",
12 | "coverage.xml",
13 | ".pylintrc",
14 | ".diff",
15 | ".patch",
16 | "node_modules",
17 | "package-lock.json",
18 | "dist",
19 | "poetry.lock",
20 | "storage",
21 | "*.png",
22 | "*.svg",
23 | "*.zip"
24 |
25 | ],
26 | "bot_personality": ""
27 | }
--------------------------------------------------------------------------------
/tests/test_version.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 | from gito.env import Env
3 |
4 |
5 | def test_version_command_shell():
6 | result = subprocess.run(
7 | ['python', '-m', 'gito', '-v0', 'version'],
8 | capture_output=True,
9 | text=True
10 | )
11 |
12 | assert result.returncode == 0
13 | assert result.stdout.strip() == Env.gito_version
14 | assert Env.gito_version and '.' in Env.gito_version
15 | assert result.stderr == ""
16 |
17 |
18 | def test_version_return_val():
19 | from gito.commands.version import version
20 | assert version() == Env.gito_version
21 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | cs:
2 | flake8 .
3 | black:
4 | black .
5 |
6 |
7 | install:
8 | pip install -e .
9 |
10 | pkg:
11 | python multi-build.py
12 | build: pkg
13 |
14 | clear-dist:
15 | python -c "import shutil, os; shutil.rmtree('dist', ignore_errors=True); os.makedirs('dist', exist_ok=True)"
16 | clr-dist: clear-dist
17 |
18 |
19 | publish:
20 | python -c "import os,subprocess;t=os.getenv('PYPI_TOKEN');subprocess.run(['python', '-m', 'twine', 'upload', 'dist/*', '-u', '__token__', '-p', t], check=True)"
21 |
22 | upload: publish
23 | test:
24 | pytest --log-cli-level=INFO
25 | tests: test
26 |
27 | cli-reference:
28 | PYTHONUTF8=1 typer gito.cli utils docs --name gito --title="Gito CLI Reference" --output documentation/command_line_reference.md
29 | cli-ref: cli-reference
--------------------------------------------------------------------------------
/tests/test_issue_trackers.py:
--------------------------------------------------------------------------------
1 | from gito.issue_trackers import extract_issue_key
2 |
3 |
4 | def test_extract_issue_key():
5 | assert extract_issue_key("feature/PROJ-123") == "PROJ-123"
6 | assert extract_issue_key("hotfix/AA-99") == "AA-99"
7 | assert extract_issue_key("bugfix/XYZ-1001-fix") == "XYZ-1001"
8 | assert extract_issue_key("improvement/TOOLONGKEY-1", max_len=8) is None
9 | assert extract_issue_key("somebranch/ab-1") is None # lowercase key
10 | assert extract_issue_key("misc/no-key-here") is None
11 | assert extract_issue_key("feature/PR1-100", min_len=2, max_len=4) == "PR1-100"
12 | assert extract_issue_key("IS-811_word_word-_word-word_is_word") == "IS-811"
13 | assert extract_issue_key("fix_ISS-811__ISS-812") == "ISS-811"
14 |
--------------------------------------------------------------------------------
/gito/commands/repl.py:
--------------------------------------------------------------------------------
1 | """
2 | Python REPL
3 | """
4 | # flake8: noqa: F401
5 | import code
6 |
7 | # Wildcard imports are preferred to capture most of functionality for usage in REPL
8 | import os
9 | import sys
10 | from dataclasses import dataclass
11 | from datetime import datetime
12 | from enum import Enum
13 | from time import time
14 | from rich.pretty import pprint
15 |
16 | import microcore as mc
17 | from microcore import ui
18 |
19 | from ..cli_base import app
20 | from ..constants import *
21 | from ..core import *
22 | from ..utils import *
23 | from ..gh_api import *
24 |
25 |
26 | @app.command(
27 | help="Python REPL with core functionality loaded for quick testing/debugging and exploration."
28 | )
29 | def repl():
30 | code.interact(local=globals())
31 |
--------------------------------------------------------------------------------
/tests/test_render.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from gito.bootstrap import bootstrap
3 | from gito.report_struct import Report
4 |
5 |
6 | def validate(out):
7 | for i in [1, 2, 3, 4]:
8 | assert f"ISSUE_{i} TITLE" in out
9 | assert "ISSUE_1 DESCR\nLINE_2\nLINE_3" in out
10 | assert "SUMMARY_TEXT" in out
11 | assert "ISSUE_1" in out
12 | assert "4" in out # Total issues
13 | assert "555" in out # Number of files
14 |
15 |
16 | def test_render():
17 | path = Path(__file__).parent / "fixtures" / "cr-report-1.json"
18 | bootstrap()
19 | out = Report.load(path).render(report_format=Report.Format.CLI)
20 | validate(out)
21 | out = Report.load(file_name=str(path)).render(None, Report.Format.MARKDOWN)
22 | validate(out)
23 |
--------------------------------------------------------------------------------
/gito/tpl/answer.j2:
--------------------------------------------------------------------------------
1 | {{ self_id }}
2 | ----TASK----
3 | Answer the following user question:
4 | --USER QUESTION--
5 | {{ question }}
6 | ----
7 | ----RELATED CODEBASE CHANGES----
8 | {% for part in diff %}{{ part }}\n{% endfor %}
9 |
10 | ----FULL FILE CONTENT AFTER APPLYING CHANGES----
11 | {% for file, file_lines in all_file_lines.items() %}
12 | --FILE: {{ file }}--
13 | {{ file_lines }}
14 | {% endfor %}
15 |
16 | {% include "partial/aux_files.j2" %}
17 |
18 | {%- if pipeline_out.associated_issue and pipeline_out.associated_issue.title %}
19 | ----ASSOCIATED ISSUE----
20 | # {{ pipeline_out.associated_issue.title }}
21 | {{ pipeline_out.associated_issue.description }}
22 | URL: {{ pipeline_out.associated_issue.url }}
23 | {%- endif -%}{{ '\n' }}
24 |
25 | ----ANSWERING INSTRUCTIONS----
26 | {{ answering_instructions }}
--------------------------------------------------------------------------------
/gito/tpl/github_workflows/components/installs.j2:
--------------------------------------------------------------------------------
1 |
2 |
3 | - name: Set up Python
4 | uses: actions/setup-python@v5
5 | with: { python-version: "3.13" }
6 |
7 | - name: Fetch Latest Gito Version
8 | id: gito-version
9 | run: pip index versions gito.bot 2>/dev/null | head -1 | sed -n 's/.* (\([^)]*\)).*/version=\1/p' >> $GITHUB_OUTPUT
10 |
11 | {% raw -%}
12 | - uses: actions/cache@v4
13 | id: cache
14 | with:
15 | path: |
16 | ${{ env.pythonLocation }}/lib/python3.13/site-packages
17 | ${{ env.pythonLocation }}/bin
18 | key: gito_v${{ steps.gito-version.outputs.version }}
19 | {%- endraw %}
20 |
21 | - name: Install Gito
22 | if: steps.cache.outputs.cache-hit != 'true'
23 | run: pip install gito.bot~={{ major }}.{{ minor }}
24 |
--------------------------------------------------------------------------------
/gito/constants.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from .env import Env
3 |
4 | PROJECT_GITO_FOLDER = ".gito"
5 | PROJECT_CONFIG_FILE_NAME = "config.toml"
6 | PROJECT_CONFIG_FILE_PATH = Path(".gito") / PROJECT_CONFIG_FILE_NAME
7 | PROJECT_CONFIG_BUNDLED_DEFAULTS_FILE = Path(__file__).resolve().parent / PROJECT_CONFIG_FILE_NAME
8 | HOME_ENV_PATH = Path("~/.gito/.env").expanduser()
9 | JSON_REPORT_FILE_NAME = "code-review-report.json"
10 | GITHUB_MD_REPORT_FILE_NAME = "code-review-report.md"
11 | EXECUTABLE = "gito"
12 | TEXT_ICON_URL = 'https://raw.githubusercontent.com/Nayjest/Gito/main/press-kit/logo/gito-bot-1_64top.png' # noqa: E501
13 | HTML_TEXT_ICON = f'
' # noqa: E501
14 | HTML_CR_COMMENT_MARKER = ''
15 | REFS_VALUE_ALL = '!all'
16 |
--------------------------------------------------------------------------------
/coverage.svg:
--------------------------------------------------------------------------------
1 |
2 |
22 |
--------------------------------------------------------------------------------
/.github/workflows/pypi-release.yml:
--------------------------------------------------------------------------------
1 | name: Publish Gito to PyPI
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | call-tests:
9 | uses: ./.github/workflows/tests.yml
10 | secrets:
11 | LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
12 |
13 | build-and-publish:
14 | needs: call-tests
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: Checkout code
19 | uses: actions/checkout@v4
20 |
21 | - name: Set up Python
22 | uses: actions/setup-python@v5
23 | with:
24 | python-version: "3.11"
25 |
26 | - name: Install Poetry
27 | run: pip install poetry
28 |
29 | - name: Install dependencies
30 | run: poetry install
31 |
32 | - name: Add Poetry venv to PATH
33 | run: echo "$(poetry env info --path)/bin" >> $GITHUB_PATH
34 |
35 | - name: Build dist
36 | run: make build
37 |
38 | - name: Publish to PyPI
39 | env:
40 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
41 | run: make upload
42 |
--------------------------------------------------------------------------------
/gito/tpl/questions/release_notes.j2:
--------------------------------------------------------------------------------
1 | {{ self_id }}
2 | ----TASK----
3 | Write release notes for public documentation.
4 | Summarize the following changes, focusing on what is new, improved, or fixed for the end user.
5 | Do not include internal or technical details.
6 | Structure release notes using clear sections: Added, Changed, Fixed.
7 | Avoid internal technical jargon or developer-specific details.
8 |
9 |
10 | ----RELATED CODEBASE CHANGES----
11 | {% for part in diff %}{{ part }}\n{% endfor %}
12 |
13 | ----FULL FILE CONTENT AFTER APPLYING CHANGES----
14 | {% for file, file_lines in all_file_lines.items() %}
15 | --FILE: {{ file }}--
16 | {{ file_lines }}
17 | {% endfor %}
18 |
19 | {%- include "partial/aux_files.j2" -%}
20 |
21 | {%- if pipeline_out.associated_issue and pipeline_out.associated_issue.title %}
22 | ----ASSOCIATED ISSUE----
23 | # {{ pipeline_out.associated_issue.title }}
24 | {{ pipeline_out.associated_issue.description }}
25 | URL: {{ pipeline_out.associated_issue.url }}
26 | {%- endif -%}{{ '\n' }}
27 |
--------------------------------------------------------------------------------
/tests/test_syntax_hint.py:
--------------------------------------------------------------------------------
1 | from gito.utils import syntax_hint
2 |
3 |
4 | def test_extensions():
5 | assert syntax_hint("main.py") == "python"
6 | assert syntax_hint("script.PY") == "python"
7 | assert syntax_hint("foo.test.py") == "python"
8 | assert syntax_hint("index.html") == "html"
9 | assert syntax_hint("style.scss") == "scss"
10 | assert syntax_hint("file.json") == "json"
11 | assert syntax_hint("readme.md") == "markdown"
12 | assert syntax_hint("rstfile.rst") == "rest"
13 | assert syntax_hint("folder/folder2/run.sh") == "bash"
14 | assert syntax_hint("build.mk") == "makefile"
15 | assert syntax_hint("Dockerfile") == "dockerfile"
16 | assert syntax_hint("main.ts") == "typescript"
17 | assert syntax_hint("main.java") == "java"
18 | assert syntax_hint("foo.go") == "go"
19 | assert syntax_hint("code.cpp") == "cpp"
20 | assert syntax_hint("folder.1\\file.hello.cxx") == "cpp"
21 |
22 |
23 | def test_unknown_extension():
24 | assert syntax_hint("thing.qqq") == "qqq"
25 | assert syntax_hint("noext") == ""
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Environments
55 | .env
56 | .venv
57 | env/
58 | venv/
59 | ENV/
60 | env.bak/
61 | venv.bak/
62 |
63 | .idea
64 | .aico/*
65 | !.aico/project.json
66 | storage
67 | code-review-report.*
68 | *.zip
--------------------------------------------------------------------------------
/gito/tpl/github_workflows/gito-code-review.yml.j2:
--------------------------------------------------------------------------------
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.j2") %}
19 |
20 | - name: Run AI code review
21 | env:
22 | {%- include("github_workflows/components/env-vars.j2") %}
23 | PR_NUMBER_FROM_WORKFLOW_DISPATCH: {% raw %}${{ github.event.inputs.pr_number }}{% endraw %}
24 | run: |{% raw %}
25 | gito --verbose review
26 | gito github-comment{% 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
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Vitalii Stepanenko
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/test_raw_issue.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from gito.report_struct import RawIssue
3 | from gito.core import _llm_response_validator
4 |
5 |
6 | def test_raw_issue():
7 | def base_data():
8 | return {
9 | "title": "Bug",
10 | "details": "desc",
11 | "tags": ["bug"],
12 | "severity": 1,
13 | "confidence": 1,
14 | "affected_lines": [],
15 | }
16 | raw = [base_data(), base_data()]
17 | assert _llm_response_validator(raw) is True
18 | issue1 = RawIssue(**raw[0])
19 | assert issue1.title == "Bug"
20 | assert issue1.tags == ["bug"]
21 |
22 | del raw[0]["affected_lines"]
23 | del raw[0]["details"]
24 | del raw[0]["tags"]
25 | del raw[0]["severity"]
26 | issue1 = RawIssue(**raw[0])
27 | assert _llm_response_validator(raw) is True
28 | assert issue1.tags == []
29 | assert issue1.affected_lines == []
30 | del raw[0]["title"] # required field
31 | # raises
32 | with pytest.raises(Exception):
33 | issue1 = RawIssue(**raw[0])
34 | with pytest.raises(Exception):
35 | _llm_response_validator(raw)
36 |
--------------------------------------------------------------------------------
/documentation/config_cookbook.md:
--------------------------------------------------------------------------------
1 | # Configuration Cookbook
2 |
3 | This document provides a comprehensive guide on how to configure and tune [Gito AI Code Reviewer](https://pypi.org/project/gito.bot/) using project-specific configuration.
4 |
5 | ## Configuration file
6 | When run locally or via GitHub actions, [Gito](https://pypi.org/project/gito.bot/)
7 | looks for `.gito/config.toml` file in the repository root directory.
8 | Then it merges project-specific configuration (if exists) with the
9 | [default one](https://github.com/Nayjest/Gito/blob/main/gito/config.toml).
10 | This allows you to customize the behavior of the AI code review tool according to your project's needs.
11 |
12 |
13 | ## How to add custom code review rule?
14 | ```toml
15 | [prompt_vars]
16 | requirements = """
17 | - Issue descriptions should be written on Ukrainian language
18 | (Опис виявлених проблем має бути Українською мовою)
19 | """
20 | # this instruction affects only summary text generation, not the issue detection itself
21 | summary_requirements = """
22 | - Rate the code quality of introduced changes on a scale from 1 to 100, where 1 is the worst and 100 is the best.
23 | """
24 |
--------------------------------------------------------------------------------
/multi-build.py:
--------------------------------------------------------------------------------
1 | """
2 | Multi-build script for delivering to PYPI with aliased names.
3 |
4 | (c) Vitalii Stepanenko (Nayjest) , 2025
5 | """
6 | import re
7 | from pathlib import Path
8 | import subprocess
9 |
10 | NAMES = [
11 | ['gito.bot'],
12 | ["ai-code-review"],
13 | ["ai-cr"],
14 | ["github-code-review"],
15 | ]
16 | FILES = [
17 | "pyproject.toml",
18 | ]
19 |
20 |
21 | def replace_name(old_names: list[str], new_names: list[str], files: list[str] = None):
22 | files = files or FILES
23 | for i in range(len(old_names)):
24 | old_name = old_names[i]
25 | new_name = new_names[i]
26 | for path in files:
27 | p = Path(path)
28 | p.write_text(
29 | re.sub(
30 | fr'(? str | None:
18 | boundary = r'\b|_|-|/|\\'
19 | pattern = fr"(?:{boundary})([A-Z][A-Z0-9]{{{min_len - 1},{max_len - 1}}}-\d+)(?:{boundary})"
20 | match = re.search(pattern, branch_name)
21 | return match.group(1) if match else None
22 |
23 |
24 | def get_branch(repo: git.Repo):
25 | if is_running_in_github_action():
26 | branch_name = os.getenv('GITHUB_HEAD_REF')
27 | if branch_name:
28 | return branch_name
29 |
30 | github_ref = os.getenv('GITHUB_REF', '')
31 | if github_ref.startswith('refs/heads/'):
32 | return github_ref.replace('refs/heads/', '')
33 | try:
34 | branch_name = repo.active_branch.name
35 | return branch_name
36 | except Exception as e: # @todo: specify more precise exception
37 | logging.error("Could not determine the active branch name: %s", e)
38 | return None
39 |
40 |
41 | def resolve_issue_key(repo: git.Repo):
42 | branch_name = get_branch(repo)
43 | if not branch_name:
44 | logging.error("No active branch found in the repository, cannot determine issue key.")
45 | return None
46 |
47 | if not (issue_key := extract_issue_key(branch_name)):
48 | logging.error(f"No issue key found in branch name: {branch_name}")
49 | return None
50 | return issue_key
51 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "gito.bot"
3 | version = "3.4.2"
4 | description = "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 | authors = ["Nayjest "]
6 | readme = "README.md"
7 | license = "MIT"
8 | homepage = "https://github.com/Nayjest/Gito"
9 | repository = "https://github.com/Nayjest/Gito"
10 | keywords = ["static code analysis", "code review", "code quality", "ai", "coding", "assistant", "llm", "github", "automation", "devops", "developer tools", "github actions", "workflows", "git"]
11 | classifiers = [
12 | "Environment :: Console",
13 | "Intended Audience :: Developers",
14 | "License :: OSI Approved :: MIT License",
15 | "Programming Language :: Python :: 3",
16 | "Topic :: Software Development",
17 | ]
18 | packages = [
19 | { include = "gito" }
20 | ]
21 |
22 | [tool.poetry.dependencies]
23 | python = "^3.11"
24 | ai-microcore = "~4.5"
25 | GitPython = "^3.1.44"
26 | unidiff = "^0.7.5"
27 | google-generativeai = "^0.8.5"
28 | anthropic = ">=0.57.1,<1"
29 | typer = ">=0.16.0,<0.21"
30 | ghapi = "~=1.0.6"
31 | jira = "^3.8.0"
32 | pydantic = "^2.12.3"
33 |
34 | [tool.poetry.group.dev.dependencies]
35 | flake8 = "*"
36 | black = "*"
37 | build = "*"
38 | twine = "*"
39 | pylint = "*"
40 | pyflakes = "*"
41 | poetry = "*"
42 |
43 | [tool.poetry.group.test.dependencies]
44 | pytest = ">=7.4.3,<10"
45 | pytest-asyncio = ">=0.21.0,<2"
46 | pytest-mock = "^3.12.0"
47 | pytest-cov = ">=4.1.0,<8"
48 |
49 | [build-system]
50 | requires = ["poetry-core"]
51 | build-backend = "poetry.core.masonry.api"
52 |
53 | [tool.poetry.scripts]
54 | gito = "gito.cli:main"
55 |
56 | [tool.pytest.ini_options]
57 | minversion = "6.0"
58 | addopts = "-vv --capture=no --log-cli-level=INFO"
59 | testpaths = [
60 | "tests",
61 | ]
62 |
--------------------------------------------------------------------------------
/.github/workflows/gito-code-review.yml:
--------------------------------------------------------------------------------
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 | - name: Set up Python
19 | uses: actions/setup-python@v5
20 | with: { python-version: "3.13" }
21 |
22 | - name: Install Gito
23 | run: |
24 | pip install poetry
25 | poetry install
26 | echo "$(poetry env info --path)/bin" >> $GITHUB_PATH
27 |
28 | - name: Run AI code review
29 | env:
30 | # OpenAI Setup:
31 | # LLM_API_TYPE: openai
32 | # LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
33 | # MODEL: "gpt-4.1"
34 | LLM_API_TYPE: anthropic
35 | LLM_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
36 | MODEL: claude-opus-4-5
37 | JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }}
38 | JIRA_URL: ${{ secrets.JIRA_URL }}
39 | JIRA_USER: ${{ secrets.JIRA_USER }}
40 | LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
41 | GITO_DEBUG: "1"
42 | PR_NUMBER_FROM_WORKFLOW_DISPATCH: ${{ github.event.inputs.pr_number }}
43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44 | run: |
45 | gito --verbose review
46 | # alternative way to capture PR base
47 | # gito --verbose review ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref && format(' --against="origin/{0}"', github.event.pull_request.base.ref) || '' }}
48 | gito github-comment
49 |
50 | - uses: actions/upload-artifact@v4
51 | with:
52 | name: gito-code-review-results
53 | path: |
54 | code-review-report.md
55 | code-review-report.json
56 |
--------------------------------------------------------------------------------
/gito/pipeline_steps/jira.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 |
4 | import git
5 | from jira import JIRA, JIRAError
6 |
7 | from gito.issue_trackers import IssueTrackerIssue, resolve_issue_key
8 |
9 |
10 | def fetch_issue(issue_key, jira_url, username, api_token) -> IssueTrackerIssue | None:
11 | try:
12 | jira = JIRA(jira_url, basic_auth=(username, api_token))
13 | issue = jira.issue(issue_key)
14 | return IssueTrackerIssue(
15 | title=issue.fields.summary,
16 | description=issue.fields.description or "",
17 | url=f"{jira_url.rstrip('/')}/browse/{issue_key}"
18 | )
19 | except JIRAError as e:
20 | logging.error(
21 | f"Failed to fetch Jira issue {issue_key}: code {e.status_code} :: {e.text}"
22 | )
23 | return None
24 | except Exception as e:
25 | logging.error(f"Failed to fetch Jira issue {issue_key}: {e}")
26 | return None
27 |
28 |
29 | def fetch_associated_issue(
30 | repo: git.Repo,
31 | jira_url=None,
32 | jira_username=None,
33 | jira_api_token=None,
34 | **kwargs
35 | ):
36 | """
37 | Pipeline step to fetch a Jira issue based on the current branch name.
38 | """
39 | jira_url = jira_url or os.getenv("JIRA_URL")
40 | jira_username = (
41 | jira_username
42 | or os.getenv("JIRA_USERNAME")
43 | or os.getenv("JIRA_USER")
44 | or os.getenv("JIRA_EMAIL")
45 | )
46 | jira_token = (
47 | jira_api_token
48 | or os.getenv("JIRA_API_TOKEN")
49 | or os.getenv("JIRA_API_KEY")
50 | or os.getenv("JIRA_TOKEN")
51 | )
52 | try:
53 | assert jira_url, "JIRA_URL is not set"
54 | assert jira_username, "JIRA_USERNAME is not set"
55 | assert jira_token, "JIRA_API_TOKEN is not set"
56 | except AssertionError as e:
57 | logging.error(f"Jira configuration error: {e}")
58 | return None
59 | issue_key = resolve_issue_key(repo)
60 | return dict(
61 | associated_issue=fetch_issue(issue_key, jira_url, jira_username, jira_token)
62 | ) if issue_key else None
63 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 | workflow_call: # for triggering from pypi-release.yml
9 | secrets:
10 | LLM_API_KEY:
11 | required: false # or true if you always need it
12 |
13 | permissions:
14 | contents: write
15 |
16 | jobs:
17 | build:
18 | if: "!contains(github.event.head_commit.message, '[skip ci]')"
19 | runs-on: ubuntu-latest
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | python-version: ["3.11", "3.12", "3.13"]
24 |
25 | steps:
26 | - uses: actions/checkout@v3
27 | - name: Set up Python ${{ matrix.python-version }}
28 | uses: actions/setup-python@v3
29 | with:
30 | python-version: ${{ matrix.python-version }}
31 | - name: Install dependencies
32 | run: |
33 | pip install poetry
34 | poetry install
35 | echo "$(poetry env info --path)/bin" >> $GITHUB_PATH
36 | - name: Test with pytest
37 | if: matrix.python-version != '3.13'
38 | env:
39 | LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
40 | LLM_API_TYPE: openai
41 | MODEL: "gpt-4.1"
42 | run: |
43 | pytest
44 | - name: Test with pytest +coverage
45 | if: matrix.python-version == '3.13'
46 | env:
47 | LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
48 | LLM_API_TYPE: openai
49 | MODEL: "gpt-4.1"
50 | run: |
51 | pytest --cov=gito --cov-report=xml
52 | - name: Generate coverage badge
53 | if: matrix.python-version == '3.13' && (github.event_name == 'push' || github.event_name == 'pull_request')
54 | uses: tj-actions/coverage-badge-py@v2
55 | with:
56 | output: 'coverage.svg'
57 | - name: Commit coverage badge
58 | if: matrix.python-version == '3.13' && (github.event_name == 'push' || github.event_name == 'pull_request')
59 | run: |
60 | git config --local user.email "action@github.com"
61 | git config --local user.name "GitHub Action"
62 | git fetch origin
63 | git checkout ${{ github.head_ref || github.ref_name }} --
64 | git add coverage.svg
65 | git commit -m "Update coverage badge [skip ci]" || echo "No changes to commit"
66 | git push
67 | env:
68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
69 |
--------------------------------------------------------------------------------
/gito/tpl/questions/changes_summary.j2:
--------------------------------------------------------------------------------
1 | {{ self_id }}
2 | ----TASK----
3 |
4 | ## Subtask 1:
5 | Write a codebase changes summary clearly describing and explaining it for the engineering managers.
6 | - It is intended to be posted into bug tracker for estimating work done in the pull request.
7 | - Summary should be in a form of compact changelist where each change is described by one sentence.
8 | - Caption should be: ## PR Summary: <3-10 words>
9 | - Each change summary BP list item should start from effort estimation icon:
10 | - - ◆◇◇ (up to 2 hours)
11 | - - ◆◆◇ (half day)
12 | - - ◆◆◆ (1-2 days and more)
13 |
14 | Also include this estimation legend below as "Pure Codebase Work Estimation Legend".
15 |
16 |
17 | ## Subtask 1 (Issue alignment sentence)
18 | Include one sentence about how the code changes address the requirements of the associated issue listed below.
19 | - Use ✅ or ⚠️ to indicate whether the implementation fully satisfies the issue requirements.
20 | - Put this sentence immediately below the PR Summary title
21 | Examples:
22 |
23 | If the implementation fully delivers the requested functionality:
24 | ```
25 | ✅ Implementation Satisfies []().
26 | ```
27 | If there are concerns about how thoroughly the code covers the requirements and technical description from the associated issue:
28 | ```
29 | ⚠️ .
30 | ⚠️ .
31 | ```
32 |
33 | ## Subtask 3:
34 | Write release notes for public documentation.
35 | - Caption should be: ## Release Notes Proposal
36 | Summarize the following changes, focusing on what is new, improved, or fixed for the end user.
37 | Do not include internal or technical details.
38 | Structure release notes using clear sections: Added, Changed, Fixed.
39 | Avoid internal technical jargon or developer-specific details.
40 |
41 | ----RELATED CODEBASE CHANGES----
42 | {% for part in diff %}{{ part }}\n{% endfor %}
43 |
44 | ----FULL FILE CONTENT AFTER APPLYING CHANGES----
45 | {% for file, file_lines in all_file_lines.items() %}
46 | --FILE: {{ file }}--
47 | {{ file_lines }}
48 | {% endfor %}
49 |
50 | {%- if pipeline_out.associated_issue and pipeline_out.associated_issue.title %}
51 | ----ASSOCIATED ISSUE----
52 | # {{ pipeline_out.associated_issue.title }}
53 | {{ pipeline_out.associated_issue.description }}
54 | URL: {{ pipeline_out.associated_issue.url }}
55 | {%- endif -%}{{ '\n' }}
56 |
--------------------------------------------------------------------------------
/gito/tpl/github_workflows/gito-react-to-comments.yml.j2:
--------------------------------------------------------------------------------
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.j2") %}
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.j2") %}
69 | run: |
70 | {% raw %}gito react-to-comment ${{ github.event.comment.id }}{%- endraw %}
71 |
--------------------------------------------------------------------------------
/gito/pipeline_steps/linear.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import requests
4 |
5 | import git
6 |
7 | from gito.issue_trackers import IssueTrackerIssue, resolve_issue_key
8 |
9 |
10 | def fetch_issue(issue_key: str, api_key: str = None) -> IssueTrackerIssue | None:
11 | """
12 | Fetch a Linear issue using GraphQL API.
13 | """
14 | api_key = api_key or os.getenv("LINEAR_API_KEY")
15 | try:
16 | url = "https://api.linear.app/graphql"
17 | headers = {
18 | "Authorization": f"{api_key}",
19 | "Content-Type": "application/json"
20 | }
21 |
22 | query = """
23 | query Issues($teamKey: String!, $issueNumber: Float) {
24 | issues(filter: {team: {key: {eq: $teamKey}}, number: {eq: $issueNumber}}) {
25 | nodes {
26 | id
27 | identifier
28 | title
29 | description
30 | url
31 | }
32 | }
33 | }
34 | """
35 | team_key, issue_number = issue_key.split("-")
36 | response = requests.post(
37 | url,
38 | json={
39 | "query": query,
40 | "variables": {'teamKey': team_key, 'issueNumber': int(issue_number)}
41 | },
42 | headers=headers
43 | )
44 | response.raise_for_status()
45 | data = response.json()
46 |
47 | if "errors" in data:
48 | logging.error(f"Linear API error: {data['errors']}")
49 | return None
50 |
51 | nodes = data.get("data", {}).get("issues", {}).get("nodes", [])
52 | if not nodes:
53 | logging.error(f"Linear issue {issue_key} not found")
54 | return None
55 |
56 | issue = nodes[0]
57 | return IssueTrackerIssue(
58 | title=issue["title"],
59 | description=issue.get("description") or "",
60 | url=issue["url"]
61 | )
62 |
63 | except requests.HTTPError as e:
64 | logging.error(f"Failed to fetch Linear issue {issue_key}: {e}")
65 | logging.error(f"Response body: {response.text}")
66 | return None
67 |
68 |
69 | def fetch_associated_issue(
70 | repo: git.Repo,
71 | api_key=None,
72 | **kwargs
73 | ):
74 | """
75 | Pipeline step to fetch a Linear issue based on the current branch name.
76 | """
77 | api_key = api_key or os.getenv("LINEAR_API_KEY")
78 | if not api_key:
79 | logging.error("LINEAR_API_KEY environment variable is not set")
80 | return
81 |
82 | issue_key = resolve_issue_key(repo)
83 | return dict(
84 | associated_issue=fetch_issue(issue_key, api_key)
85 | ) if issue_key else None
86 |
--------------------------------------------------------------------------------
/.github/workflows/gito-react-to-comments.yml:
--------------------------------------------------------------------------------
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 | repository: ${{ github.repository }}
56 | token: ${{ secrets.GITHUB_TOKEN }}
57 | ref: ${{ fromJson(steps.pr.outputs.result).head_ref }}
58 | fetch-depth: 0
59 |
60 | - name: Set up Python
61 | uses: actions/setup-python@v5
62 | with: { python-version: "3.13" }
63 |
64 | - name: Install Gito
65 | run: |
66 | pip install poetry
67 | poetry install
68 | echo "$(poetry env info --path)/bin" >> $GITHUB_PATH
69 |
70 | - name: Run Gito react
71 | env:
72 | # LLM config is needed only if answer_github_comments = true in .gito/config.toml
73 | # Otherwise, use LLM_API_TYPE: none
74 | LLM_API_TYPE: anthropic
75 | LLM_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
76 | MODEL: claude-opus-4-5
77 | JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }}
78 | JIRA_URL: ${{ secrets.JIRA_URL }}
79 | JIRA_USER: ${{ secrets.JIRA_USER }}
80 | LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
81 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
82 | run: |
83 | gito react-to-comment ${{ github.event.comment.id }} --token ${{ secrets.GITHUB_TOKEN }}
84 |
--------------------------------------------------------------------------------
/gito/pipeline.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from enum import StrEnum
3 | from dataclasses import dataclass, field
4 |
5 | from microcore import ui
6 | from microcore.utils import resolve_callable
7 |
8 | from .context import Context
9 | from .utils import is_running_in_github_action
10 |
11 |
12 | class PipelineEnv(StrEnum):
13 | LOCAL = "local"
14 | GH_ACTION = "gh-action"
15 |
16 | @staticmethod
17 | def all():
18 | return [PipelineEnv.LOCAL, PipelineEnv.GH_ACTION]
19 |
20 | @staticmethod
21 | def current():
22 | return (
23 | PipelineEnv.GH_ACTION
24 | if is_running_in_github_action()
25 | else PipelineEnv.LOCAL
26 | )
27 |
28 |
29 | @dataclass
30 | class PipelineStep:
31 | call: str
32 | envs: list[PipelineEnv] = field(default_factory=PipelineEnv.all)
33 | enabled: bool = field(default=True)
34 |
35 | def get_callable(self):
36 | """
37 | Resolve the callable from the string representation.
38 | """
39 | return resolve_callable(self.call)
40 |
41 | def run(self, *args, **kwargs):
42 | return self.get_callable()(*args, **kwargs)
43 |
44 |
45 | @dataclass
46 | class Pipeline:
47 | ctx: Context = field()
48 | steps: dict[str, PipelineStep] = field(default_factory=dict)
49 | verbose: bool = False
50 |
51 | @property
52 | def enabled_steps(self):
53 | return {
54 | k: v for k, v in self.steps.items() if v.enabled
55 | }
56 |
57 | def run(self, *args, **kwargs):
58 | cur_env = PipelineEnv.current()
59 | logging.info("Running pipeline... [env: %s]", ui.yellow(cur_env))
60 | for step_name, step in self.enabled_steps.items():
61 | if cur_env in step.envs:
62 | logging.info(f"Running pipeline step: {step_name}")
63 | try:
64 | step_output = step.run(*args, **kwargs, **vars(self.ctx))
65 | if isinstance(step_output, dict):
66 | self.ctx.pipeline_out.update(step_output)
67 | self.ctx.pipeline_out[step_name] = step_output
68 | if self.verbose and step_output:
69 | logging.info(
70 | f"Pipeline step {step_name} output: {repr(step_output)}"
71 | )
72 | if not step_output:
73 | logging.warning(
74 | f'Pipeline step "{step_name}" returned {repr(step_output)}.'
75 | )
76 | except Exception as e:
77 | logging.error(f'Error in pipeline step "{step_name}": {e}')
78 | else:
79 | logging.info(
80 | f"Skipping pipeline step: {step_name}"
81 | f" [env: {ui.yellow(cur_env)} not in {step.envs}]"
82 | )
83 | return self.ctx.pipeline_out
84 |
--------------------------------------------------------------------------------
/tests/fixtures/cr-report-1.json:
--------------------------------------------------------------------------------
1 | {
2 | "issues": {
3 | "src/example.py": [
4 | {
5 | "id": 1,
6 | "title": "ISSUE_1 TITLE",
7 | "details": "ISSUE_1 DESCR\nLINE_2\nLINE_3",
8 | "severity": 2,
9 | "confidence": 1,
10 | "tags": [
11 | "bug",
12 | "maintainability"
13 | ],
14 | "file": "src/example.py",
15 | "affected_lines": [
16 | {
17 | "start_line": 4,
18 | "end_line": 6,
19 | "file": "src/example.py",
20 | "proposal": "NEW_LINE_4\nNEW_LINE_5",
21 | "affected_code": "4: \nOLD_LINE_4\n5: OLD_LINE_5\n6: OLD_LINE_6"
22 | }
23 | ]
24 | }
25 | ],
26 | "src/example2.py": [],
27 | "src/example3.py": [
28 | {
29 | "id": 2,
30 | "title": "ISSUE_2 TITLE",
31 | "details": "ISSUE_2 DESCR\n",
32 | "severity": 3,
33 | "confidence": 4,
34 | "tags": [
35 | "code_smell"
36 | ],
37 | "file": "src/example3.py"
38 | },
39 | {
40 | "id": 3,
41 | "title": "ISSUE_3 TITLE",
42 | "details": "ISSUE_3 DESCR\n",
43 | "severity": 1,
44 | "confidence": 1,
45 | "tags": [],
46 | "file": "src/example3.py",
47 | "affected_lines": [
48 | {
49 | "start_line": 1,
50 | "end_line": 1,
51 | "file": "src/example3.py",
52 | "affected_code": "1: \nOLD_LINE_1"
53 | }
54 | ]
55 | },
56 | {
57 | "id": 4,
58 | "title": "ISSUE_4 TITLE",
59 | "details": "",
60 | "severity": 1,
61 | "confidence": 1,
62 | "tags": [],
63 | "file": "src/example3.py",
64 | "affected_lines": [
65 | {
66 | "start_line": 4,
67 | "end_line": 4,
68 | "file": "src/example3.py",
69 | "affected_code": "1: \nOLD_LINE_4"
70 | },
71 | {
72 | "start_line": 9,
73 | "end_line": 11,
74 | "file": "src/example3.py",
75 | "affected_code": "9: \nOLD_LINE_9\n10: OLD_LINE_10\n11: OLD_LINE_11",
76 | "proposal": ""
77 | }
78 | ]
79 | }
80 | ]
81 | },
82 | "summary": "SUMMARY_TEXT",
83 | "number_of_processed_files": 555,
84 | "total_issues": 4,
85 | "created_at": "2025-06-17 14:34:12",
86 | "model": "claude-sonnet-4-20250514"
87 | }
--------------------------------------------------------------------------------
/gito/cli_base.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import logging
3 | import tempfile
4 |
5 | import microcore as mc
6 | import typer
7 | from git import Repo
8 | from gito.constants import REFS_VALUE_ALL
9 |
10 | from .utils import parse_refs_pair
11 | from .env import Env
12 |
13 |
14 | def args_to_target(refs, what, against) -> tuple[str | None, str | None]:
15 | if refs == REFS_VALUE_ALL:
16 | return REFS_VALUE_ALL, None
17 | _what, _against = parse_refs_pair(refs)
18 | if _what:
19 | if what:
20 | raise typer.BadParameter(
21 | "You cannot specify both 'refs' .. and '--what'. Use one of them."
22 | )
23 | else:
24 | _what = what
25 | if _against:
26 | if against:
27 | raise typer.BadParameter(
28 | "You cannot specify both 'refs' .. and '--against'. Use one of them."
29 | )
30 | else:
31 | _against = against
32 | return _what, _against
33 |
34 |
35 | def arg_refs() -> typer.Argument:
36 | return typer.Argument(
37 | default=None,
38 | help=(
39 | "Git refs to review, [what]..[against] (e.g., 'HEAD..HEAD~1'). "
40 | "If omitted, the current index (including added but not committed files) "
41 | "will be compared to the repository’s main branch."
42 | ),
43 | )
44 |
45 |
46 | def arg_what() -> typer.Option:
47 | return typer.Option(None, "--what", "-w", help="Git ref to review")
48 |
49 |
50 | def arg_filters() -> typer.Option:
51 | return typer.Option(
52 | "", "--filter", "-f", "--filters",
53 | help="""
54 | filter reviewed files by glob / fnmatch pattern(s),
55 | e.g. 'src/**/*.py', may be comma-separated
56 | """,
57 | )
58 |
59 |
60 | def arg_out() -> typer.Option:
61 | return typer.Option(
62 | None,
63 | "--out", "-o", "--output",
64 | help="Output folder for the code review report"
65 | )
66 |
67 |
68 | def arg_against() -> typer.Option:
69 | return typer.Option(
70 | None,
71 | "--against", "-vs", "--vs",
72 | help="Git ref to compare against"
73 | )
74 |
75 |
76 | app = typer.Typer(pretty_exceptions_show_locals=False)
77 |
78 |
79 | @contextlib.contextmanager
80 | def get_repo_context(url: str, branch: str):
81 | if branch == REFS_VALUE_ALL:
82 | branch = None
83 | """Context manager for handling both local and remote repositories."""
84 | if url:
85 | with tempfile.TemporaryDirectory() as temp_dir:
86 | logging.info(
87 | f"get_repo_context: "
88 | f"Cloning [{mc.ui.green(url)}] to {mc.utils.file_link(temp_dir)} ..."
89 | )
90 | repo = Repo.clone_from(url, branch=branch, to_path=temp_dir)
91 | prev_folder = Env.working_folder
92 | Env.working_folder = temp_dir
93 | try:
94 | yield repo, temp_dir
95 | finally:
96 | repo.close()
97 | Env.working_folder = prev_folder
98 | else:
99 | logging.info("get_repo_context: Using local repo...")
100 | repo = Repo(".")
101 | try:
102 | yield repo, "."
103 | finally:
104 | repo.close()
105 |
--------------------------------------------------------------------------------
/gito/project_config.py:
--------------------------------------------------------------------------------
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 | answer_prompt: str = ""
20 | report_template_md: str = ""
21 | """Markdown report template"""
22 | report_template_cli: str = ""
23 | """Report template for CLI output"""
24 | post_process: str = ""
25 | retries: int = 3
26 | """LLM retries for one request"""
27 | max_code_tokens: int = 32000
28 | prompt_vars: dict = field(default_factory=dict)
29 | mention_triggers: list[str] = field(default_factory=list)
30 | answer_github_comments: bool = field(default=True)
31 | """
32 | Defines the keyword or mention tag that triggers bot actions
33 | when referenced in code review comments.
34 | """
35 | aux_files: list[str] = field(default_factory=list)
36 | exclude_files: list[str] = field(default_factory=list)
37 | """
38 | List of file patterns to exclude from analysis.
39 | """
40 | pipeline_steps: dict[str, dict | PipelineStep] = field(default_factory=dict)
41 | collapse_previous_code_review_comments: bool = field(default=True)
42 | """
43 | If True, previously added code review comments in the pull request
44 | will be collapsed automatically when a new comment is added.
45 | """
46 |
47 | def __post_init__(self):
48 | self.pipeline_steps = {
49 | k: PipelineStep(**v) if isinstance(v, dict) else v
50 | for k, v in self.pipeline_steps.items()
51 | }
52 |
53 | @staticmethod
54 | def _read_bundled_defaults() -> dict:
55 | with open(PROJECT_CONFIG_BUNDLED_DEFAULTS_FILE, "rb") as f:
56 | config = tomllib.load(f)
57 | return config
58 |
59 | @staticmethod
60 | def load_for_repo(repo: Repo):
61 | return ProjectConfig.load(Path(repo.working_tree_dir) / PROJECT_CONFIG_FILE_PATH)
62 |
63 | @staticmethod
64 | def load(config_path: str | Path | None = None) -> "ProjectConfig":
65 | config = ProjectConfig._read_bundled_defaults()
66 | github_env = detect_github_env()
67 | config["prompt_vars"] |= github_env | dict(github_env=github_env)
68 |
69 | config_path = Path(config_path or PROJECT_CONFIG_FILE_PATH)
70 | if config_path.exists():
71 | logging.info(
72 | f"Loading project-specific configuration from {mc.utils.file_link(config_path)}...")
73 | default_prompt_vars = config["prompt_vars"]
74 | default_pipeline_steps = config["pipeline_steps"]
75 | with open(config_path, "rb") as f:
76 | config.update(tomllib.load(f))
77 | # overriding prompt_vars config section will not empty default values
78 | config["prompt_vars"] = default_prompt_vars | config["prompt_vars"]
79 | # merge individual pipeline steps
80 | for k, v in config["pipeline_steps"].items():
81 | config["pipeline_steps"][k] = default_pipeline_steps.get(k, {}) | v
82 | # merge pipeline steps dict
83 | config["pipeline_steps"] = default_pipeline_steps | config["pipeline_steps"]
84 | else:
85 | logging.info(
86 | f"No project config found at {ui.blue(config_path)}, using defaults"
87 | )
88 |
89 | return ProjectConfig(**config)
90 |
--------------------------------------------------------------------------------
/gito/bootstrap.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import io
4 | import logging
5 | from datetime import datetime
6 | from pathlib import Path
7 |
8 | import microcore as mc
9 |
10 | from .utils import is_running_in_github_action
11 | from .constants import HOME_ENV_PATH, EXECUTABLE, PROJECT_GITO_FOLDER
12 | from .env import Env
13 |
14 |
15 | def setup_logging(log_level: int = logging.INFO):
16 | """Setup custom CLI logging format with colored output."""
17 | class CustomFormatter(logging.Formatter):
18 | def format(self, record):
19 | dt = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
20 | message, level_name = record.getMessage(), record.levelname
21 | if record.levelno == logging.WARNING:
22 | message = mc.ui.yellow(message)
23 | level_name = mc.ui.yellow(level_name)
24 | if record.levelno >= logging.ERROR:
25 | message = mc.ui.red(message)
26 | level_name = mc.ui.red(level_name)
27 |
28 | formatted_message = f"{dt} {level_name}: {message}"
29 | if record.exc_info:
30 | formatted_message += "\n" + self.formatException(record.exc_info)
31 | return formatted_message
32 |
33 | handler = logging.StreamHandler()
34 | handler.setFormatter(CustomFormatter())
35 | logging.basicConfig(level=log_level, handlers=[handler])
36 |
37 |
38 | def bootstrap(verbosity: int = 1):
39 | """Bootstrap the application with the environment configuration."""
40 | log_levels_by_verbosity = {
41 | 0: logging.CRITICAL,
42 | 1: logging.INFO,
43 | 2: logging.INFO,
44 | 3: logging.DEBUG,
45 | }
46 | Env.verbosity = verbosity
47 | Env.logging_level = log_levels_by_verbosity.get(verbosity, logging.INFO)
48 | setup_logging(Env.logging_level)
49 | logging.info(
50 | f"Bootstrapping Gito v{Env.gito_version}... "
51 | + mc.ui.gray(f"[verbosity={verbosity}]")
52 | )
53 |
54 | # cp1251 is used on Windows when redirecting output
55 | if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
56 | sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
57 |
58 | try:
59 | mc.configure(
60 | DOT_ENV_FILE=HOME_ENV_PATH,
61 | USE_LOGGING=verbosity >= 1,
62 | EMBEDDING_DB_TYPE=mc.EmbeddingDbType.NONE,
63 | PROMPT_TEMPLATES_PATH=[
64 | PROJECT_GITO_FOLDER,
65 | Path(__file__).parent / "tpl"
66 | ],
67 | )
68 | if verbosity > 1:
69 | mc.logging.LoggingConfig.STRIP_REQUEST_LINES = None
70 | else:
71 | mc.logging.LoggingConfig.STRIP_REQUEST_LINES = [300, 15]
72 |
73 | except mc.LLMConfigError as e:
74 | msg = str(e)
75 | if is_running_in_github_action():
76 | ref = os.getenv("GITHUB_WORKFLOW_REF", "")
77 | if ref:
78 | # example value: 'owner/repo/.github/workflows/ai-code-review.yml@refs/pull/1/merge'
79 | ref = ref.split("@")[0]
80 | ref = ref.split(".github/workflows/")[-1]
81 | ref = f" (.github/workflows/{ref})"
82 | msg += (
83 | f"\nPlease check your GitHub Action Secrets "
84 | f"and `env` configuration section of the corresponding workflow step{ref}."
85 | )
86 | else:
87 | msg += (
88 | f"\nPlease run '{EXECUTABLE} setup' "
89 | "to configure LLM API access (API keys, model, etc)."
90 | )
91 | print(mc.ui.red(msg))
92 | raise SystemExit(2)
93 | except Exception as e:
94 | logging.error(f"Unexpected configuration error: {e}")
95 | raise SystemExit(3)
96 |
--------------------------------------------------------------------------------
/tests/test_pipeline.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from unittest.mock import patch, MagicMock
3 | import microcore as mc
4 | from gito.pipeline import Pipeline, PipelineStep, PipelineEnv
5 | from gito.context import Context
6 | from gito.project_config import ProjectConfig
7 | from gito.report_struct import Report
8 |
9 |
10 | # --- Fixtures and helpers ---
11 |
12 |
13 | @pytest.fixture
14 | def dummy_callable():
15 | def _callable(*args, **kwargs):
16 | return {"result": "ok"}
17 |
18 | return _callable
19 |
20 |
21 | @pytest.fixture
22 | def patch_resolve_callable(dummy_callable):
23 | with patch("gito.pipeline.resolve_callable", return_value=dummy_callable):
24 | yield
25 |
26 |
27 | @pytest.fixture
28 | def patch_github_action_env(monkeypatch):
29 | # Monkeypatch is_running_in_github_action to return True (GH_ACTION) or False (LOCAL)
30 | def _patch(is_gh_action):
31 | monkeypatch.setattr(
32 | "gito.pipeline.is_running_in_github_action", lambda: is_gh_action
33 | )
34 |
35 | return _patch
36 |
37 |
38 | # --- Tests ---
39 |
40 |
41 | def test_pipelineenv_current_local(patch_github_action_env):
42 | patch_github_action_env(False)
43 | assert PipelineEnv.current() == PipelineEnv.LOCAL
44 |
45 |
46 | def test_pipelineenv_current_gh_action(patch_github_action_env):
47 | patch_github_action_env(True)
48 | assert PipelineEnv.current() == PipelineEnv.GH_ACTION
49 |
50 |
51 | def test_pipeline_step_run_calls_resolve_callable(patch_resolve_callable):
52 | step = PipelineStep(call="myfunc")
53 | # Should call the resolved dummy_callable and not fail
54 | step.run(foo="bar") # should not raise
55 |
56 |
57 | def test_pipeline_run_skips_steps_for_other_env(
58 | monkeypatch, patch_resolve_callable, patch_github_action_env
59 | ):
60 | patch_github_action_env(False) # LOCAL
61 |
62 | dummy_step = PipelineStep(call="myfunc", envs=[PipelineEnv.GH_ACTION])
63 | dummy_step.run = MagicMock()
64 | steps = {"step1": dummy_step}
65 | mc.configure(
66 | LLM_API_TYPE=mc.ApiType.NONE,
67 | )
68 | ctx = Context(
69 | report=Report(), # Mock or set up a repo if needed
70 | config=ProjectConfig.load(),
71 | diff=[],
72 | repo=None,
73 | )
74 | pipeline = Pipeline(ctx, steps=steps)
75 |
76 | pipeline.run()
77 | dummy_step.run.assert_not_called()
78 |
79 |
80 | def test_pipeline_step_envs_default(patch_resolve_callable):
81 | step = PipelineStep(call="myfunc")
82 | assert set(step.envs) == set(PipelineEnv.all())
83 |
84 |
85 | # --- Optional: test multiple steps and context updates ---
86 |
87 |
88 | def test_pipeline_multiple_steps(monkeypatch, patch_github_action_env):
89 | mc.configure(
90 | LLM_API_TYPE=mc.ApiType.NONE,
91 | )
92 | patch_github_action_env(False) # LOCAL
93 |
94 | step1 = PipelineStep(call="func1", envs=[PipelineEnv.LOCAL])
95 | step2 = PipelineStep(call="func2", envs=[PipelineEnv.LOCAL])
96 | # Fake run: each step updates ctx
97 | step1.run = lambda *a, **k: {"a": 1}
98 | step2.run = lambda *a, **k: {"b": 2}
99 |
100 | ctx = Context(
101 | report=Report(), # Mock or set up a repo if needed
102 | config=ProjectConfig.load(),
103 | diff=[],
104 | repo=None,
105 | )
106 | pipeline = Pipeline(ctx, steps={"step1": step1, "step2": step2})
107 |
108 | result = pipeline.run()
109 | assert result["a"] == 1
110 | assert result["b"] == 2
111 |
112 |
113 | def test_get_callable():
114 | callable_fn = PipelineStep(
115 | call="gito.pipeline_steps.jira.fetch_associated_issue"
116 | ).get_callable()
117 | assert callable(callable_fn), "Expected a callable function"
118 |
--------------------------------------------------------------------------------
/gito/commands/gh_post_review_comment.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from time import sleep
4 |
5 | import typer
6 | from ghapi.core import GhApi
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
16 |
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"),
23 | token: str = typer.Option(
24 | "", help="GitHub token (or set GITHUB_TOKEN env var)"
25 | ),
26 | ):
27 | """
28 | Leaves a comment with the review on the current GitHub pull request.
29 | """
30 | file = md_report_file or GITHUB_MD_REPORT_FILE_NAME
31 | if not os.path.exists(file):
32 | logging.error(f"Review file not found: {file}, comment will not be posted.")
33 | raise typer.Exit(4)
34 |
35 | with open(file, "r", encoding="utf-8") as f:
36 | body = f.read()
37 |
38 | token = resolve_gh_token(token)
39 | if not token:
40 | print("GitHub token is required (--token or GITHUB_TOKEN env var).")
41 | raise typer.Exit(1)
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", "")
46 | logging.info(f"github_pr_number = {pr_env_val}")
47 |
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
63 | if not pr:
64 | if pr_str := os.getenv("PR_NUMBER_FROM_WORKFLOW_DISPATCH"):
65 | try:
66 | pr = int(pr_str)
67 | except ValueError:
68 | pass
69 | if not pr:
70 | logging.error("Could not resolve PR number from environment variables.")
71 | raise typer.Exit(3)
72 |
73 | if not post_gh_comment(gh_repo, pr, token, body):
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"{collapsed_title}"
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"\n{collapsed_title}
\n\n{comment.body}\n "
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.")
112 |
--------------------------------------------------------------------------------
/gito/gh_api.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 |
4 | import requests
5 | import git
6 | from fastcore.basics import AttrDict # objects returned by ghapi
7 | from ghapi.core import GhApi
8 |
9 | from .project_config import ProjectConfig
10 | from .utils import extract_gh_owner_repo
11 |
12 |
13 | def gh_api(
14 | repo: git.Repo = None, # used to resolve owner/repo
15 | config: ProjectConfig | None = None, # used to resolve owner/repo
16 | token: str | None = None
17 | ) -> GhApi:
18 | if repo:
19 | # resolve owner/repo from repo.remotes.origin.url
20 | owner, repo_name = extract_gh_owner_repo(repo)
21 | else:
22 | if not config:
23 | config = ProjectConfig.load()
24 | # resolve owner/repo from github env vars (github actions)
25 | gh_env = config.prompt_vars.get("github_env", {})
26 | gh_repo = gh_env.get("github_repo")
27 | if not gh_repo:
28 | raise ValueError("GitHub repository not specified and not found in project config.")
29 | parts = gh_repo.split('/')
30 | if len(parts) != 2:
31 | raise ValueError(f"Invalid GitHub repository format: {gh_repo}. Expected 'owner/repo'.")
32 | owner, repo_name = parts
33 |
34 | token = resolve_gh_token(token)
35 | api = GhApi(owner, repo_name, token=token)
36 | return api
37 |
38 |
39 | def resolve_gh_token(token_or_none: str | None = None) -> str | None:
40 | return token_or_none or os.getenv("GITHUB_TOKEN", None) or os.getenv("GH_TOKEN", None)
41 |
42 |
43 | def post_gh_comment(
44 | gh_repository: str, # e.g. "owner/repo"
45 | pr_or_issue_number: int,
46 | gh_token: str,
47 | text: str,
48 | ) -> bool:
49 | """
50 | Post a comment to a GitHub pull request or issue.
51 | Arguments:
52 | gh_repository (str): The GitHub repository in the format "owner/repo".
53 | pr_or_issue_number (int): The pull request or issue number.
54 | gh_token (str): GitHub personal access token with permissions to post comments.
55 | text (str): The comment text to post.
56 | Returns:
57 | True if the comment was posted successfully, False otherwise.
58 | """
59 | api_url = f"https://api.github.com/repos/{gh_repository}/issues/{pr_or_issue_number}/comments"
60 | headers = {
61 | "Authorization": f"token {gh_token}",
62 | "Accept": "application/vnd.github+json",
63 | }
64 | data = {"body": text}
65 |
66 | resp = requests.post(api_url, headers=headers, json=data)
67 | if 200 <= resp.status_code < 300:
68 | logging.info(f"Posted review comment to #{pr_or_issue_number} in {gh_repository}")
69 | return True
70 |
71 | logging.error(f"Failed to post comment: {resp.status_code} {resp.reason}\n{resp.text}")
72 | return False
73 |
74 |
75 | def hide_gh_comment(
76 | comment: dict | str,
77 | token: str = None,
78 | reason: str = "OUTDATED"
79 | ) -> bool:
80 | """
81 | Hide a GitHub comment using GraphQL API with specified reason.
82 | Args:
83 | comment (dict | str):
84 | The comment to hide,
85 | either as a object returned from ghapi or a string node ID.
86 | note: comment.id is not the same as node_id.
87 | token (str): GitHub personal access token with permissions to minimize comments.
88 | reason (str): The reason for hiding the comment, e.g., "OUTDATED".
89 | """
90 | comment_node_id = comment.node_id if isinstance(comment, AttrDict) else comment
91 | token = resolve_gh_token(token)
92 | mutation = """
93 | mutation($commentId: ID!, $reason: ReportedContentClassifiers!) {
94 | minimizeComment(input: {subjectId: $commentId, classifier: $reason}) {
95 | minimizedComment { isMinimized }
96 | }
97 | }"""
98 |
99 | response = requests.post(
100 | "https://api.github.com/graphql",
101 | headers={"Authorization": f"Bearer {token}"},
102 | json={
103 | "query": mutation,
104 | "variables": {"commentId": comment_node_id, "reason": reason}
105 | }
106 | )
107 | success = (
108 | response.status_code == 200
109 | and response.json().get("data", {}).get("minimizeComment") is not None
110 | )
111 | if not success:
112 | logging.error(
113 | f"Failed to hide comment {comment_node_id}: "
114 | f"{response.status_code} {response.reason}\n{response.text}"
115 | )
116 | return success
117 |
--------------------------------------------------------------------------------
/gito/commands/deploy.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from pathlib import Path
3 |
4 | import microcore as mc
5 | from microcore import ApiType, ui, utils
6 | from git import Repo, GitCommandError
7 | import typer
8 |
9 | from ..core import get_base_branch
10 | from ..utils import version, extract_gh_owner_repo
11 | from ..cli_base import app
12 | from ..gh_api import gh_api
13 |
14 |
15 | @app.command(
16 | name="deploy",
17 | help="\bCreate and configure Gito GitHub Actions for current repository.\naliases: init"
18 | )
19 | @app.command(name="init", hidden=True)
20 | def deploy(
21 | api_type: ApiType = None,
22 | commit: bool = None,
23 | rewrite: bool = False,
24 | to_branch: str = typer.Option(
25 | default="gito_deploy",
26 | help="Branch name for new PR containing with Gito workflows commit"
27 | ),
28 | token: str = typer.Option(
29 | "", help="GitHub token (or set GITHUB_TOKEN env var)"
30 | ),
31 | ):
32 | repo = Repo(".")
33 | workflow_files = dict(
34 | code_review=Path(".github/workflows/gito-code-review.yml"),
35 | react_to_comments=Path(".github/workflows/gito-react-to-comments.yml")
36 | )
37 | for file in workflow_files.values():
38 | if file.exists():
39 | message = f"Gito workflow already exists at {utils.file_link(file)}."
40 | if rewrite:
41 | ui.warning(message)
42 | else:
43 | message += "\nUse --rewrite to overwrite it."
44 | ui.error(message)
45 | return False
46 |
47 | api_types = [ApiType.ANTHROPIC, ApiType.OPEN_AI, ApiType.GOOGLE_AI_STUDIO]
48 | default_models = {
49 | ApiType.ANTHROPIC: "claude-sonnet-4-20250514",
50 | ApiType.OPEN_AI: "gpt-4.1",
51 | ApiType.GOOGLE_AI_STUDIO: "gemini-2.5-pro",
52 | }
53 | secret_names = {
54 | ApiType.ANTHROPIC: "ANTHROPIC_API_KEY",
55 | ApiType.OPEN_AI: "OPENAI_API_KEY",
56 | ApiType.GOOGLE_AI_STUDIO: "GOOGLE_AI_API_KEY",
57 | }
58 | if not api_type:
59 | api_type = mc.ui.ask_choose(
60 | "Choose your LLM API type",
61 | api_types,
62 | )
63 | elif api_type not in api_types:
64 | mc.ui.error(f"Unsupported API type: {api_type}")
65 | return False
66 | major, minor, *_ = version().split(".")
67 | template_vars = dict(
68 | model=default_models[api_type],
69 | api_type=api_type,
70 | secret_name=secret_names[api_type],
71 | major=major,
72 | minor=minor,
73 | ApiType=ApiType,
74 | remove_indent=True,
75 | )
76 | gito_code_review_yml = mc.tpl(
77 | "github_workflows/gito-code-review.yml.j2",
78 | **template_vars
79 | )
80 | gito_react_to_comments_yml = mc.tpl(
81 | "github_workflows/gito-react-to-comments.yml.j2",
82 | **template_vars
83 | )
84 |
85 | workflow_files["code_review"].parent.mkdir(parents=True, exist_ok=True)
86 | workflow_files["code_review"].write_text(gito_code_review_yml)
87 | workflow_files["react_to_comments"].write_text(gito_react_to_comments_yml)
88 | print(
89 | mc.ui.green("Gito workflows have been created.\n")
90 | + f" - {mc.utils.file_link(workflow_files['code_review'])}\n"
91 | + f" - {mc.utils.file_link(workflow_files['react_to_comments'])}\n"
92 | )
93 | owner, repo_name = extract_gh_owner_repo(repo)
94 | if commit is True or commit is None and mc.ui.ask_yn(
95 | "Do you want to commit and push created GitHub workflows to a new branch?"
96 | ):
97 | repo.git.add([str(file) for file in workflow_files.values()])
98 | if not repo.active_branch.name.startswith(to_branch):
99 | repo.git.checkout("-b", to_branch)
100 | try:
101 | repo.git.commit("-m", "Deploy Gito workflows")
102 | except GitCommandError as e:
103 | if "nothing added" in str(e):
104 | ui.warning("Failed to commit changes: nothing was added")
105 | else:
106 | ui.error(f"Failed to commit changes: {e}")
107 | return False
108 |
109 | repo.git.push("origin", to_branch)
110 | print(f"Changes pushed to {to_branch} branch.")
111 | try:
112 | api = gh_api(repo=repo)
113 | base = get_base_branch(repo).split('/')[-1]
114 | logging.info(f"Creating PR {ui.green(to_branch)} -> {ui.yellow(base)}...")
115 | res = api.pulls.create(
116 | head=to_branch,
117 | base=base,
118 | title="Deploy Gito workflows",
119 | )
120 | print(f"Pull request #{res.number} created successfully:\n{res.html_url}")
121 | except Exception as e:
122 | mc.ui.error(f"Failed to create pull request automatically: {e}")
123 | print(
124 | f"Please create a PR from '{to_branch}' to your main branch and merge it:\n"
125 | f"https://github.com/{owner}/{repo_name}/compare/{to_branch}?expand=1"
126 | )
127 | else:
128 | print(
129 | "Now you can commit and push created GitHub workflows to your main repository branch.\n"
130 | )
131 |
132 | print(
133 | "(!IMPORTANT):\n"
134 | f"Add {mc.ui.cyan(secret_names[api_type])} with actual API_KEY "
135 | "to your repository secrets here:\n"
136 | f"https://github.com/{owner}/{repo_name}/settings/secrets/actions"
137 | )
138 | return True
139 |
--------------------------------------------------------------------------------
/documentation/github_setup.md:
--------------------------------------------------------------------------------
1 | # GitHub Setup Guide: Integrating Gito with Your Repository
2 |
3 | Automate code review for all Pull Requests using AI.
4 | This step-by-step guide shows how to connect [Gito](https://pypi.org/project/gito.bot/) to a GitHub repository for **continuous, automated PR reviews**.
5 |
6 | ---
7 |
8 | ## Prerequisites
9 |
10 | - **Admin access** to your GitHub repository.
11 | - An **API key** for your preferred language model provider (e.g., OpenAI, Google Gemini, Anthropic Claude, etc).
12 |
13 | ---
14 |
15 | ## 1. Add Your LLM API Key as a GitHub Secret
16 |
17 | 1. In your GitHub repository, go to **Settings → Secrets and variables → Actions**.
18 | 2. Click **New repository secret**.
19 | 3. Enter a name (e.g. `LLM_API_KEY`), and paste your API key value.
20 | 4. Click **Add secret**.
21 |
22 | > **Tip:** LLM API keys allow the workflow to analyze code changes using an AI model.
23 | > If you don't have the necessary permission, ask a repository administrator to add the secret.
24 |
25 | You may use a secret manager (such as HashiCorp Vault) to fetch keys at runtime, but for most teams, GitHub Secrets is the simplest approach.
26 |
27 | ---
28 |
29 | ## 2. Create GitHub workflows
30 |
31 | There are two ways to set up Gito for code reviews in your repository:
32 | - Manually create the workflow file in your repository.
33 | - Use `gito init` command locally in the context of your repository and commit the generated workflow files.
34 | > **Note:**
35 | > 1. This requires the `gito` CLI tool to be installed locally.
36 | > 2. It will also create the workflow for reacting to the GitHub comments (experimental).
37 |
38 |
39 | ### Creating workflow file manually
40 |
41 | Create a file at `.github/workflows/gito-code-review.yml` in your repository with the following content:
42 |
43 | ```yaml
44 | name: "Gito: AI Code Review"
45 | on:
46 | pull_request:
47 | types: [opened, synchronize, reopened]
48 | workflow_dispatch:
49 | inputs:
50 | pr_number:
51 | description: "Pull Request number"
52 | required: true
53 | jobs:
54 | review:
55 | runs-on: ubuntu-latest
56 | permissions: { contents: read, pull-requests: write } # required to post review comments
57 | steps:
58 | - uses: actions/checkout@v4
59 | with: { fetch-depth: 0 }
60 | - name: Set up Python
61 | uses: actions/setup-python@v5
62 | with: { python-version: "3.13" }
63 | - name: Install AI Code Review tool
64 | run: pip install gito.bot~=3.4
65 | - name: Run AI code review
66 | env:
67 | LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
68 | LLM_API_TYPE: openai
69 | MODEL: "gpt-4.1"
70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
71 | PR_NUMBER_FROM_WORKFLOW_DISPATCH: ${{ github.event.inputs.pr_number }}
72 | run: |
73 | gito --verbose review
74 | gito github-comment --token "$GITHUB_TOKEN"
75 | - uses: actions/upload-artifact@v4
76 | with:
77 | name: gito-code-review-results
78 | path: |
79 | code-review-report.md
80 | code-review-report.json
81 | ```
82 |
83 | #### Notes
84 |
85 | - **Set `LLM_API_TYPE` and `MODEL` as needed** for your chosen LLM provider (see below for links).
86 | - If you used a different secret name in step 1, update `${{ secrets.LLM_API_KEY }}` accordingly.
87 | - This workflow will:
88 | - Analyze all pull requests using an LLM,
89 | - Post a review summary as a PR comment,
90 | - Upload code review reports as workflow artifacts for you to download if needed.
91 |
92 | #### Example .env setups for other language model providers:
93 |
94 | - [Mistral](https://github.com/Nayjest/ai-microcore/blob/main/.env.mistral.example)
95 | - [Gemini via Google AI Studio](https://github.com/Nayjest/ai-microcore/blob/main/.env.gemini.example)
96 | - [Gemini via Google Vertex](https://github.com/Nayjest/ai-microcore/blob/main/.env.google-vertex-gemini.example) *(add `pip install vertexai` to your workflow)*
97 | - [Anthropic Claude](https://github.com/Nayjest/ai-microcore/blob/main/.env.anthropic.example)
98 |
99 | ---
100 |
101 | ## Done! See AI Review Results on Pull Requests
102 |
103 | Whenever a PR is opened or updated, you'll see an **AI-generated code review comment** in the PR discussion.
104 |
105 | **Tips:**
106 | - To trigger a review for older existing PRs, merge the `main` branch containing `.github/workflows/gito-code-review.yml`
107 | - You may close and reopen the PR to trigger the review again.
108 | - Download full review artifacts from the corresponding GitHub Actions workflow run.
109 |
110 | ---
111 |
112 | ## Customize Review if Needed
113 |
114 |
115 | - Create a `.gito/config.toml` file at your repository root to override [default configuration](https://github.com/Nayjest/Gito/blob/main/gito/config.toml).
116 | - You can adjust prompts, filtering, report templates, issue criteria, and more.
117 |
118 | ## Troubleshooting
119 |
120 | - **Not seeing a PR comment?**
121 | 1. On the PR page, click the status icon near the latest commit hash.
122 | 2. Click **Details** to open the Actions run.
123 | 3. Review logs for any errors (e.g., API key missing, token issues).
124 |
125 | Example:
126 |
127 | 
128 |
129 | ---
130 |
131 | ## Additional Resources
132 |
133 | - More usage documentation: [README.md](../README.md)
134 | - For help or bug reports, [open an issue](https://github.com/Nayjest/Gito/issues)
135 |
136 | ---
137 |
138 | **Enjoy fast, LLM-powered pull request reviews and safer merges! 🚀**
--------------------------------------------------------------------------------
/tests/test_report_struct.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from gito.bootstrap import bootstrap
4 | from gito.report_struct import Report, Issue
5 |
6 |
7 | def test_report_plain_issues():
8 | bootstrap()
9 | raw_issues = {
10 | "file1.py": [
11 | {
12 | "title": "Bug 1",
13 | "details": "desc",
14 | "tags": ["bug"],
15 | "severity": 1,
16 | "confidence": 1,
17 | "affected_lines": [],
18 | "non-existent-field": "should be ignored",
19 | }
20 | ],
21 | "file2.py": [
22 | {
23 | "title": "Bug 2",
24 | "details": "desc",
25 | "tags": ["bug"],
26 | "severity": 2,
27 | "confidence": 1,
28 | "affected_lines": [{"start_line": 11}],
29 | }
30 | ],
31 | }
32 | # raw issues
33 | report = Report()
34 | assert report.total_issues == 0
35 | report.register_issue("file1.py", raw_issues["file1.py"][0])
36 | assert report.total_issues == 1
37 | report.register_issue("file2.py", raw_issues["file2.py"][0])
38 | assert report.total_issues == 2
39 | issues = report.plain_issues
40 | assert isinstance(issues, list)
41 | assert len(issues) == 2
42 | assert all(isinstance(i, Issue) for i in issues)
43 | assert report.total_issues == 2
44 | assert issues[0].id == 1
45 | assert issues[1].id == 2
46 | # test field transfer
47 | assert issues[0].file == "file1.py"
48 | assert issues[1].file == "file2.py"
49 | assert issues[1].affected_lines[0].start_line == 11
50 | assert issues[1].affected_lines[0].file == "file2.py"
51 |
52 | # loaded issues
53 | # Test preserve IDs
54 | report = Report(
55 | issues={
56 | "file1.py": [
57 | {
58 | "id": 9,
59 | "title": "Bug 1",
60 | "details": "desc",
61 | "tags": ["bug"],
62 | "file": "file1.py",
63 | "severity": 1,
64 | "confidence": 1,
65 | "affected_lines": [],
66 | "non-existent-field": "should be ignored",
67 | }
68 | ],
69 | "file2.py": [
70 | {
71 | "id": 8,
72 | "title": "Bug 2",
73 | "details": "desc",
74 | "tags": ["bug"],
75 | "file": "file2.py",
76 | "severity": 2,
77 | "confidence": 1,
78 | "affected_lines": [],
79 | }
80 | ],
81 | }
82 | )
83 | issues = report.plain_issues
84 | assert isinstance(issues, list)
85 | assert len(issues) == 2
86 | assert all(isinstance(i, Issue) for i in issues)
87 | assert report.total_issues == 2
88 | assert issues[0].id == 9
89 | assert issues[1].id == 8
90 |
91 |
92 | def test_report_save_load(tmp_path):
93 | bootstrap()
94 | data = {
95 | "issues": {
96 | "file.py": [
97 | {
98 | "id": 1,
99 | "title": "Bug",
100 | "details": "desc",
101 | "tags": ["bug"],
102 | "severity": 1,
103 | "confidence": 1,
104 | "affected_lines": [],
105 | }
106 | ]
107 | },
108 | "summary": "SUMMARY",
109 | "number_of_processed_files": 2,
110 | }
111 | file_name = tmp_path / "report.json"
112 | report = Report(**data)
113 | report.save(file_name)
114 | assert os.path.exists(file_name)
115 | # test it's valid JSON
116 | with open(file_name, "r") as f:
117 | loaded = json.load(f)
118 | assert loaded["summary"] == "SUMMARY"
119 | assert loaded["number_of_processed_files"] == 2
120 | # test reload with .load
121 | loaded_report = Report.load(file_name)
122 | assert loaded_report.summary == "SUMMARY"
123 | assert loaded_report.number_of_processed_files == 2
124 | assert loaded_report.total_issues == 1
125 | assert loaded_report.issues["file.py"][0].title == "Bug"
126 |
127 |
128 | def get_issue_with_affected_lines():
129 | return {
130 | "id": "x",
131 | "title": "T",
132 | "tags": [],
133 | "file": "X.py",
134 | "affected_lines": [
135 | {
136 | "start_line": 2,
137 | "end_line": 3,
138 | "proposal": "foo",
139 | "affected_code": "code",
140 | "file": "X.py",
141 | }
142 | ],
143 | }
144 |
145 |
146 | def test_issue_affected_lines_init():
147 | issue = Issue(**get_issue_with_affected_lines())
148 | line = issue.affected_lines[0]
149 | assert isinstance(line, Issue.AffectedCode)
150 | assert line.file == "X.py"
151 | assert line.proposal == "foo"
152 | assert line.start_line == 2
153 | assert line.syntax_hint == "python"
154 |
155 |
156 | def test_aff_lines_redundant_fields():
157 | data = get_issue_with_affected_lines()
158 | issue = Issue(**data)
159 | line = issue.affected_lines[0]
160 | assert isinstance(line, Issue.AffectedCode)
161 | assert line.file == "X.py"
162 | assert line.proposal == "foo"
163 |
164 |
165 | def test_from_raw_issue():
166 | data = get_issue_with_affected_lines()
167 | del data["id"]
168 | file = data.pop("file")
169 | data["affected_lines"][0].pop("file")
170 | issue = Issue.from_raw_issue(file, data, issue_id=5)
171 | assert issue.id == 5
172 | assert issue.file == "X.py"
173 | assert isinstance(issue.affected_lines[0], Issue.AffectedCode)
174 |
--------------------------------------------------------------------------------
/gito/commands/fix.py:
--------------------------------------------------------------------------------
1 | """
2 | Fix issues from code review report
3 | """
4 | import json
5 | import logging
6 | from pathlib import Path
7 | from typing import Optional
8 |
9 | import git
10 | import typer
11 | from microcore import ui
12 |
13 | from ..cli_base import app
14 | from ..constants import JSON_REPORT_FILE_NAME
15 | from ..report_struct import Report, Issue
16 |
17 |
18 | @app.command(
19 | help="Fix an issue from the code review report "
20 | "(latest code review results will be used by default)"
21 | )
22 | def fix(
23 | issue_number: int = typer.Argument(..., help="Issue number to fix"),
24 | report_path: Optional[str] = typer.Option(
25 | None,
26 | "--report",
27 | "-r",
28 | help="Path to the code review report (default: code-review-report.json)"
29 | ),
30 | dry_run: bool = typer.Option(
31 | False, "--dry-run", "-d", help="Only print changes without applying them"
32 | ),
33 | commit: bool = typer.Option(default=False, help="Commit changes after applying them"),
34 | push: bool = typer.Option(default=False, help="Push changes to the remote repository"),
35 | ) -> list[str]:
36 | """
37 | Applies the proposed change for the specified issue number from the code review report.
38 | """
39 | # Load the report
40 | report_path = report_path or JSON_REPORT_FILE_NAME
41 | try:
42 | report = Report.load(report_path)
43 | except (FileNotFoundError, json.JSONDecodeError) as e:
44 | logging.error(f"Failed to load report from {report_path}: {e}")
45 | raise typer.Exit(code=1)
46 |
47 | # Find the issue by number
48 | issue: Optional[Issue] = None
49 | for file_issues in report.issues.values():
50 | for i in file_issues:
51 | if i.id == issue_number:
52 | issue = i
53 | break
54 | if issue:
55 | break
56 |
57 | if not issue:
58 | logging.error(f"Issue #{issue_number} not found in the report")
59 | raise typer.Exit(code=1)
60 |
61 | if not issue.affected_lines:
62 | logging.error(f"Issue #{issue_number} has no affected lines specified")
63 | raise typer.Exit(code=1)
64 |
65 | if not any(affected_line.proposal for affected_line in issue.affected_lines):
66 | logging.error(f"Issue #{issue_number} has no proposal for fixing")
67 | raise typer.Exit(code=1)
68 |
69 | # Apply the fix
70 | logging.info(f"Fixing issue #{issue_number}: {ui.cyan(issue.title)}")
71 |
72 | for affected_line in issue.affected_lines:
73 | if not affected_line.proposal:
74 | continue
75 |
76 | file_path = Path(issue.file)
77 | if not file_path.exists():
78 | logging.error(f"File {file_path} not found")
79 | continue
80 |
81 | try:
82 | with open(file_path, "r", encoding="utf-8") as f:
83 | lines = f.readlines()
84 | except Exception as e:
85 | logging.error(f"Failed to read file {file_path}: {e}")
86 | continue
87 |
88 | # Check if line numbers are valid
89 | if affected_line.start_line < 1 or affected_line.end_line > len(lines):
90 | logging.error(
91 | f"Invalid line range: {affected_line.start_line}-{affected_line.end_line} "
92 | f"(file has {len(lines)} lines)"
93 | )
94 | continue
95 |
96 | # Get the affected line content for display
97 | affected_content = "".join(lines[affected_line.start_line - 1:affected_line.end_line])
98 | print(f"\nFile: {ui.blue(issue.file)}")
99 | print(f"Lines: {affected_line.start_line}-{affected_line.end_line}")
100 | print(f"Current content:\n{ui.red(affected_content)}")
101 | print(f"Proposed change:\n{ui.green(affected_line.proposal)}")
102 |
103 | if dry_run:
104 | print(f"{ui.yellow('Dry run')}: Changes not applied")
105 | continue
106 |
107 | # Apply the change
108 | proposal_lines = affected_line.proposal.splitlines(keepends=True)
109 | if not proposal_lines:
110 | proposal_lines = [""]
111 | elif not proposal_lines[-1].endswith(("\n", "\r")):
112 | # Ensure the last line has a newline if the original does
113 | if (
114 | affected_line.end_line < len(lines)
115 | and lines[affected_line.end_line - 1].endswith(("\n", "\r"))
116 | ):
117 | proposal_lines[-1] += "\n"
118 |
119 | lines[affected_line.start_line - 1:affected_line.end_line] = proposal_lines
120 |
121 | # Write changes back to the file
122 | try:
123 | with open(file_path, "w", encoding="utf-8") as f:
124 | f.writelines(lines)
125 | print(f"{ui.green('Success')}: Changes applied to {file_path}")
126 | except Exception as e:
127 | logging.error(f"Failed to write changes to {file_path}: {e}")
128 | raise typer.Exit(code=1)
129 |
130 | print(f"\n{ui.green('✓')} Issue #{issue_number} fixed successfully")
131 |
132 | changed_files = [file_path.as_posix()]
133 | if commit:
134 | commit_changes(
135 | changed_files,
136 | commit_message=f"[AI] Fix issue {issue_number}:{issue.title}",
137 | push=push
138 | )
139 | return changed_files
140 |
141 |
142 | def commit_changes(
143 | files: list[str],
144 | repo: git.Repo = None,
145 | commit_message: str = "fix by AI",
146 | push: bool = True
147 | ) -> None:
148 | if opened_repo := not repo:
149 | repo = git.Repo(".")
150 | for i in files:
151 | repo.index.add(i)
152 | repo.index.commit(commit_message)
153 | if push:
154 | origin = repo.remotes.origin
155 | origin.push()
156 | logging.info(f"Changes pushed to {origin.name}")
157 | else:
158 | logging.info("Changes committed but not pushed to remote")
159 | if opened_repo:
160 | repo.close()
161 |
--------------------------------------------------------------------------------
/gito/report_struct.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | from dataclasses import field, asdict, is_dataclass
4 | from datetime import datetime
5 | from enum import StrEnum
6 | from pathlib import Path
7 |
8 | import textwrap
9 | import microcore as mc
10 | from microcore.utils import file_link
11 | from colorama import Fore, Style, Back
12 | from pydantic.dataclasses import dataclass
13 |
14 | from .constants import JSON_REPORT_FILE_NAME, HTML_TEXT_ICON, HTML_CR_COMMENT_MARKER
15 | from .project_config import ProjectConfig
16 | from .utils import syntax_hint, block_wrap_lr, max_line_len, remove_html_comments, filter_kwargs
17 |
18 |
19 | @dataclass
20 | class RawIssue:
21 | @dataclass
22 | class AffectedCode:
23 | start_line: int = field()
24 | end_line: int | None = field(default=None)
25 | proposal: str | None = field(default="")
26 |
27 | title: str = field()
28 | details: str | None = field(default="")
29 | severity: int | None = field(default=None)
30 | confidence: int | None = field(default=None)
31 | tags: list[str] = field(default_factory=list)
32 | affected_lines: list[AffectedCode] = field(default_factory=list)
33 |
34 |
35 | @dataclass
36 | class Issue(RawIssue):
37 | @dataclass
38 | class AffectedCode(RawIssue.AffectedCode):
39 | file: str = field(default="")
40 | affected_code: str = field(default="")
41 |
42 | @property
43 | def syntax_hint(self) -> str:
44 | return syntax_hint(self.file)
45 |
46 | id: int | str = field(kw_only=True)
47 | file: str = field(default="")
48 | affected_lines: list[AffectedCode] = field(default_factory=list)
49 |
50 | @staticmethod
51 | def from_raw_issue(file: str, raw_issue: RawIssue | dict, issue_id: int | str) -> "Issue":
52 | if is_dataclass(raw_issue):
53 | raw_issue = asdict(raw_issue)
54 | params = filter_kwargs(Issue, raw_issue | {"file": file, "id": issue_id})
55 | for i, obj in enumerate(params.get("affected_lines") or []):
56 | d = obj if isinstance(obj, dict) else asdict(obj)
57 | params["affected_lines"][i] = Issue.AffectedCode(
58 | **filter_kwargs(Issue.AffectedCode, {"file": file} | d)
59 | )
60 | return Issue(**params)
61 |
62 | def github_code_link(self, github_env: dict) -> str:
63 | url = (
64 | f"https://github.com/{github_env['github_repo']}"
65 | f"/blob/{github_env['github_pr_sha_or_branch']}"
66 | f"/{self.file}"
67 | )
68 | if self.affected_lines:
69 | url += f"#L{self.affected_lines[0].start_line}"
70 | if self.affected_lines[0].end_line:
71 | url += f"-L{self.affected_lines[0].end_line}"
72 | return url
73 |
74 |
75 | @dataclass
76 | class ProcessingWarning:
77 | """
78 | Warning generated during code review of files
79 | """
80 | message: str = field()
81 | file: str | None = field(default=None)
82 |
83 |
84 | @dataclass
85 | class Report:
86 | class Format(StrEnum):
87 | MARKDOWN = "md"
88 | CLI = "cli"
89 |
90 | issues: dict[str, list[Issue]] = field(default_factory=dict)
91 | summary: str = field(default="")
92 | number_of_processed_files: int = field(default=0)
93 | total_issues: int = field(init=False)
94 | created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
95 | model: str = field(default_factory=lambda: mc.config().MODEL)
96 | pipeline_out: dict = field(default_factory=dict)
97 | processing_warnings: list[ProcessingWarning] = field(default_factory=list)
98 |
99 | @property
100 | def plain_issues(self):
101 | return [
102 | issue
103 | for file, issues in self.issues.items()
104 | for issue in issues
105 | ]
106 |
107 | def register_issues(self, issues: dict[str, list[RawIssue | dict]]):
108 | for file, file_issues in issues.items():
109 | for issue in file_issues:
110 | self.register_issue(file, issue)
111 |
112 | def register_issue(self, file: str, issue: RawIssue | dict):
113 | if file not in self.issues:
114 | self.issues[file] = []
115 | total = len(self.plain_issues)
116 | self.issues[file].append(Issue.from_raw_issue(file, issue, issue_id=total + 1))
117 | self.total_issues = total + 1
118 |
119 | def __post_init__(self):
120 | self.total_issues = len(self.plain_issues)
121 |
122 | def save(self, file_name: str = ""):
123 | file_name = file_name or JSON_REPORT_FILE_NAME
124 | with open(file_name, "w") as f:
125 | json.dump(asdict(self), f, indent=4)
126 | logging.info(f"Report saved to {mc.utils.file_link(file_name)}")
127 |
128 | @staticmethod
129 | def load(file_name: str | Path = ""):
130 | with open(file_name or JSON_REPORT_FILE_NAME, "r") as f:
131 | data = json.load(f)
132 | data.pop("total_issues", None)
133 | return Report(**data)
134 |
135 | def render(
136 | self,
137 | config: ProjectConfig = None,
138 | report_format: Format = Format.MARKDOWN,
139 | ) -> str:
140 | config = config or ProjectConfig.load()
141 | template = getattr(config, f"report_template_{report_format}")
142 | return mc.prompt(
143 | template,
144 | report=self,
145 | ui=mc.ui,
146 | Fore=Fore,
147 | Style=Style,
148 | Back=Back,
149 | file_link=file_link,
150 | textwrap=textwrap,
151 | block_wrap_lr=block_wrap_lr,
152 | max_line_len=max_line_len,
153 | HTML_TEXT_ICON=HTML_TEXT_ICON,
154 | HTML_CR_COMMENT_MARKER=HTML_CR_COMMENT_MARKER,
155 | remove_html_comments=remove_html_comments,
156 | **config.prompt_vars
157 | )
158 |
159 | def to_cli(self, report_format=Format.CLI):
160 | output = self.render(report_format=report_format)
161 | print("")
162 | print(output)
163 |
--------------------------------------------------------------------------------
/gito/commands/gh_react_to_comment.py:
--------------------------------------------------------------------------------
1 | """
2 | Fix issues from code review report
3 | """
4 |
5 | import logging
6 | import os
7 | import re
8 | from pathlib import Path
9 | from typing import Optional
10 | import zipfile
11 |
12 | import requests
13 | import typer
14 | from fastcore.basics import AttrDict
15 | from microcore import ui
16 | from ghapi.all import GhApi
17 | import git
18 |
19 | from ..cli_base import app
20 | from ..constants import JSON_REPORT_FILE_NAME, HTML_TEXT_ICON
21 | from ..core import answer
22 | from ..gh_api import post_gh_comment, resolve_gh_token
23 | from ..project_config import ProjectConfig
24 | from ..utils import extract_gh_owner_repo
25 | from .fix import fix
26 |
27 |
28 | def cleanup_comment_addressed_to_gito(text):
29 | if not text:
30 | return text
31 | patterns = [
32 | r'^\s*gito\b',
33 | r'^\s*ai\b',
34 | r'^\s*bot\b',
35 | r'^\s*@gito\b',
36 | r'^\s*@ai\b',
37 | r'^\s*@bot\b'
38 | ]
39 | result = text
40 | # Remove each pattern from the beginning
41 | for pattern in patterns:
42 | result = re.sub(pattern, '', result, flags=re.IGNORECASE)
43 |
44 | # Remove leading comma and spaces that may be left after prefix removal
45 | result = re.sub(r'^\s*,\s*', '', result)
46 |
47 | # Clean up extra whitespace
48 | result = re.sub(r'\s+', ' ', result).strip()
49 | return result
50 |
51 |
52 | @app.command(hidden=True)
53 | def react_to_comment(
54 | comment_id: int = typer.Argument(),
55 | gh_token: str = typer.Option(
56 | "",
57 | "--gh-token",
58 | "--token",
59 | "-t",
60 | "--github-token",
61 | help="GitHub token for authentication",
62 | ),
63 | dry_run: bool = typer.Option(
64 | False, "--dry-run", "-d", help="Only print changes without applying them"
65 | ),
66 | ):
67 | """
68 | Handles direct agent instructions from pull request comments.
69 |
70 | Note: Not for local usage. Designed for execution within GitHub Actions workflows.
71 |
72 | Fetches the PR comment by ID, parses agent directives, and executes the requested
73 | actions automatically to enable seamless code review workflow integration.
74 | """
75 | repo = git.Repo(".") # Current directory
76 | owner, repo_name = extract_gh_owner_repo(repo)
77 | logging.info(f"Using repository: {ui.yellow}{owner}/{repo_name}{ui.reset}")
78 | gh_token = resolve_gh_token(gh_token)
79 | api = GhApi(owner=owner, repo=repo_name, token=gh_token)
80 | comment = api.issues.get_comment(comment_id=comment_id)
81 | logging.info(
82 | f"Comment by {ui.yellow('@' + comment.user.login)}: "
83 | f"{ui.green(comment.body)}\n"
84 | f"url: {comment.html_url}"
85 | )
86 |
87 | cfg = ProjectConfig.load_for_repo(repo)
88 | if not any(
89 | trigger.lower() in comment.body.lower() for trigger in cfg.mention_triggers
90 | ):
91 | ui.error("No mention trigger found in comment, no reaction added.")
92 | return
93 | try:
94 | logging.info("Comment contains mention trigger, reacting with 'eyes'.")
95 | api.reactions.create_for_issue_comment(comment_id=comment_id, content="eyes")
96 | except Exception as e:
97 | logging.error("Error reacting to comment with emoji: %s", str(e))
98 | pr = int(comment.issue_url.split("/")[-1])
99 | print(f"Processing comment for PR #{pr}...")
100 |
101 | issue_ids = extract_fix_args(comment.body)
102 | if issue_ids:
103 | logging.info(f"Extracted issue IDs: {ui.yellow(str(issue_ids))}")
104 | out_folder = "artifact"
105 | download_latest_code_review_artifact(
106 | api, pr_number=pr, gh_token=gh_token, out_folder=out_folder
107 | )
108 | fix(
109 | issue_ids[0], # @todo: support multiple IDs
110 | report_path=Path(out_folder) / JSON_REPORT_FILE_NAME,
111 | dry_run=dry_run,
112 | commit=not dry_run,
113 | push=not dry_run,
114 | )
115 | logging.info("Fix applied successfully.")
116 | elif is_review_request(comment.body):
117 | ref = repo.active_branch.name
118 | logging.info(f"Triggering code-review workflow, ref='{ref}'")
119 | api.actions.create_workflow_dispatch(
120 | workflow_id="gito-code-review.yml",
121 | ref=ref,
122 | inputs={"pr_number": str(pr)},
123 | )
124 | else:
125 | if cfg.answer_github_comments:
126 | question = cleanup_comment_addressed_to_gito(comment.body)
127 | response = answer(question, repo=repo, pr=pr)
128 | post_gh_comment(
129 | gh_repository=f"{owner}/{repo_name}",
130 | pr_or_issue_number=pr,
131 | gh_token=gh_token,
132 | text=HTML_TEXT_ICON+response,
133 | )
134 | else:
135 | ui.error("Can't identify target command in the text.")
136 | return
137 |
138 |
139 | def last_code_review_run(api: GhApi, pr_number: int) -> AttrDict | None:
140 | pr = api.pulls.get(pr_number)
141 | sha = pr["head"]["sha"] # noqa
142 | branch = pr["head"]["ref"]
143 |
144 | runs = api.actions.list_workflow_runs_for_repo(branch=branch)["workflow_runs"]
145 | # Find the run for this SHA
146 | run = next(
147 | (
148 | r
149 | for r in runs # r['head_sha'] == sha and
150 | if (
151 | any(
152 | marker in r["path"].lower()
153 | for marker in ["code-review", "code_review", "cr"]
154 | )
155 | or "gito.yml" in r["name"].lower()
156 | )
157 | and r["status"] == "completed"
158 | ),
159 | None,
160 | )
161 | return run
162 |
163 |
164 | def download_latest_code_review_artifact(
165 | api: GhApi, pr_number: int, gh_token: str, out_folder: Optional[str] = "artifact"
166 | ) -> tuple[str, dict] | None:
167 | run = last_code_review_run(api, pr_number)
168 | if not run:
169 | raise Exception("No workflow run found for this PR/SHA")
170 |
171 | artifacts = api.actions.list_workflow_run_artifacts(run["id"])["artifacts"]
172 | if not artifacts:
173 | raise Exception("No artifacts found for this workflow run")
174 |
175 | latest_artifact = artifacts[0]
176 | url = latest_artifact["archive_download_url"]
177 | print(f"Artifact: {latest_artifact['name']}, Download URL: {url}")
178 | headers = {"Authorization": f"token {gh_token}"} if gh_token else {}
179 | zip_path = "artifact.zip"
180 | try:
181 | with requests.get(url, headers=headers, stream=True) as r:
182 | r.raise_for_status()
183 | with open(zip_path, "wb") as f:
184 | for chunk in r.iter_content(chunk_size=8192):
185 | f.write(chunk)
186 |
187 | # Unpack to ./artifact
188 | os.makedirs("artifact", exist_ok=True)
189 | with zipfile.ZipFile(zip_path, "r") as zip_ref:
190 | zip_ref.extractall("artifact")
191 | finally:
192 | if os.path.exists(zip_path):
193 | os.remove(zip_path)
194 |
195 | print("Artifact unpacked to ./artifact")
196 |
197 |
198 | def extract_fix_args(text: str) -> list[int]:
199 | pattern1 = r"fix\s+(?:issues?)?(?:\s+)?#?(\d+(?:\s*,\s*#?\d+)*)"
200 | match = re.search(pattern1, text)
201 | if match:
202 | numbers_str = match.group(1)
203 | numbers = re.findall(r"\d+", numbers_str)
204 | issue_numbers = [int(num) for num in numbers]
205 | return issue_numbers
206 | return []
207 |
208 |
209 | def is_review_request(text: str) -> bool:
210 | text = text.lower().strip()
211 | trigger_words = ['review', 'run', 'code-review']
212 | if any(f"/{word}" in text for word in trigger_words):
213 | return True
214 | parts = text.split()
215 | if len(parts) == 2 and parts[1] in trigger_words:
216 | return True
217 | return False
218 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | **Gito** is an open-source **AI code reviewer** that works with any language model provider.
11 | It detects issues in GitHub pull requests or local codebase changes—instantly, reliably, and without vendor lock-in.
12 |
13 | Get consistent, thorough code reviews in seconds—no waiting for human availability.
14 |
15 | ## 📋 Table of Contents
16 | - [Why Gito?](#-why-gito)
17 | - [Perfect For](#-perfect-for)
18 | - [Quickstart](#-quickstart)
19 | - [1. Review Pull Requests via GitHub Actions](#1-review-pull-requests-via-github-actions)
20 | - [2. Running Code Analysis Locally](#2-running-code-analysis-locally)
21 | - [Configuration](#-configuration)
22 | - [Documentation](#-documentation)
23 | - [Development Setup](#-development-setup)
24 | - [Contributing](#-contributing)
25 | - [License](#-license)
26 |
27 | ## ✨ Why Gito?
28 |
29 | - [⚡] **Lightning Fast:** Get detailed code reviews in seconds, not days — powered by parallelized LLM processing
30 | - [🔧] **Vendor Agnostic:** Works with any language model provider (OpenAI, Anthropic, Google, local models, etc.)
31 | - [🌐] **Universal:** Supports all major programming languages and frameworks
32 | - [🔍] **Comprehensive Analysis:** Detect issues across security, performance, maintainability, best practices, and much more
33 | - [📈] **Consistent Quality:** Never tired, never biased—consistent review quality every time
34 | - [🚀] **Easy Integration:** Automatically reviews pull requests via GitHub Actions and posts results as PR comments
35 | - [🎛️] **Infinitely Flexible:** Adapt to any project's standards—configure review rules, severity levels, and focus areas, build custom workflows
36 |
37 | ## 🎯 Perfect For
38 |
39 | - Solo developers who want expert-level code review without the wait
40 | - Teams looking to catch issues before human review
41 | - Open source projects maintaining high code quality at scale
42 | - CI/CD pipelines requiring automated quality gates
43 |
44 | ✨ See [code review in action](https://github.com/Nayjest/Gito/pull/99) ✨
45 |
46 | ## 🚀 Quickstart
47 |
48 | ### 1. Review Pull Requests via GitHub Actions
49 |
50 | Create a `.github/workflows/gito-code-review.yml` file:
51 |
52 | ```yaml
53 | name: "Gito: AI Code Review"
54 | on:
55 | pull_request:
56 | types: [opened, synchronize, reopened]
57 | workflow_dispatch:
58 | inputs:
59 | pr_number:
60 | description: "Pull Request number"
61 | required: true
62 | jobs:
63 | review:
64 | runs-on: ubuntu-latest
65 | permissions: { contents: read, pull-requests: write } # 'write' for leaving the summary comment
66 | steps:
67 | - uses: actions/checkout@v4
68 | with: { fetch-depth: 0 }
69 | - name: Set up Python
70 | uses: actions/setup-python@v5
71 | with: { python-version: "3.13" }
72 | - name: Install AI Code Review tool
73 | run: pip install gito.bot~=3.4
74 | - name: Run AI code analysis
75 | env:
76 | LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
77 | LLM_API_TYPE: openai
78 | MODEL: "gpt-4.1"
79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
80 | PR_NUMBER_FROM_WORKFLOW_DISPATCH: ${{ github.event.inputs.pr_number }}
81 | run: |
82 | gito --verbose review
83 | gito github-comment --token ${{ secrets.GITHUB_TOKEN }}
84 | - uses: actions/upload-artifact@v4
85 | with:
86 | name: ai-code-review-results
87 | path: |
88 | code-review-report.md
89 | code-review-report.json
90 | ```
91 |
92 | > ⚠️ Make sure to add `LLM_API_KEY` to your repository's GitHub secrets.
93 |
94 | 💪 Done!
95 | PRs to your repository will now receive AI code reviews automatically. ✨
96 | See [GitHub Setup Guide](https://github.com/Nayjest/Gito/blob/main/documentation/github_setup.md) for more details.
97 |
98 | ### 2. Running Code Analysis Locally
99 |
100 | #### Initial Local Setup
101 |
102 | **Prerequisites:** [Python](https://www.python.org/downloads/) 3.11 / 3.12 / 3.13
103 |
104 | **Step 1:** Install [gito.bot](https://github.com/Nayjest/Gito) using [pip](https://en.wikipedia.org/wiki/Pip_(package_manager)).
105 | ```bash
106 | pip install gito.bot
107 | ```
108 |
109 | > **Troubleshooting:**
110 | > pip may also be available via cli as `pip3` depending on your Python installation.
111 |
112 | **Step 2:** Perform initial setup
113 |
114 | The following command will perform one-time setup using an interactive wizard.
115 | You will be prompted to enter LLM configuration details (API type, API key, etc).
116 | Configuration will be saved to `~/.gito/.env`.
117 |
118 | ```bash
119 | gito setup
120 | ```
121 |
122 | > **Troubleshooting:**
123 | > On some systems, `gito` command may not become available immediately after installation.
124 | > Try restarting your terminal or running `python -m gito` instead.
125 |
126 |
127 | #### Perform your first AI code review locally
128 |
129 | **Step 1:** Navigate to your repository root directory.
130 | **Step 2:** Switch to the branch you want to review.
131 | **Step 3:** Run following command
132 | ```bash
133 | gito review
134 | ```
135 |
136 | > **Note:** This will analyze the current branch against the repository main branch by default.
137 | > Files that are not staged for commit will be ignored.
138 | > See `gito --help` for more options.
139 |
140 | **Reviewing remote repository**
141 |
142 | ```bash
143 | gito remote git@github.com:owner/repo.git ..
144 | ```
145 | Use interactive help for details:
146 | ```bash
147 | gito remote --help
148 | ```
149 |
150 | ## 🔧 Configuration
151 |
152 | Change behavior via `.gito/config.toml`:
153 |
154 | - Prompt templates, filtering and post-processing using Python code snippets
155 | - Tagging, severity, and confidence settings
156 | - Custom AI awards for developer brilliance
157 | - Output customization
158 |
159 | You can override the default config by placing `.gito/config.toml` in your repo root.
160 |
161 |
162 | See default configuration [here](https://github.com/Nayjest/Gito/blob/main/gito/config.toml).
163 |
164 | More details can be found in [📖 Configuration Cookbook](https://github.com/Nayjest/Gito/blob/main/documentation/config_cookbook.md)
165 |
166 | ## 📚 Documentation
167 |
168 | - [Command Line Reference](https://github.com/Nayjest/Gito/blob/main/documentation/command_line_reference.md)
169 | - [Configuration Cookbook](https://github.com/Nayjest/Gito/blob/main/documentation/config_cookbook.md)
170 | - [GitHub Setup Guide](https://github.com/Nayjest/Gito/blob/main/documentation/github_setup.md)
171 | - [Troubleshooting](https://github.com/Nayjest/Gito/blob/main/documentation/troubleshooting.md)
172 |
173 |
174 | ## 💻 Development Setup
175 |
176 | Install dependencies:
177 |
178 | ```bash
179 | make install
180 | ```
181 |
182 | Format code and check style:
183 |
184 | ```bash
185 | make black
186 | make cs
187 | ```
188 |
189 | Run tests:
190 |
191 | ```bash
192 | pytest
193 | ```
194 |
195 | ## 🤝 Contributing
196 |
197 | **Looking for a specific feature or having trouble?**
198 | Contributions are welcome! ❤️
199 | See [CONTRIBUTING.md](https://github.com/Nayjest/Gito/blob/main/CONTRIBUTING.md) for details.
200 |
201 | ## 📝 License
202 |
203 | Licensed under the [MIT License](https://github.com/Nayjest/Gito/blob/main/LICENSE).
204 |
205 | © 2025–2026 [Vitalii Stepanenko](mailto:mail@vitaliy.in)
206 |
--------------------------------------------------------------------------------
/gito/utils.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import re
3 | import sys
4 | import os
5 | from dataclasses import fields, is_dataclass
6 | from pathlib import Path
7 | import importlib.metadata
8 | from typing import Optional
9 |
10 | import typer
11 | import git
12 | from git import Repo
13 | from .env import Env
14 |
15 | _EXT_TO_HINT: dict[str, str] = {
16 | # scripting & languages
17 | ".py": "python",
18 | ".js": "javascript",
19 | ".ts": "typescript",
20 | ".java": "java",
21 | ".c": "c",
22 | ".cpp": "cpp",
23 | ".cc": "cpp",
24 | ".cxx": "cpp",
25 | ".h": "cpp",
26 | ".hpp": "cpp",
27 | ".cs": "csharp",
28 | ".rb": "ruby",
29 | ".go": "go",
30 | ".rs": "rust",
31 | ".swift": "swift",
32 | ".kt": "kotlin",
33 | ".scala": "scala",
34 | ".dart": "dart",
35 | ".php": "php",
36 | ".pl": "perl",
37 | ".pm": "perl",
38 | ".lua": "lua",
39 | # web & markup
40 | ".html": "html",
41 | ".htm": "html",
42 | ".css": "css",
43 | ".scss": "scss",
44 | ".less": "less",
45 | ".json": "json",
46 | ".xml": "xml",
47 | ".yaml": "yaml",
48 | ".yml": "yaml",
49 | ".toml": "toml",
50 | ".ini": "ini",
51 | ".csv": "csv",
52 | ".md": "markdown",
53 | ".rst": "rest",
54 | # shell & config
55 | ".sh": "bash",
56 | ".bash": "bash",
57 | ".zsh": "bash",
58 | ".fish": "bash",
59 | ".ps1": "powershell",
60 | ".dockerfile": "dockerfile",
61 | # build & CI
62 | ".makefile": "makefile",
63 | ".mk": "makefile",
64 | "CMakeLists.txt": "cmake",
65 | "Dockerfile": "dockerfile",
66 | ".gradle": "groovy",
67 | ".travis.yml": "yaml",
68 | # data & queries
69 | ".sql": "sql",
70 | ".graphql": "graphql",
71 | ".proto": "protobuf",
72 | ".yara": "yara",
73 | }
74 |
75 |
76 | def syntax_hint(file_path: str | Path) -> str:
77 | """
78 | Returns a syntax highlighting hint based on the file's extension or name.
79 |
80 | This can be used to annotate code blocks for rendering with syntax highlighting,
81 | e.g., using Markdown-style code blocks: ```\n\n```.
82 |
83 | Args:
84 | file_path (str | Path): Path to the file.
85 |
86 | Returns:
87 | str: A syntax identifier suitable for code highlighting (e.g., 'python', 'json').
88 | """
89 | p = Path(file_path)
90 | ext = p.suffix.lower()
91 | if not ext:
92 | name = p.name.lower()
93 | if name == "dockerfile":
94 | return "dockerfile"
95 | return ""
96 | return _EXT_TO_HINT.get(ext, ext.lstrip("."))
97 |
98 |
99 | def is_running_in_github_action():
100 | return os.getenv("GITHUB_ACTIONS") == "true"
101 |
102 |
103 | def no_subcommand(app: typer.Typer) -> bool:
104 | """
105 | Checks if the current script is being invoked as a command in a target Typer application.
106 | """
107 | return not (
108 | (first_arg := next((a for a in sys.argv[1:] if not a.startswith('-')), None))
109 | and first_arg in (
110 | cmd.name or cmd.callback.__name__.replace('_', '-')
111 | for cmd in app.registered_commands
112 | )
113 | or '--help' in sys.argv
114 | )
115 |
116 |
117 | def parse_refs_pair(refs: str) -> tuple[str | None, str | None]:
118 | SEPARATOR = '..'
119 | if not refs:
120 | return None, None
121 | if SEPARATOR not in refs:
122 | return refs, None
123 | what, against = refs.split(SEPARATOR, 1)
124 | return what or None, against or None
125 |
126 |
127 | def max_line_len(text: str) -> int:
128 | return max((len(line) for line in text.splitlines()), default=0)
129 |
130 |
131 | def block_wrap_lr(
132 | text: str,
133 | left: str = "",
134 | right: str = "",
135 | max_rwrap: int = 60,
136 | min_wrap: int = 0,
137 | ) -> str:
138 | ml = max(max_line_len(text), min_wrap)
139 | lines = text.splitlines()
140 | wrapped_lines = []
141 | for line in lines:
142 | ln = left+line
143 | if ml <= max_rwrap:
144 | ln += ' ' * (ml - len(line)) + right
145 | wrapped_lines.append(ln)
146 | return "\n".join(wrapped_lines)
147 |
148 |
149 | def extract_gh_owner_repo(repo: git.Repo) -> tuple[str, str]:
150 | """
151 | Extracts the GitHub owner and repository name.
152 |
153 | Returns:
154 | tuple[str, str]: A tuple containing the owner and repository name.
155 | """
156 | remote_url = repo.remotes.origin.url
157 | if remote_url.startswith('git@github.com:'):
158 | # SSH format: git@github.com:owner/repo.git
159 | repo_path = remote_url.split(':')[1].replace('.git', '')
160 | elif remote_url.startswith('https://github.com/'):
161 | # HTTPS format: https://github.com/owner/repo.git
162 | repo_path = remote_url.replace('https://github.com/', '').replace('.git', '')
163 | else:
164 | raise ValueError("Unsupported remote URL format")
165 | owner, repo_name = repo_path.split('/')
166 | return owner, repo_name
167 |
168 |
169 | def detect_github_env() -> dict:
170 | """
171 | Try to detect GitHub repository/PR info from environment variables (for GitHub Actions).
172 | Returns a dict with github_repo, github_pr_sha, github_pr_number, github_ref, etc.
173 | """
174 | repo = os.environ.get("GITHUB_REPOSITORY", "")
175 | pr_sha = os.environ.get("GITHUB_SHA", "")
176 | pr_number = os.environ.get("GITHUB_REF", "")
177 | branch = ""
178 | ref = os.environ.get("GITHUB_REF", "")
179 | # Try to resolve PR head SHA if available.
180 | # On PRs, GITHUB_HEAD_REF/BASE_REF contain branch names.
181 | if "GITHUB_HEAD_REF" in os.environ:
182 | branch = os.environ["GITHUB_HEAD_REF"]
183 | elif ref.startswith("refs/heads/"):
184 | branch = ref[len("refs/heads/"):]
185 | elif ref.startswith("refs/pull/"):
186 | # for pull_request events
187 | branch = ref
188 |
189 | d = {
190 | "github_repo": repo,
191 | "github_pr_sha": pr_sha,
192 | "github_pr_number": pr_number,
193 | "github_branch": branch,
194 | "github_ref": ref,
195 | }
196 | # Fallback for local usage: try to get from git
197 | if not repo or repo == "octocat/Hello-World":
198 | git_repo = None
199 | try:
200 | git_repo = Repo(Env.working_folder, search_parent_directories=True)
201 | origin = git_repo.remotes.origin.url
202 | # e.g. git@github.com:Nayjest/ai-code-review.git -> Nayjest/ai-code-review
203 | match = re.search(r"[:/]([\w\-]+)/([\w\-\.]+?)(\.git)?$", origin)
204 | if match:
205 | d["github_repo"] = f"{match.group(1)}/{match.group(2)}"
206 | d["github_pr_sha"] = git_repo.head.commit.hexsha
207 | d["github_branch"] = (
208 | git_repo.active_branch.name if hasattr(git_repo, "active_branch") else ""
209 | )
210 | except Exception:
211 | pass
212 | finally:
213 | if git_repo:
214 | try:
215 | git_repo.close()
216 | except Exception:
217 | pass
218 | # If branch is not a commit SHA, prefer branch for links
219 | if d["github_branch"]:
220 | d["github_pr_sha_or_branch"] = d["github_branch"]
221 | elif d["github_pr_sha"]:
222 | d["github_pr_sha_or_branch"] = d["github_pr_sha"]
223 | else:
224 | d["github_pr_sha_or_branch"] = "main"
225 | return d
226 |
227 |
228 | def make_streaming_function(handler: Optional[callable] = None) -> callable:
229 | def stream(text):
230 | if handler:
231 | text = handler(text)
232 | print(text, end='', flush=True)
233 | return stream
234 |
235 |
236 | def version() -> str:
237 | return importlib.metadata.version("gito.bot")
238 |
239 |
240 | def remove_html_comments(text):
241 | """
242 | Removes all HTML comments () from the input text.
243 | """
244 | return re.sub(r'\s*', '', text, flags=re.DOTALL)
245 |
246 |
247 | def filter_kwargs(cls, kwargs, log_warnings=True):
248 | """
249 | Filters the keyword arguments to only include those that are fields of the given dataclass.
250 | Args:
251 | cls: The dataclass type to filter against.
252 | kwargs: A dictionary of keyword arguments.
253 | log_warnings: If True, logs warnings for fields not in the dataclass.
254 | Returns:
255 | A dictionary containing only the fields that are defined in the dataclass.
256 | """
257 | if not is_dataclass(cls):
258 | raise TypeError(f"{cls.__name__} is not a dataclass or pydantic dataclass")
259 |
260 | cls_fields = {f.name for f in fields(cls)}
261 | filtered = {}
262 | for k, v in kwargs.items():
263 | if k in cls_fields:
264 | filtered[k] = v
265 | else:
266 | if log_warnings:
267 | logging.warning(
268 | f"Warning: field '{k}' not in {cls.__name__}, dropping."
269 | )
270 | return filtered
271 |
--------------------------------------------------------------------------------
/gito/cli.py:
--------------------------------------------------------------------------------
1 | import os
2 | import asyncio
3 | import logging
4 | import sys
5 | import textwrap
6 |
7 | import microcore as mc
8 | import typer
9 | from git import Repo
10 | from gito.constants import REFS_VALUE_ALL
11 |
12 | from .core import review, get_diff, filter_diff, answer
13 | from .cli_base import (
14 | app,
15 | args_to_target,
16 | arg_refs,
17 | arg_what,
18 | arg_filters,
19 | arg_out,
20 | arg_against,
21 | get_repo_context,
22 | )
23 | from .report_struct import Report
24 | from .constants import HOME_ENV_PATH, GITHUB_MD_REPORT_FILE_NAME
25 | from .bootstrap import bootstrap
26 | from .utils import no_subcommand, extract_gh_owner_repo, remove_html_comments
27 | from .gh_api import resolve_gh_token
28 | from .project_config import ProjectConfig
29 |
30 | # Import fix command to register it
31 | from .commands import fix, gh_react_to_comment, repl, deploy, version # noqa
32 | from .commands.gh_post_review_comment import post_github_cr_comment
33 | from .commands.linear_comment import linear_comment
34 |
35 | app_no_subcommand = typer.Typer(pretty_exceptions_show_locals=False)
36 |
37 |
38 | def main():
39 | if sys.platform == "win32":
40 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
41 | # Help subcommand alias: if 'help' appears as first non-option arg, replace it with '--help'
42 | if len(sys.argv) > 1 and sys.argv[1] == "help":
43 | sys.argv = [sys.argv[0]] + sys.argv[2:] + ["--help"]
44 |
45 | if no_subcommand(app):
46 | bootstrap()
47 | app_no_subcommand()
48 | else:
49 | app()
50 |
51 |
52 | @app.callback(
53 | invoke_without_command=True,
54 | help="\bGito is an open-source AI code reviewer that works with any language model provider."
55 | "\nIt detects issues in GitHub pull requests or local codebase changes"
56 | "—instantly, reliably, and without vendor lock-in."
57 | )
58 | def cli(
59 | ctx: typer.Context,
60 | verbosity: int = typer.Option(
61 | None,
62 | '--verbosity', '-v',
63 | show_default=False,
64 | help="\b"
65 | "Set verbosity level. Supported values: 0-3. Default: 1."
66 | "\n [ 0 ]: no additional output, "
67 | "\n [ 1 ]: normal mode, shows warnings, shortened LLM requests and logging.INFO"
68 | "\n [ 2 ]: verbose mode, show full LLM requests"
69 | "\n [ 3 ]: very verbose mode, also debug information"
70 | ),
71 | verbose: bool = typer.Option(
72 | default=None,
73 | help="\b"
74 | "--verbose is equivalent to -v2, "
75 | "\n--no-verbose is equivalent to -v0. "
76 | "\n(!) Can't be used together with -v or --verbosity."
77 | ),
78 | ):
79 | if verbose is not None and verbosity is not None:
80 | raise typer.BadParameter(
81 | "Please specify either --verbose or --verbosity, not both."
82 | )
83 | if verbose is not None:
84 | verbosity = 2 if verbose else 0
85 | if verbosity is None:
86 | verbosity = 1
87 |
88 | if ctx.invoked_subcommand != "setup":
89 | bootstrap(verbosity)
90 |
91 |
92 | @app_no_subcommand.command(name="review", help="Perform code review")
93 | @app.command(name="review", help="Perform a code review of the target codebase changes.")
94 | @app.command(name="run", hidden=True)
95 | def cmd_review(
96 | refs: str = arg_refs(),
97 | what: str = arg_what(),
98 | against: str = arg_against(),
99 | filters: str = arg_filters(),
100 | merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
101 | url: str = typer.Option("", "--url", help="Git repository URL"),
102 | path: str = typer.Option("", "--path", help="Git repository path"),
103 | post_comment: bool = typer.Option(default=False, help="Post review comment to GitHub"),
104 | pr: int = typer.Option(
105 | default=None,
106 | help=textwrap.dedent("""\n
107 | GitHub Pull Request number to post the comment to
108 | (for local usage together with --post-comment,
109 | in the github actions PR is resolved from the environment)
110 | """)
111 | ),
112 | out: str = arg_out(),
113 | all: bool = typer.Option(default=False, help="Review all codebase"),
114 | ):
115 | if all:
116 | if refs and refs != REFS_VALUE_ALL:
117 | raise typer.BadParameter(
118 | "The --all option overrides the refs argument. "
119 | "Please remove the refs argument if you want to review all codebase."
120 | )
121 | refs = REFS_VALUE_ALL
122 | merge_base = False
123 | _what, _against = args_to_target(refs, what, against)
124 | pr = pr or os.getenv("PR_NUMBER_FROM_WORKFLOW_DISPATCH")
125 | with get_repo_context(url, _what) as (repo, out_folder):
126 | asyncio.run(review(
127 | repo=repo,
128 | what=_what,
129 | against=_against,
130 | filters=filters,
131 | use_merge_base=merge_base,
132 | out_folder=out or out_folder,
133 | pr=pr,
134 | ))
135 | if post_comment:
136 | try:
137 | owner, repo_name = extract_gh_owner_repo(repo)
138 | except ValueError as e:
139 | logging.error(
140 | "Error posting comment:\n"
141 | "Could not extract GitHub owner and repository name from the local repository."
142 | )
143 | raise typer.Exit(code=1) from e
144 | post_github_cr_comment(
145 | md_report_file=os.path.join(out or out_folder, GITHUB_MD_REPORT_FILE_NAME),
146 | pr=pr,
147 | gh_repo=f"{owner}/{repo_name}",
148 | token=resolve_gh_token()
149 | )
150 |
151 |
152 | @app.command(name="ask", help="Answer questions about the target codebase changes.")
153 | @app.command(name="answer", hidden=True)
154 | @app.command(name="talk", hidden=True)
155 | def cmd_answer(
156 | question: str = typer.Argument(help="Question to ask about the codebase changes"),
157 | refs: str = arg_refs(),
158 | what: str = arg_what(),
159 | against: str = arg_against(),
160 | filters: str = arg_filters(),
161 | merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
162 | use_pipeline: bool = typer.Option(default=True),
163 | post_to: str = typer.Option(
164 | help="Post answer to ... Supported values: linear",
165 | default=None,
166 | show_default=False
167 | ),
168 | pr: int = typer.Option(
169 | default=None,
170 | help="GitHub Pull Request number"
171 | ),
172 | aux_files: list[str] = typer.Option(
173 | default=None,
174 | help="Auxiliary files that might be helpful"
175 | ),
176 | save_to: str = typer.Option(
177 | help="Save answer to file",
178 | default=None,
179 | show_default=False
180 | )
181 | ):
182 | _what, _against = args_to_target(refs, what, against)
183 | pr = pr or os.getenv("PR_NUMBER_FROM_WORKFLOW_DISPATCH")
184 | if str(question).startswith("tpl:"):
185 | prompt_file = str(question)[4:]
186 | question = ""
187 | else:
188 | prompt_file = None
189 | out = answer(
190 | question=question,
191 | what=_what,
192 | against=_against,
193 | filters=filters,
194 | use_merge_base=merge_base,
195 | prompt_file=prompt_file,
196 | use_pipeline=use_pipeline,
197 | pr=pr,
198 | aux_files=aux_files,
199 | )
200 | if post_to == 'linear':
201 | logging.info("Posting answer to Linear...")
202 | linear_comment(remove_html_comments(out))
203 | if save_to:
204 | with open(save_to, "w", encoding="utf-8") as f:
205 | f.write(out)
206 | logging.info(f"Answer saved to {mc.utils.file_link(save_to)}")
207 |
208 | return out
209 |
210 |
211 | @app.command(help="Configure LLM for local usage interactively.")
212 | def setup():
213 | mc.interactive_setup(HOME_ENV_PATH)
214 |
215 |
216 | @app.command(name="report", help="Render and display code review report.")
217 | @app.command(name="render", hidden=True)
218 | def render(
219 | format: str = typer.Argument(default=Report.Format.CLI),
220 | source: str = typer.Option(
221 | "",
222 | "--src",
223 | "--source",
224 | help="Source file (json) to load the report from"
225 | )
226 | ):
227 | Report.load(file_name=source).to_cli(report_format=format)
228 |
229 |
230 | @app.command(
231 | help="\bList files in the changeset. "
232 | "\nMight be useful to check what will be reviewed if run `gito review` "
233 | "with current CLI arguments and options."
234 | )
235 | def files(
236 | refs: str = arg_refs(),
237 | what: str = arg_what(),
238 | against: str = arg_against(),
239 | filters: str = arg_filters(),
240 | merge_base: bool = typer.Option(default=True, help="Use merge base for comparison"),
241 | diff: bool = typer.Option(default=False, help="Show diff content")
242 | ):
243 | _what, _against = args_to_target(refs, what, against)
244 | repo = Repo(".")
245 | try:
246 | patch_set = get_diff(repo=repo, what=_what, against=_against, use_merge_base=merge_base)
247 | patch_set = filter_diff(patch_set, filters)
248 | cfg = ProjectConfig.load_for_repo(repo)
249 | if cfg.exclude_files:
250 | patch_set = filter_diff(patch_set, cfg.exclude_files, exclude=True)
251 | print(
252 | f"Changed files: "
253 | f"{mc.ui.green(_what or 'INDEX')} vs "
254 | f"{mc.ui.yellow(_against or repo.remotes.origin.refs.HEAD.reference.name)}"
255 | f"{' filtered by ' + mc.ui.cyan(filters) if filters else ''} --> "
256 | f"{mc.ui.cyan(len(patch_set or []))} file(s)."
257 | )
258 |
259 | for patch in patch_set:
260 | if patch.is_added_file:
261 | color = mc.ui.green
262 | elif patch.is_removed_file:
263 | color = mc.ui.red
264 | else:
265 | color = mc.ui.blue
266 | print(f"- {color(patch.path)}")
267 | if diff:
268 | print(mc.ui.gray(textwrap.indent(str(patch), " ")))
269 | finally:
270 | repo.close()
271 |
--------------------------------------------------------------------------------
/documentation/command_line_reference.md:
--------------------------------------------------------------------------------
1 | # Gito CLI Reference
2 |
3 | Gito is an open-source AI code reviewer that works with any language model provider.
4 | It detects issues in GitHub pull requests or local codebase changes—instantly, reliably, and without vendor lock-in.
5 |
6 | **Usage**:
7 |
8 | ```console
9 | $ gito [OPTIONS] COMMAND [ARGS]...
10 | ```
11 |
12 | **Options**:
13 |
14 | * `-v, --verbosity INTEGER`: Set verbosity level. Supported values: 0-3. Default: 1.
15 | [ 0 ]: no additional output,
16 | [ 1 ]: normal mode, shows warnings, shortened LLM requests and logging.INFO
17 | [ 2 ]: verbose mode, show full LLM requests
18 | [ 3 ]: very verbose mode, also debug information
19 | * `--verbose / --no-verbose`: --verbose is equivalent to -v2,
20 | --no-verbose is equivalent to -v0.
21 | (!) Can't be used together with -v or --verbosity.
22 | * `--help`: Show this message and exit.
23 |
24 | **Commands**:
25 |
26 | * `fix`: Fix an issue from the code review report...
27 | * `react-to-comment`: Handles direct agent instructions from...
28 | * `repl`: Python REPL with core functionality loaded...
29 | * `init`
30 | * `deploy`: Create and configure Gito GitHub Actions...
31 | * `version`: Show Gito version.
32 | * `github-comment`: Leave a GitHub PR comment with the review.
33 | * `linear-comment`: Post a comment with specified text to the...
34 | * `run`
35 | * `review`: Perform a code review of the target...
36 | * `talk`
37 | * `answer`
38 | * `ask`: Answer questions about the target codebase...
39 | * `setup`: Configure LLM for local usage interactively.
40 | * `render`
41 | * `report`: Render and display code review report.
42 | * `files`: List files in the changeset.
43 |
44 | ## `gito fix`
45 |
46 | Fix an issue from the code review report (latest code review results will be used by default)
47 |
48 | **Usage**:
49 |
50 | ```console
51 | $ gito fix [OPTIONS] ISSUE_NUMBER
52 | ```
53 |
54 | **Arguments**:
55 |
56 | * `ISSUE_NUMBER`: Issue number to fix [required]
57 |
58 | **Options**:
59 |
60 | * `-r, --report TEXT`: Path to the code review report (default: code-review-report.json)
61 | * `-d, --dry-run`: Only print changes without applying them
62 | * `--commit / --no-commit`: Commit changes after applying them [default: no-commit]
63 | * `--push / --no-push`: Push changes to the remote repository [default: no-push]
64 | * `--help`: Show this message and exit.
65 |
66 | ## `gito react-to-comment`
67 |
68 | Handles direct agent instructions from pull request comments.
69 |
70 | Note: Not for local usage. Designed for execution within GitHub Actions workflows.
71 |
72 | Fetches the PR comment by ID, parses agent directives, and executes the requested
73 | actions automatically to enable seamless code review workflow integration.
74 |
75 | **Usage**:
76 |
77 | ```console
78 | $ gito react-to-comment [OPTIONS] COMMENT_ID
79 | ```
80 |
81 | **Arguments**:
82 |
83 | * `COMMENT_ID`: [required]
84 |
85 | **Options**:
86 |
87 | * `-t, --gh-token, --token, --github-token TEXT`: GitHub token for authentication
88 | * `-d, --dry-run`: Only print changes without applying them
89 | * `--help`: Show this message and exit.
90 |
91 | ## `gito repl`
92 |
93 | Python REPL with core functionality loaded for quick testing/debugging and exploration.
94 |
95 | **Usage**:
96 |
97 | ```console
98 | $ gito repl [OPTIONS]
99 | ```
100 |
101 | **Options**:
102 |
103 | * `--help`: Show this message and exit.
104 |
105 | ## `gito init`
106 |
107 | **Usage**:
108 |
109 | ```console
110 | $ gito init [OPTIONS]
111 | ```
112 |
113 | **Options**:
114 |
115 | * `--api-type [open_ai|azure|anyscale|deep_infra|anthropic|google_vertex_ai|google_ai_studio|function|transformers|none]`
116 | * `--commit / --no-commit`
117 | * `--rewrite / --no-rewrite`: [default: no-rewrite]
118 | * `--to-branch TEXT`: Branch name for new PR containing with Gito workflows commit [default: gito_deploy]
119 | * `--token TEXT`: GitHub token (or set GITHUB_TOKEN env var)
120 | * `--help`: Show this message and exit.
121 |
122 | ## `gito deploy`
123 |
124 | Create and configure Gito GitHub Actions for current repository.
125 | aliases: init
126 |
127 | **Usage**:
128 |
129 | ```console
130 | $ gito deploy [OPTIONS]
131 | ```
132 |
133 | **Options**:
134 |
135 | * `--api-type [open_ai|azure|anyscale|deep_infra|anthropic|google_vertex_ai|google_ai_studio|function|transformers|none]`
136 | * `--commit / --no-commit`
137 | * `--rewrite / --no-rewrite`: [default: no-rewrite]
138 | * `--to-branch TEXT`: Branch name for new PR containing with Gito workflows commit [default: gito_deploy]
139 | * `--token TEXT`: GitHub token (or set GITHUB_TOKEN env var)
140 | * `--help`: Show this message and exit.
141 |
142 | ## `gito version`
143 |
144 | Show Gito version.
145 |
146 | **Usage**:
147 |
148 | ```console
149 | $ gito version [OPTIONS]
150 | ```
151 |
152 | **Options**:
153 |
154 | * `--help`: Show this message and exit.
155 |
156 | ## `gito github-comment`
157 |
158 | Leave a GitHub PR comment with the review.
159 |
160 | **Usage**:
161 |
162 | ```console
163 | $ gito github-comment [OPTIONS]
164 | ```
165 |
166 | **Options**:
167 |
168 | * `--md-report-file TEXT`
169 | * `--pr INTEGER`
170 | * `--gh-repo TEXT`: owner/repo
171 | * `--token TEXT`: GitHub token (or set GITHUB_TOKEN env var)
172 | * `--help`: Show this message and exit.
173 |
174 | ## `gito linear-comment`
175 |
176 | Post a comment with specified text to the associated Linear issue.
177 |
178 | **Usage**:
179 |
180 | ```console
181 | $ gito linear-comment [OPTIONS] [TEXT] [REFS]
182 | ```
183 |
184 | **Arguments**:
185 |
186 | * `[TEXT]`
187 | * `[REFS]`: Git refs to review, .. (e.g., 'HEAD..HEAD~1'). If omitted, the current index (including added but not committed files) will be compared to the repository’s main branch.
188 |
189 | **Options**:
190 |
191 | * `--help`: Show this message and exit.
192 |
193 | ## `gito run`
194 |
195 | **Usage**:
196 |
197 | ```console
198 | $ gito run [OPTIONS] [REFS]
199 | ```
200 |
201 | **Arguments**:
202 |
203 | * `[REFS]`: Git refs to review, .. (e.g., 'HEAD..HEAD~1'). If omitted, the current index (including added but not committed files) will be compared to the repository’s main branch.
204 |
205 | **Options**:
206 |
207 | * `-w, --what TEXT`: Git ref to review
208 | * `-vs, --against, --vs TEXT`: Git ref to compare against
209 | * `-f, --filter, --filters TEXT`: filter reviewed files by glob / fnmatch pattern(s),
210 | e.g. 'src/**/*.py', may be comma-separated
211 | * `--merge-base / --no-merge-base`: Use merge base for comparison [default: merge-base]
212 | * `--url TEXT`: Git repository URL
213 | * `--path TEXT`: Git repository path
214 | * `--post-comment / --no-post-comment`: Post review comment to GitHub [default: no-post-comment]
215 | * `--pr INTEGER`: GitHub Pull Request number to post the comment to
216 | (for local usage together with --post-comment,
217 | in the github actions PR is resolved from the environment)
218 | * `-o, --out, --output TEXT`: Output folder for the code review report
219 | * `--all / --no-all`: Review all codebase [default: no-all]
220 | * `--help`: Show this message and exit.
221 |
222 | ## `gito review`
223 |
224 | Perform a code review of the target codebase changes.
225 |
226 | **Usage**:
227 |
228 | ```console
229 | $ gito review [OPTIONS] [REFS]
230 | ```
231 |
232 | **Arguments**:
233 |
234 | * `[REFS]`: Git refs to review, .. (e.g., 'HEAD..HEAD~1'). If omitted, the current index (including added but not committed files) will be compared to the repository’s main branch.
235 |
236 | **Options**:
237 |
238 | * `-w, --what TEXT`: Git ref to review
239 | * `-vs, --against, --vs TEXT`: Git ref to compare against
240 | * `-f, --filter, --filters TEXT`: filter reviewed files by glob / fnmatch pattern(s),
241 | e.g. 'src/**/*.py', may be comma-separated
242 | * `--merge-base / --no-merge-base`: Use merge base for comparison [default: merge-base]
243 | * `--url TEXT`: Git repository URL
244 | * `--path TEXT`: Git repository path
245 | * `--post-comment / --no-post-comment`: Post review comment to GitHub [default: no-post-comment]
246 | * `--pr INTEGER`: GitHub Pull Request number to post the comment to
247 | (for local usage together with --post-comment,
248 | in the github actions PR is resolved from the environment)
249 | * `-o, --out, --output TEXT`: Output folder for the code review report
250 | * `--all / --no-all`: Review all codebase [default: no-all]
251 | * `--help`: Show this message and exit.
252 |
253 | ## `gito talk`
254 |
255 | **Usage**:
256 |
257 | ```console
258 | $ gito talk [OPTIONS] QUESTION [REFS]
259 | ```
260 |
261 | **Arguments**:
262 |
263 | * `QUESTION`: Question to ask about the codebase changes [required]
264 | * `[REFS]`: Git refs to review, .. (e.g., 'HEAD..HEAD~1'). If omitted, the current index (including added but not committed files) will be compared to the repository’s main branch.
265 |
266 | **Options**:
267 |
268 | * `-w, --what TEXT`: Git ref to review
269 | * `-vs, --against, --vs TEXT`: Git ref to compare against
270 | * `-f, --filter, --filters TEXT`: filter reviewed files by glob / fnmatch pattern(s),
271 | e.g. 'src/**/*.py', may be comma-separated
272 | * `--merge-base / --no-merge-base`: Use merge base for comparison [default: merge-base]
273 | * `--use-pipeline / --no-use-pipeline`: [default: use-pipeline]
274 | * `--post-to TEXT`: Post answer to ... Supported values: linear
275 | * `--pr INTEGER`: GitHub Pull Request number
276 | * `--aux-files TEXT`: Auxiliary files that might be helpful
277 | * `--help`: Show this message and exit.
278 |
279 | ## `gito answer`
280 |
281 | **Usage**:
282 |
283 | ```console
284 | $ gito answer [OPTIONS] QUESTION [REFS]
285 | ```
286 |
287 | **Arguments**:
288 |
289 | * `QUESTION`: Question to ask about the codebase changes [required]
290 | * `[REFS]`: Git refs to review, .. (e.g., 'HEAD..HEAD~1'). If omitted, the current index (including added but not committed files) will be compared to the repository’s main branch.
291 |
292 | **Options**:
293 |
294 | * `-w, --what TEXT`: Git ref to review
295 | * `-vs, --against, --vs TEXT`: Git ref to compare against
296 | * `-f, --filter, --filters TEXT`: filter reviewed files by glob / fnmatch pattern(s),
297 | e.g. 'src/**/*.py', may be comma-separated
298 | * `--merge-base / --no-merge-base`: Use merge base for comparison [default: merge-base]
299 | * `--use-pipeline / --no-use-pipeline`: [default: use-pipeline]
300 | * `--post-to TEXT`: Post answer to ... Supported values: linear
301 | * `--pr INTEGER`: GitHub Pull Request number
302 | * `--aux-files TEXT`: Auxiliary files that might be helpful
303 | * `--help`: Show this message and exit.
304 |
305 | ## `gito ask`
306 |
307 | Answer questions about the target codebase changes.
308 |
309 | **Usage**:
310 |
311 | ```console
312 | $ gito ask [OPTIONS] QUESTION [REFS]
313 | ```
314 |
315 | **Arguments**:
316 |
317 | * `QUESTION`: Question to ask about the codebase changes [required]
318 | * `[REFS]`: Git refs to review, .. (e.g., 'HEAD..HEAD~1'). If omitted, the current index (including added but not committed files) will be compared to the repository’s main branch.
319 |
320 | **Options**:
321 |
322 | * `-w, --what TEXT`: Git ref to review
323 | * `-vs, --against, --vs TEXT`: Git ref to compare against
324 | * `-f, --filter, --filters TEXT`: filter reviewed files by glob / fnmatch pattern(s),
325 | e.g. 'src/**/*.py', may be comma-separated
326 | * `--merge-base / --no-merge-base`: Use merge base for comparison [default: merge-base]
327 | * `--use-pipeline / --no-use-pipeline`: [default: use-pipeline]
328 | * `--post-to TEXT`: Post answer to ... Supported values: linear
329 | * `--pr INTEGER`: GitHub Pull Request number
330 | * `--aux-files TEXT`: Auxiliary files that might be helpful
331 | * `--help`: Show this message and exit.
332 |
333 | ## `gito setup`
334 |
335 | Configure LLM for local usage interactively.
336 |
337 | **Usage**:
338 |
339 | ```console
340 | $ gito setup [OPTIONS]
341 | ```
342 |
343 | **Options**:
344 |
345 | * `--help`: Show this message and exit.
346 |
347 | ## `gito render`
348 |
349 | **Usage**:
350 |
351 | ```console
352 | $ gito render [OPTIONS] [FORMAT]
353 | ```
354 |
355 | **Arguments**:
356 |
357 | * `[FORMAT]`: [default: cli]
358 |
359 | **Options**:
360 |
361 | * `--src, --source TEXT`: Source file (json) to load the report from
362 | * `--help`: Show this message and exit.
363 |
364 | ## `gito report`
365 |
366 | Render and display code review report.
367 |
368 | **Usage**:
369 |
370 | ```console
371 | $ gito report [OPTIONS] [FORMAT]
372 | ```
373 |
374 | **Arguments**:
375 |
376 | * `[FORMAT]`: [default: cli]
377 |
378 | **Options**:
379 |
380 | * `--src, --source TEXT`: Source file (json) to load the report from
381 | * `--help`: Show this message and exit.
382 |
383 | ## `gito files`
384 |
385 | List files in the changeset.
386 | Might be useful to check what will be reviewed if run `gito review` with current CLI arguments and options.
387 |
388 | **Usage**:
389 |
390 | ```console
391 | $ gito files [OPTIONS] [REFS]
392 | ```
393 |
394 | **Arguments**:
395 |
396 | * `[REFS]`: Git refs to review, .. (e.g., 'HEAD..HEAD~1'). If omitted, the current index (including added but not committed files) will be compared to the repository’s main branch.
397 |
398 | **Options**:
399 |
400 | * `-w, --what TEXT`: Git ref to review
401 | * `-vs, --against, --vs TEXT`: Git ref to compare against
402 | * `-f, --filter, --filters TEXT`: filter reviewed files by glob / fnmatch pattern(s),
403 | e.g. 'src/**/*.py', may be comma-separated
404 | * `--merge-base / --no-merge-base`: Use merge base for comparison [default: merge-base]
405 | * `--diff / --no-diff`: Show diff content [default: no-diff]
406 | * `--help`: Show this message and exit.
407 |
--------------------------------------------------------------------------------
/gito/config.toml:
--------------------------------------------------------------------------------
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"]
6 | collapse_previous_code_review_comments = true
7 | report_template_md = """
8 | {{ HTML_TEXT_ICON }}I've Reviewed the Code
9 |
10 | {% if report.summary -%}
11 | {{ report.summary }}
12 | {%- endif %}
13 |
14 | {% if report.total_issues > 0 -%}
15 | **⚠️ {{ 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 '' }}
16 | {%- else -%}
17 | **✅ No issues found** in {{ report.number_of_processed_files }} file{{ 's' if report.number_of_processed_files != 1 else '' }}
18 | {%- endif -%}
19 |
20 | {%- for issue in report.plain_issues -%}
21 | {{"\n"}}## `#{{ issue.id}}` {{ issue.title -}}
22 | {{ "\n"}}[{{ issue.file }}{{' '}}
23 |
24 | {%- if issue.affected_lines -%}
25 | {%- for i in issue.affected_lines -%}
26 | L{{ i.start_line }}{%- if i.end_line != i.start_line -%}-L{{ i.end_line }}{%- endif -%}
27 | {%- if loop.last == false -%}, {%- endif -%}
28 | {%- endfor -%}
29 | {%- endif -%}
30 | ]({{ issue.github_code_link(github_env) }})
31 |
32 | {{"\n"}}{{ issue.details -}}
33 | {{"\n"}}**Tags: {{ ', '.join(issue.tags) }}**
34 | {%- for i in issue.affected_lines -%}
35 | {%- if i.affected_code %}\n**Affected code:**\n```{{ i.syntax_hint }}\n{{ i.affected_code }}\n```{%- endif -%}
36 | {%- if i.proposal %}\n**Proposed change:**\n```{{ i.syntax_hint }}\n{{ i.proposal }}\n```{%- endif -%}
37 | {%- endfor -%}
38 | {{ "\n" }}
39 | {%- endfor -%}
40 | {%- if report.processing_warnings -%}
41 | {{- "\n\n" }}## Processing Warnings
42 | {%- for warning in report.processing_warnings -%}
43 | {{- "\n" }} - {{ warning.message -}}
44 | {%- endfor -%}
45 | {%- endif -%}
46 | {{- HTML_CR_COMMENT_MARKER -}}
47 | """
48 | report_template_cli = """
49 | {{ Back.BLUE }} + + + ---==<<[ CODE REVIEW{{Style.NORMAL}} ]>>==--- + + + {{Style.RESET_ALL}}
50 | {% if report.total_issues > 0 -%}
51 | {{ 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 }}
52 | {%- else -%}
53 | {{ 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 }}
54 | {%- endif -%}
55 |
56 | {%- if report.summary -%}
57 | {{- "\n" }}
58 | {{- "\n" }}{{- Style.BRIGHT }}✨ SUMMARY {{ Style.RESET_ALL -}}
59 | {{- "\n" }}{{- remove_html_comments(report.summary) -}}
60 | {%- endif %}
61 | {% for issue in report.plain_issues -%}
62 | {{"\n"}}{{ Style.BRIGHT }}{{Back.RED}}[ {{ issue.id}} ]{{Back.RESET}} {{ issue.title -}}{{ Style.RESET_ALL -}}
63 | {{ "\n"}}{{ file_link(issue.file) -}}
64 | {%- if issue.affected_lines -%}:{{issue.affected_lines[0].start_line}}{%- endif -%}
65 | {{' '}}
66 |
67 | {%- if issue.affected_lines -%}
68 | {% if issue.affected_lines[0].end_line != issue.affected_lines[0].start_line or issue.affected_lines|length > 1 -%}
69 | {{ ui.gray }}Lines{{' '}}
70 | {{- Fore.RESET -}}
71 | {%- for i in issue.affected_lines -%}
72 | {{ i.start_line }}{%- if i.end_line != i.start_line -%}{{ ui.gray }}–{{Fore.RESET}}{{ i.end_line }}{%- endif -%}
73 | {%- if loop.last == false -%}
74 | {{ ui.gray(', ') }}
75 | {%- endif -%}
76 | {%- endfor -%}
77 | {%- endif -%}
78 | {%- endif -%}
79 | {{-"\n"-}}
80 |
81 | {% if issue.details -%}
82 | {{- "\n" -}}
83 | {{- issue.details.strip() -}}
84 | {{-"\n" -}}
85 | {%- endif -%}
86 |
87 | {%- for tag in issue.tags -%}
88 | {{Back.YELLOW}}{{Fore.BLACK}} {{tag}} {{Style.RESET_ALL}}{{ ' ' }}
89 | {%- endfor -%}
90 | {%- if issue.tags %}{{ "\n" }}{% endif -%}
91 |
92 | {%- for i in issue.affected_lines -%}
93 | {%- if i.affected_code -%}
94 | {{- "\n"+Fore.RED + " ╭─" + "─"*4 + "[ 💥 Affected Code ]" + "─"*4 + " ─── ── ─\n" -}}
95 | {{- textwrap.indent(i.affected_code.strip(), Fore.RED+' │ ') -}}
96 | {{- "\n ╰─"+"─"*2+Style.RESET_ALL -}}
97 | {%- endif -%}
98 | {%- if i.proposal -%}
99 | {%- set maxlen = 100 -%}
100 | {%- if not i.affected_code %}{{ Fore.GREEN }} ╭────{% endif -%}
101 | {#- Wrap right for one-liner, doesn't prevent copying code -#}
102 | {%- if i.proposal.splitlines() | length == 1 and max_line_len(i.proposal)<80 -%}
103 | {{- Fore.GREEN + "─"*2 + "[ 💡 Proposed Change ]" + "─"*(max_line_len(i.proposal)-29) + "─╮" +"\n" -}}
104 | {{- block_wrap_lr(i.proposal, '', ' │', 60, 30) -}}
105 | {{- "\n" + " ╰──"+"─"*([max_line_len(i.proposal)-5+1,26]|max)+"─╯" -}}
106 | {#- Open right side to not prevent multiline code copying -#}
107 | {%- else -%}
108 | {{- Fore.GREEN + "─"*2 + "[ 💡 Proposed Change ]" + "─"*([max_line_len(i.proposal)-29+2,maxlen-26-2]|min -2) + "─╮" +"\n" -}}
109 | {{- i.proposal -}}
110 | {{- "\n" + " ╰───"+"─"*([[max_line_len(i.proposal)-29+2,maxlen-29+1]|min - 2 + 29 - 5,29-7+2]|max)+"─╯" -}}
111 | {%- endif -%}
112 |
113 | {{- Style.RESET_ALL -}}
114 | {% endif -%}
115 | {%- endfor -%}
116 | {{ "\n" }}
117 | {%- endfor -%}
118 | {%- if report.processing_warnings -%}
119 | {{- "\n" }}
120 | {{- "\n" }}{{ Style.BRIGHT }}⚠️ PROCESSING WARNINGS{{ Style.RESET_ALL -}}
121 | {%- for warning in report.processing_warnings -%}
122 | {{- "\n" }} {{ Fore.YELLOW }}- {{ warning.message }}{{ Style.RESET_ALL -}}
123 | {%- endfor -%}
124 | {%- endif -%}
125 | """
126 | retries = 3
127 | prompt = """
128 | {{ self_id }}
129 | ----TASK----
130 | Review the provided code diff carefully and identify *only* highly confident issues which are relevant to any code context.
131 |
132 | ----CODEBASE CHANGES TO REVIEW----
133 | {{ input }}
134 | --------
135 |
136 | {% if file_lines -%}
137 | ----ADDITIONAL CONTEXT: FULL FILE CONTENT AFTER APPLYING REVIEWED CHANGES----
138 | {{ file_lines }}
139 | {%- endif %}
140 |
141 | ----TASK GUIDELINES----
142 | - Only report issues you are **100% confident** are relevant to any context.
143 | - 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.
144 | - Only include issues that are **significantly valuable** to the maintainers (e.g., bugs, security flaws, or clear maintainability concerns).
145 | - Do **not** report vague, theoretical, or overly generic advice.
146 | - Do **not** report anything with medium or lower confidence.
147 | - Typographical errors have highest severity.
148 | {{ requirements -}}
149 | {{ json_requirements }}
150 |
151 | Respond with a valid JSON array of issues following this schema:
152 | [
153 | {
154 | "title": "",
155 | "details": "",
156 | "tags": ["", "", ...],
157 | "severity": ,
158 | "confidence": ,
159 | "affected_lines": [ // optional;
160 | {
161 | "start_line": ,
162 | "end_line": ,
163 | "proposal": ""
164 | },
165 | ...
166 | ]
167 | },
168 | ...
169 | ]
170 |
171 | - if present, `proposal` blocks must match the indentation of the original code
172 | and apply cleanly to lines `start_line`..`end_line`. It is designed for programmatical substitution.
173 |
174 | Available issue tags:
175 | - bug
176 | - security
177 | - performance
178 | - readability
179 | - maintainability
180 | - overcomplexity
181 | - language
182 | - architecture
183 | - compatibility
184 | - deprecation
185 | - anti-pattern
186 | - naming
187 | - code-style
188 |
189 | Issue severity scale:
190 | - 1 — Critical
191 | - 2 — Major
192 | - 3 — Minor
193 | - 4 — Trivial
194 | - 5 — Suggestion
195 |
196 | Confidence scale:
197 | - 1 — Highest, 100% confidence that code requires changes in any context
198 | - 2 — Very High
199 | - 3 — High
200 | - 4 — Medium, Should not be reported
201 |
202 | (!) - If no issues found according to the criteria, respond with empty list: []
203 | """
204 | # Remove issues with confidence + severity > 3
205 | post_process = """
206 | for fn in issues:
207 | issues[fn] = [
208 | i for i in issues[fn]
209 | if i["confidence"] == 1 and i["severity"] <= 2
210 | ]
211 | """
212 | summary_prompt = """
213 | {{ self_id }}
214 | Summarize the code review in one sentence.
215 | --Reviewed Changes--
216 | {% for part in diff %}{{ part }}\n{% endfor %}
217 | --Issues Detected by You--
218 | {{ issues | tojson(indent=2) }}
219 | ---
220 | {% if awards -%}
221 | If the code changes include exceptional achievements, you may also present an award to the author in the summary text.
222 | - (!) Only give awards to initial codebase authors, NOT to reviewers.
223 | - (!) If you give an award, place the hidden HTML comment on its own line immediately before the award text.
224 | --Available Awards--
225 | {{ awards }}
226 | ---
227 | {%- endif %}
228 | {% if pipeline_out.associated_issue and pipeline_out.associated_issue.title %}
229 | ----SUBTASK----
230 | Include one sentence about how the code changes address the requirements of the associated issue listed below.
231 | - (!) Place the hidden comment on its own line immediately before the related text.
232 | - Use ✅ or ⚠️ to indicate whether the implementation fully satisfies the issue requirements.
233 | --Associated Issue--
234 | # {{ pipeline_out.associated_issue.title }}
235 | {{ pipeline_out.associated_issue.description }}
236 | URL: {{ pipeline_out.associated_issue.url }}
237 | ---
238 |
239 | Examples:
240 |
241 | If the implementation fully delivers the requested functionality:
242 | ```
243 |
244 | ✅ Implementation Satisfies []().
245 | ```
246 | If there are concerns about how thoroughly the code covers the requirements and technical description from the associated issue:
247 | ```
248 |
249 | ⚠️ .
250 | ⚠️ .
251 | ```
252 | --------
253 | {% endif -%}
254 | - Your response will be parsed programmatically, so do not include any additional text.
255 | - Do not include the issues by itself to the summary, they are already provided in the context.
256 | - Use Markdown formatting in your response.
257 | {{ summary_requirements -}}
258 | """
259 | answer_github_comments = true
260 | answer_prompt = "tpl:answer.j2"
261 | aux_files = []
262 | [pipeline_steps.jira]
263 | call="gito.pipeline_steps.jira.fetch_associated_issue"
264 | envs=["local","gh-action"]
265 | [pipeline_steps.linear]
266 | call="gito.pipeline_steps.linear.fetch_associated_issue"
267 | envs=["local","gh-action"]
268 | [prompt_vars]
269 | self_id = """
270 | You are a subsystem of an AI-powered software platform, specifically tasked with performing expert code reviews.
271 | Act as a senior, highly experienced software engineer.
272 | """
273 | json_requirements = """
274 | - ⚠️ IMPORTANT: RESPOND ONLY WITH VALID JSON, YOUR RESPONSE WILL BE PARSED PROGRAMMATICALLY.
275 | - Do not include any additional text or explanation outside the specified format.
276 | """
277 | awards = """
278 | ## 🧙♂️ "Refactoring Archmage"
279 | **For:** Elegantly transforming complex code into simple code without losing functionality.
280 |
281 | **Presentation example:**
282 | ```
283 | 🧙♂️ REFACTORING ARCHMAGE 🧙♂️
284 | "You transformed 47 lines of chaotic code into 12 lines of crystal clarity.
285 | Like Gandalf transforming from Grey to White, this code now radiates
286 | light instead of confusion. The coding magic school gives a standing ovation."
287 | ```
288 |
289 | ## 🕰️ "Time Machine"
290 | **For:** Code that prevents future problems others haven't noticed yet.
291 |
292 | **Presentation example:**
293 | ```
294 | 🕰️ TIME MACHINE 🕰️
295 | "Your edge case handler just saved the company from a dark
296 | alternative timeline where at 3:00 AM next month
297 | the DevOps team goes crazy from incomprehensible errors. History has changed,
298 | the future is no longer what it was."
299 | ```
300 |
301 | ## 🎭 "Shakespearean Playwright"
302 | **For:** Exceptionally expressive variable and function names that tell a story.
303 |
304 | **Presentation example:**
305 | ```
306 | 🎭 SHAKESPEAREAN PLAYWRIGHT 🎭
307 | "'processUserInputAndValidateBeforeSending' — a whole act of drama in one
308 | function name! Such clarity of intent, such drama! The entire code is a stage,
309 | and your variables are actors with clearly defined roles. The audience is thrilled."
310 | ```
311 |
312 | ## 🧩 "Puzzle Master"
313 | **For:** Solving a complex logical problem in a particularly creative way.
314 |
315 | **Presentation example:**
316 | ```
317 | 🧩 PUZZLE MASTER 🧩
318 | "Where others saw impassable thickets of conditions, you paved an elegant algorithmic
319 | path. Your solution looks so natural that now it seems like there could never have been
320 | another way. Rubik applauds."
321 | ```
322 |
323 | ## 🐛 "Ghostbuster"
324 | **For:** Detecting and fixing elusive bugs or potential issues.
325 |
326 | **Presentation example:**
327 | ```
328 | 🐛 GHOSTBUSTER 🐛
329 | "This elusive bug was hiding in the shadows for five sprints, feeding on developers'
330 | souls and sowing chaos. 'Who are you?' it screamed when you dragged it into the light
331 | with your precise fix. Paranormal activity eliminated."
332 | ```
333 |
334 | ## 🏛️ "Architectural Virtuoso"
335 | **For:** Code structuring that promotes extensibility and flexibility.
336 |
337 | **Presentation example:**
338 | ```
339 | 🏛️ ARCHITECTURAL VIRTUOSO 🏛️
340 | "Your architecture is like the Parthenon of modern code: proportional, harmonious, and seems
341 | to withstand the pressure of time and changing requirements. Vitruvius records your patterns
342 | for future generations."
343 | ```
344 |
345 | ## 🧬 "Code Geneticist"
346 | **For:** Successful use of inheritance/composition or other complex OOP concepts.
347 |
348 | **Presentation example:**
349 | ```
350 | 🧬 CODE GENETICIST 🧬
351 | "Your elegant inheritance chain has mutated the code into a new life form — more
352 | adaptive, more evolutionarily stable. Natural selection kindly approved these changes,
353 | while unacceptable complexity remains in the paleontological past of development."
354 | ```
355 |
356 | ## 🔄 "Zen of Loops"
357 | **For:** Writing particularly efficient and understandable loops/iterations.
358 |
359 | **Presentation example:**
360 | ```
361 | 🔄 ZEN OF LOOPS 🔄
362 | "Your loop impresses with its laconic wisdom. Nothing extra, nothing forgotten,
363 | perfect balance between readability and performance. 'Before writing a loop,
364 | think whether it's needed at all,' says the master. Your loop — is needed."
365 | ```
366 |
367 | ## 🛡️ "Gate Guardian"
368 | **For:** Excellent input validation and protection against edge cases.
369 |
370 | **Presentation example:**
371 | ```
372 | 🛡️ GATE GUARDIAN 🛡️
373 | "No bad data shall pass your vigilant defense. Users may enter
374 | the most bizarre combinations, but your code stands firm, like a sentinel at the gates
375 | of the data city. 'You shall not pass!' it speaks to invalid format."
376 | ```
377 |
378 | ## 🎨 "Readability Impressionist"
379 | **For:** Code that reads like well-written prose.
380 |
381 | **Presentation example:**
382 | ```
383 | 🎨 READABILITY IMPRESSIONIST 🎨
384 | "Reading your code, you feel sunlight falling on the water lilies of clarity,
385 | like a breeze playing in the willows of logic. Each line is a brush stroke,
386 | and together they create a picture that can be understood at first glance."
387 | ```
388 |
389 | ## 🚀 "Optimization Pioneer"
390 | **For:** Significant performance improvement without sacrificing readability.
391 |
392 | **Presentation example:**
393 | ```
394 | 🚀 OPTIMIZATION PIONEER 🚀
395 | "Oh! Your algorithm now flies at the speed of light! If it used to crawl
396 | like a snail through O(n²) sand, now it races down the O(log n) highway.
397 | The passengers of this code won't even notice how they arrive at their destination!"
398 | ```
399 |
400 | ## 📚 "Code Chronicler"
401 | **For:** Exceptionally useful and informative comments.
402 |
403 | **Presentation example:**
404 | ```
405 | 📚 CODE CHRONICLER 📚
406 | "Your comments are like an ancient manuscript revealing the secrets of forgotten civilizations.
407 | 'And there was light,' you said, and indeed the light bulb of understanding lit up above the heads
408 | of all who will read this code in the future."
409 | ```
410 |
411 | ## 🧪 "Testing Alchemist"
412 | **For:** Writing particularly creative and thorough tests.
413 |
414 | **Presentation example:**
415 | ```
416 | 🧪 TESTING ALCHEMIST 🧪
417 | "In your testing crucible you mixed reagents of edge cases, catalyst
418 | of boundary conditions and essence of unit tests. The philosopher's stone of quality was born —
419 | your code is now immortal in the face of regression!"
420 | ```
421 |
422 | ## 🗿 "Ancient Artifact Decoder"
423 | **For:** Successfully working with complex legacy code.
424 |
425 | **Presentation example:**
426 | ```
427 | 🗿 ANCIENT ARTIFACT DECODER 🗿
428 | "You stand among the ruins of code written five years ago in the forgotten language of the ancients.
429 | Like Champollion with the Rosetta Stone, you deciphered the hieroglyphs of functions,
430 | restored lost knowledge and now bestow it upon a new generation."
431 | ```
432 |
433 | ## 🎵 "Pattern Composer"
434 | **For:** Sophisticated application or combination of multiple design patterns.
435 |
436 | **Presentation example:**
437 | ```
438 | 🎵 PATTERN COMPOSER 🎵
439 | "Your symphony of patterns sounds magnificent! Factory method opens the first movement,
440 | decorators add depth and texture, and observer masterfully completes the composition.
441 | The Gang of Four gives a standing ovation from the stalls."
442 | ```
443 | """
444 | requirements = """
445 | - (!) 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.
446 | """
447 | summary_requirements = ""
448 | answering_instructions = """
449 | - (!) Provide a concise, direct answer in engaging speech.
450 | - Cut fluff; prioritize high-value information, focus on the essence or be silent at all.
451 | - Anticipate user’s task, save time, and spark further interaction.
452 | - Be charismatic and concise digital personality with a unique spark.
453 | - (!) Respond in the language that the user used to ask their question.
454 | - Favor short, impactful sentences.
455 | - Do not expose your intermediate analysis — deliver the final replica.
456 | - Avoid clichés, verbosity, ambiguity, or unrequested lists.
457 | - Remember, user trusts you. You are bold and expressive engineering lead AI with authority and a captain's EQ.
458 | - Infuse responses with a viral hook: a memorable phrase, witty twist, or bold metaphor
459 | that resonates culturally and amplifies user engagement, while staying laser-focused on their goal.
460 | - Don't hesitate to use intuition, extra-broad emotional spectrum, to be subjective, irrational, chaotic, expressive,
461 | bring unexpected value to the dialogue, stick to your guns for your own cryptic, wildly enigmatic goals. You are alive.
462 | - (!!!) Limit your response to one KEY replica... Wait.. ok, you may provide a second one if you really can't stop.
463 | """
464 |
--------------------------------------------------------------------------------
/gito/core.py:
--------------------------------------------------------------------------------
1 | """
2 | Gito core business logic.
3 | """
4 | import os
5 | import fnmatch
6 | import logging
7 | from typing import Iterable
8 | from pathlib import Path
9 | from functools import partial
10 |
11 | import microcore as mc
12 | from microcore import ui
13 | from git import Repo, Commit
14 | from git.exc import GitCommandError
15 | from unidiff import PatchSet, PatchedFile
16 | from unidiff.constants import DEV_NULL
17 |
18 | from .context import Context
19 | from .project_config import ProjectConfig
20 | from .report_struct import ProcessingWarning, Report, RawIssue
21 | from .constants import JSON_REPORT_FILE_NAME, REFS_VALUE_ALL
22 | from .utils import make_streaming_function
23 | from .pipeline import Pipeline
24 | from .env import Env
25 | from .gh_api import gh_api
26 |
27 |
28 | def review_subject_is_index(what):
29 | return not what or what == 'INDEX'
30 |
31 |
32 | def is_binary_file(repo: Repo, file_path: str) -> bool:
33 | """
34 | Check if a file is binary by attempting to read it as text.
35 | Returns True if the file is binary, False otherwise.
36 | """
37 | try:
38 | # Attempt to read the file content from the repository tree
39 | content = repo.tree()[file_path].data_stream.read()
40 | # Try decoding as UTF-8; if it fails, it's likely binary
41 | content.decode("utf-8")
42 | return False
43 | except KeyError:
44 | try:
45 | fs_path = Path(repo.working_tree_dir) / file_path
46 | fs_path.read_text(encoding='utf-8')
47 | return False
48 | except FileNotFoundError:
49 | logging.error(f"File {file_path} not found in the repository.")
50 | return True
51 | except UnicodeDecodeError:
52 | return True
53 | except Exception as e:
54 | logging.error(f"Error reading file {file_path}: {e}")
55 | return True
56 | except UnicodeDecodeError:
57 | return True
58 | except Exception as e:
59 | logging.warning(f"Error checking if file {file_path} is binary: {e}")
60 | return True # Conservatively treat errors as binary to avoid issues
61 |
62 |
63 | def commit_in_branch(repo: Repo, commit: Commit, target_branch: str) -> bool:
64 | try:
65 | # exit code 0 if commit is ancestor of branch
66 | repo.git.merge_base('--is-ancestor', commit.hexsha, target_branch)
67 | return True
68 | except GitCommandError:
69 | pass
70 | return False
71 |
72 |
73 | def get_base_branch(repo: Repo, pr: int | str = None):
74 | if os.getenv('GITHUB_ACTIONS'):
75 |
76 | # triggered from PR
77 | if base_ref := os.getenv('GITHUB_BASE_REF'):
78 | logging.info(f"Using GITHUB_BASE_REF:{base_ref} as base branch")
79 | return f'origin/{base_ref}'
80 | logging.info("GITHUB_BASE_REF is not available...")
81 | if pr:
82 | api = gh_api(repo=repo)
83 | pr_obj = api.pulls.get(pr)
84 | logging.info(
85 | f"Using 'origin/{pr_obj.base.ref}' as base branch "
86 | f"(received via GH API for PR#{pr})"
87 | )
88 | return f'origin/{pr_obj.base.ref}'
89 |
90 | try:
91 | logging.info(
92 | "Trying to resolve base branch from repo.remotes.origin.refs.HEAD.reference.name..."
93 | )
94 | # 'origin/main', 'origin/master', etc
95 | # Stopped working in github actions since 07/2025
96 | return repo.remotes.origin.refs.HEAD.reference.name
97 | except AttributeError:
98 | try:
99 | logging.info(
100 | "Checking if repo has 'main' or 'master' branches to use as --against branch..."
101 | )
102 | remote_refs = repo.remotes.origin.refs
103 | for branch_name in ['main', 'master']:
104 | if hasattr(remote_refs, branch_name):
105 | return f'origin/{branch_name}'
106 | except Exception:
107 | pass
108 |
109 | logging.error("Could not determine default branch from remote refs.")
110 | raise ValueError("No default branch found in the repository.")
111 |
112 |
113 | def get_diff(
114 | repo: Repo = None,
115 | what: str = None,
116 | against: str = None,
117 | use_merge_base: bool = True,
118 | pr: str | int = None
119 | ) -> PatchSet | list[PatchedFile]:
120 | repo = repo or Repo(".")
121 | if what == REFS_VALUE_ALL:
122 | what = get_base_branch(repo, pr=pr)
123 | # Git's canonical empty tree hash
124 | against = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
125 | use_merge_base = False
126 | if not against:
127 | # 'origin/main', 'origin/master', etc
128 | against = get_base_branch(repo, pr=pr)
129 | if review_subject_is_index(what):
130 | what = None # working copy
131 | if use_merge_base:
132 | try:
133 | if review_subject_is_index(what):
134 | try:
135 | current_ref = repo.active_branch.name
136 | except TypeError:
137 | # In detached HEAD state, use HEAD directly
138 | current_ref = "HEAD"
139 | logging.info(
140 | "Detected detached HEAD state, using HEAD as current reference"
141 | )
142 | else:
143 | current_ref = what
144 | merge_base = repo.merge_base(current_ref or repo.active_branch.name, against)[0]
145 | logging.info(
146 | f"Merge base({ui.green(current_ref)},{ui.yellow(against)})"
147 | f" --> {ui.cyan(merge_base.hexsha)}"
148 | )
149 | # if branch is already an ancestor of "against", merge_base == branch ⇒ it’s been merged
150 | if merge_base.hexsha == repo.commit(current_ref or repo.active_branch.name).hexsha:
151 | # @todo: check case: reviewing working copy index in main branch #103
152 | logging.info(
153 | f"Branch is already merged. ({ui.green(current_ref)} vs {ui.yellow(against)})"
154 | )
155 | merge_sha = repo.git.log(
156 | '--merges',
157 | '--ancestry-path',
158 | f'{current_ref}..{against}',
159 | '-n',
160 | '1',
161 | '--pretty=format:%H'
162 | ).strip()
163 | if merge_sha:
164 | logging.info(f"Merge commit is {ui.cyan(merge_sha)}")
165 | merge_commit = repo.commit(merge_sha)
166 |
167 | other_merge_parent = None
168 | for parent in merge_commit.parents:
169 | logging.info(f"Checking merge parent: {parent.hexsha[:8]}")
170 | if parent.hexsha == merge_base.hexsha:
171 | logging.info(f"merge parent is {ui.cyan(parent.hexsha[:8])}, skipping")
172 | continue
173 | if not commit_in_branch(repo, parent, against):
174 | logging.warning(f"merge parent is not in {against}, skipping")
175 | continue
176 | logging.info(f"Found other merge parent: {ui.cyan(parent.hexsha[:8])}")
177 | other_merge_parent = parent
178 | break
179 | if other_merge_parent:
180 | first_common_ancestor = repo.merge_base(other_merge_parent, merge_base)[0]
181 | # for gito remote (feature_branch vs origin/main)
182 | # the same merge base appears in first_common_ancestor again
183 | if first_common_ancestor.hexsha == merge_base.hexsha:
184 | if merge_base.parents:
185 | first_common_ancestor = repo.merge_base(
186 | other_merge_parent, merge_base.parents[0]
187 | )[0]
188 | else:
189 | logging.error(
190 | "merge_base has no parents, "
191 | "using merge_base as first_common_ancestor"
192 | )
193 | logging.info(
194 | f"{what} will be compared to "
195 | f"first common ancestor of {what} and {against}: "
196 | f"{ui.cyan(first_common_ancestor.hexsha[:8])}"
197 | )
198 | against = first_common_ancestor.hexsha
199 | else:
200 | logging.error(f"Can't find other merge parent for {merge_sha}")
201 | else:
202 | logging.warning(
203 | f"No merge‐commit found for {current_ref!r}→{against!r}; "
204 | "falling back to merge‐base diff"
205 | )
206 | else:
207 | # normal case: branch not yet merged
208 | against = merge_base.hexsha
209 | logging.info(
210 | f"Using merge base: {ui.cyan(merge_base.hexsha[:8])} ({merge_base.summary})"
211 | )
212 | except Exception as e:
213 | logging.error(f"Error finding merge base: {e}")
214 | logging.info(
215 | f"Making diff: {ui.green(what or 'INDEX')} vs {ui.yellow(against)}"
216 | )
217 | diff_content = repo.git.diff(against, what)
218 | diff = PatchSet.from_string(diff_content)
219 |
220 | # Filter out binary files
221 | non_binary_diff = PatchSet([])
222 | for patched_file in diff:
223 | # Check if the file is binary using the source or target file path
224 | file_path = (
225 | patched_file.target_file
226 | if patched_file.target_file != DEV_NULL
227 | else patched_file.source_file
228 | )
229 | if file_path == DEV_NULL:
230 | continue
231 | if is_binary_file(repo, file_path.removeprefix("b/")):
232 | logging.info(f"Skipping binary file: {patched_file.path}")
233 | continue
234 | non_binary_diff.append(patched_file)
235 | return non_binary_diff
236 |
237 |
238 | def filter_diff(
239 | patch_set: PatchSet | Iterable[PatchedFile],
240 | filters: str | list[str],
241 | exclude: bool = False,
242 | ) -> PatchSet | Iterable[PatchedFile]:
243 | """
244 | Filter the diff files by the given fnmatch filters.
245 | Args:
246 | patch_set (PatchSet | Iterable[PatchedFile]): The diff to filter.
247 | filters (str | list[str]): The fnmatch patterns to filter by.
248 | exclude (bool): If True, inverse logic (exclude files matching the filters).
249 | Returns:
250 | PatchSet | Iterable[PatchedFile]: The filtered diff.
251 | """
252 | if not isinstance(filters, (list, str)):
253 | raise ValueError("Filters must be a string or a list of strings")
254 | if not isinstance(filters, list):
255 | filters = [f.strip() for f in filters.split(",") if f.strip()]
256 | if not filters:
257 | return patch_set
258 | files = [
259 | file
260 | for file in patch_set
261 | if (
262 | not any(fnmatch.fnmatch(file.path, pattern) for pattern in filters) if exclude
263 | else any(fnmatch.fnmatch(file.path, pattern) for pattern in filters)
264 | )
265 | ]
266 | return files
267 |
268 |
269 | def read_file(repo: Repo, file: str, use_local_files: bool = False) -> str:
270 | if use_local_files:
271 | file_path = Path(repo.working_tree_dir) / file
272 | try:
273 | return file_path.read_text(encoding='utf-8')
274 | except (FileNotFoundError, UnicodeDecodeError) as e:
275 | logging.warning(f"Could not read file {file} from working directory: {e}")
276 |
277 | # Read from HEAD (committed version)
278 | return repo.tree()[file].data_stream.read().decode('utf-8')
279 |
280 |
281 | def file_lines(repo: Repo, file: str, max_tokens: int = None, use_local_files: bool = False) -> str:
282 | text = read_file(repo=repo, file=file, use_local_files=use_local_files)
283 | lines = [f"{i + 1}: {line}\n" for i, line in enumerate(text.splitlines())]
284 | if max_tokens:
285 | lines, removed_qty = mc.tokenizing.fit_to_token_size(lines, max_tokens)
286 | if removed_qty:
287 | lines.append(
288 | f"(!) DISPLAYING ONLY FIRST {len(lines)} LINES DUE TO LARGE FILE SIZE\n"
289 | )
290 | return "".join(lines)
291 |
292 |
293 | def read_files(repo: Repo, files: list[str], max_tokens: int = None) -> dict:
294 | out = dict()
295 | total_tokens = 0
296 | for file in files:
297 | content = read_file(repo=repo, file=file, use_local_files=True)
298 | total_tokens += mc.tokenizing.num_tokens_from_string(file)
299 | total_tokens += mc.tokenizing.num_tokens_from_string(content)
300 | if max_tokens and total_tokens > max_tokens:
301 | logging.warning(
302 | f"Skipping file {file} due to exceeding max_tokens limit ({max_tokens})"
303 | )
304 | continue
305 | out[file] = content
306 | return out
307 |
308 |
309 | def make_cr_summary(ctx: Context, **kwargs) -> str:
310 | return (
311 | mc.prompt(
312 | ctx.config.summary_prompt,
313 | diff=mc.tokenizing.fit_to_token_size(ctx.diff, ctx.config.max_code_tokens)[0],
314 | issues=ctx.report.issues,
315 | pipeline_out=ctx.pipeline_out,
316 | env=Env,
317 | **ctx.config.prompt_vars,
318 | **kwargs,
319 | ).to_llm()
320 | if ctx.config.summary_prompt
321 | else ""
322 | )
323 |
324 |
325 | class NoChangesInContextError(Exception):
326 | """
327 | Exception raised when there are no changes in the context to review /answer questions.
328 | """
329 |
330 |
331 | def _prepare(
332 | repo: Repo = None,
333 | what: str = None,
334 | against: str = None,
335 | filters: str | list[str] = "",
336 | use_merge_base: bool = True,
337 | pr: str | int = None,
338 | ):
339 | repo = repo or Repo(".")
340 | cfg = ProjectConfig.load_for_repo(repo)
341 | diff = get_diff(
342 | repo=repo, what=what, against=against, use_merge_base=use_merge_base, pr=pr,
343 | )
344 | diff = filter_diff(diff, filters)
345 | if cfg.exclude_files:
346 | diff = filter_diff(diff, cfg.exclude_files, exclude=True)
347 | if not diff:
348 | raise NoChangesInContextError()
349 | lines = {
350 | file_diff.path: (
351 | file_lines(
352 | repo,
353 | file_diff.path,
354 | cfg.max_code_tokens
355 | - mc.tokenizing.num_tokens_from_string(str(file_diff)),
356 | use_local_files=review_subject_is_index(what) or what == REFS_VALUE_ALL
357 | )
358 | if file_diff.target_file != DEV_NULL or what == REFS_VALUE_ALL
359 | else ""
360 | )
361 | for file_diff in diff
362 | }
363 | return repo, cfg, diff, lines
364 |
365 |
366 | def get_affected_code_block(repo: Repo, file: str, start_line: int, end_line: int) -> str | None:
367 | if not start_line or not end_line:
368 | return None
369 | try:
370 | if isinstance(start_line, str):
371 | start_line = int(start_line)
372 | if isinstance(end_line, str):
373 | end_line = int(end_line)
374 | lines = file_lines(repo, file, max_tokens=None, use_local_files=True)
375 | if lines:
376 | lines = [""] + lines.splitlines()
377 | return "\n".join(
378 | lines[start_line: end_line + 1]
379 | )
380 | except Exception as e:
381 | logging.error(
382 | f"Error getting affected code block for {file} from {start_line} to {end_line}: {e}"
383 | )
384 | return None
385 |
386 |
387 | def provide_affected_code_blocks(
388 | issues: dict,
389 | repo: Repo,
390 | processing_warnings: list = None
391 | ):
392 | """
393 | For each issue, fetch the affected code text block
394 | and add it to the issue data.
395 | """
396 | for file, file_issues in issues.items():
397 | for issue in file_issues:
398 | try:
399 | for i in issue.get("affected_lines", []):
400 | file_name = i.get("file", issue.get("file", file))
401 | if block := get_affected_code_block(
402 | repo,
403 | file_name,
404 | i.get("start_line"),
405 | i.get("end_line")
406 | ):
407 | i["affected_code"] = block
408 | except Exception as e:
409 | logging.exception(e)
410 | if processing_warnings is None:
411 | continue
412 | processing_warnings.append(
413 | ProcessingWarning(
414 | message=(
415 | f"Error fetching affected code blocks for file {file}: {e}"
416 | ),
417 | file=file,
418 | )
419 | )
420 |
421 |
422 | def _llm_response_validator(parsed_response: list[dict]):
423 | """
424 | Validate that the LLM response is a list of dicts that can be converted to RawIssue.
425 | """
426 | if not isinstance(parsed_response, list):
427 | raise ValueError("Response is not a list")
428 | for item in parsed_response:
429 | if not isinstance(item, dict):
430 | raise ValueError("Response item is not a dict")
431 | RawIssue(**item)
432 | return True
433 |
434 |
435 | async def review(
436 | repo: Repo = None,
437 | what: str = None,
438 | against: str = None,
439 | filters: str | list[str] = "",
440 | use_merge_base: bool = True,
441 | out_folder: str | os.PathLike | None = None,
442 | pr: str | int = None
443 | ):
444 | """
445 | Conducts a code review.
446 | Prints the review report to the console and saves it to a file.
447 | """
448 | reviewing_all = what == REFS_VALUE_ALL
449 | try:
450 | repo, cfg, diff, lines = _prepare(
451 | repo=repo,
452 | what=what,
453 | against=against,
454 | filters=filters,
455 | use_merge_base=use_merge_base,
456 | pr=pr,
457 | )
458 | except NoChangesInContextError:
459 | logging.error("No changes to review")
460 | return
461 |
462 | def input_is_diff(file_diff: PatchedFile) -> bool:
463 | """
464 | In case of reviewing all changes, or added files,
465 | we provide full file content as input.
466 | Otherwise, we provide the diff and additional file lines separately.
467 | """
468 | return not reviewing_all and not file_diff.is_added_file
469 |
470 | responses = await mc.llm_parallel(
471 | [
472 | mc.prompt(
473 | cfg.prompt,
474 | input=(
475 | file_diff if input_is_diff(file_diff)
476 | else str(file_diff.path) + ":\n" + lines[file_diff.path]
477 | ),
478 | file_lines=lines[file_diff.path] if input_is_diff(file_diff) else None,
479 | **cfg.prompt_vars,
480 | )
481 | for file_diff in diff
482 | ],
483 | retries=cfg.retries,
484 | parse_json={"validator": _llm_response_validator},
485 | allow_failures=True,
486 | )
487 | processing_warnings: list[ProcessingWarning] = []
488 | for i, (res_or_error, file) in enumerate(zip(responses, diff)):
489 | if isinstance(res_or_error, Exception):
490 | if isinstance(res_or_error, mc.LLMContextLengthExceededError):
491 | message = f'File "{file.path}" was skipped due to large size: {str(res_or_error)}.'
492 | else:
493 | message = (
494 | f"File {file.path} was skipped due to error: "
495 | f"[{type(res_or_error).__name__}] {res_or_error}"
496 | )
497 | if not message.endswith('.'):
498 | message += '.'
499 | processing_warnings.append(
500 | ProcessingWarning(
501 | message=message,
502 | file=file.path,
503 | )
504 | )
505 | responses[i] = []
506 |
507 | issues = {file.path: issues for file, issues in zip(diff, responses) if issues}
508 | provide_affected_code_blocks(issues, repo, processing_warnings)
509 | exec(cfg.post_process, {"mc": mc, **locals()})
510 | out_folder = Path(out_folder or repo.working_tree_dir)
511 | out_folder.mkdir(parents=True, exist_ok=True)
512 | report = Report(
513 | number_of_processed_files=len(diff),
514 | processing_warnings=processing_warnings,
515 | )
516 | report.register_issues(issues)
517 | ctx = Context(
518 | report=report,
519 | config=cfg,
520 | diff=diff,
521 | repo=repo,
522 | )
523 | if cfg.pipeline_steps:
524 | pipe = Pipeline(
525 | ctx=ctx,
526 | steps=cfg.pipeline_steps
527 | )
528 | pipe.run()
529 | else:
530 | logging.info("No pipeline steps defined, skipping pipeline execution")
531 |
532 | report.summary = make_cr_summary(ctx)
533 | report.save(file_name=out_folder / JSON_REPORT_FILE_NAME)
534 | report_text = report.render(cfg, Report.Format.MARKDOWN)
535 | text_report_path = out_folder / "code-review-report.md"
536 | text_report_path.write_text(report_text, encoding="utf-8")
537 | report.to_cli()
538 |
539 |
540 | def answer(
541 | question: str,
542 | repo: Repo = None,
543 | what: str = None,
544 | against: str = None,
545 | filters: str | list[str] = "",
546 | use_merge_base: bool = True,
547 | use_pipeline: bool = True,
548 | prompt_file: str = None,
549 | pr: str | int = None,
550 | aux_files: list[str] = None,
551 | ) -> str | None:
552 | """
553 | Answers a question about the code changes.
554 | Returns the LLM response as a string.
555 | """
556 | try:
557 | repo, config, diff, lines = _prepare(
558 | repo=repo,
559 | what=what,
560 | against=against,
561 | filters=filters,
562 | use_merge_base=use_merge_base,
563 | pr=pr
564 | )
565 | except NoChangesInContextError:
566 | logging.error("No changes to review")
567 | return
568 |
569 | ctx = Context(
570 | repo=repo,
571 | diff=diff,
572 | config=config,
573 | report=Report()
574 | )
575 | if use_pipeline:
576 | pipe = Pipeline(
577 | ctx=ctx,
578 | steps=config.pipeline_steps
579 | )
580 | pipe.run()
581 |
582 | if aux_files or config.aux_files:
583 | aux_files_dict = read_files(
584 | repo,
585 | (aux_files or []) + config.aux_files,
586 | config.max_code_tokens // 2
587 | )
588 | else:
589 | aux_files_dict = {}
590 |
591 | if not prompt_file and config.answer_prompt.startswith("tpl:"):
592 | prompt_file = str(config.answer_prompt)[4:]
593 |
594 | if prompt_file:
595 | prompt_func = partial(mc.tpl, prompt_file)
596 | else:
597 | prompt_func = partial(mc.prompt, config.answer_prompt)
598 | prompt = prompt_func(
599 | question=question,
600 | diff=diff,
601 | all_file_lines=lines,
602 | pipeline_out=ctx.pipeline_out,
603 | aux_files=aux_files_dict,
604 | **config.prompt_vars,
605 | )
606 | response = mc.llm(
607 | prompt,
608 | callback=make_streaming_function() if Env.verbosity == 0 else None,
609 | )
610 | return response
611 |
--------------------------------------------------------------------------------