├── 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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | coverage 17 | coverage 18 | 43% 19 | 43% 20 | 21 | 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 | ![Workflow Diagnostics](img.png) 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 |

Gito: AI Code Reviewer

2 |

3 | PYPI Release 4 | PyLint 5 | Tests 6 | Code Coverage 7 | License 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 | --------------------------------------------------------------------------------