From f651abc0f4dee72ee79dc6ee02b1d9aecc8b361f Mon Sep 17 00:00:00 2001 From: Renan Fernandes Date: Sat, 27 Jun 2026 08:54:58 -0300 Subject: [PATCH] fix: stabilize gitauditor, improve timeouts, formatting, and datetime --- .coverage | Bin 0 -> 53248 bytes .gitignore | 17 +++++ pyproject.toml | 25 +++++++ repomix-output.xml | 72 +++++++++++++++---- requirements.txt | 1 + src/gitauditor.egg-info/PKG-INFO | 66 ----------------- src/gitauditor.egg-info/SOURCES.txt | 23 ------ src/gitauditor.egg-info/dependency_links.txt | 1 - src/gitauditor.egg-info/entry_points.txt | 2 - src/gitauditor.egg-info/requires.txt | 7 -- src/gitauditor.egg-info/top_level.txt | 1 - src/gitauditor/cli.py | 44 ++++++------ src/gitauditor/commands/amend_cmd.py | 10 +-- src/gitauditor/commands/audit_cmd.py | 4 +- src/gitauditor/commands/catalog_cmd.py | 52 ++++++++------ src/gitauditor/commands/changelog_cmd.py | 9 +-- src/gitauditor/commands/config_cmd.py | 24 ++++++- src/gitauditor/commands/policy_cmd.py | 34 ++++----- src/gitauditor/commands/repo_app.py | 18 ++--- src/gitauditor/commands/repo_cmd.py | 4 +- src/gitauditor/commands/review_cmd.py | 8 +-- src/gitauditor/commands/ssh_cmd.py | 4 +- src/gitauditor/commands/worktree_cmd.py | 33 +++++---- src/gitauditor/core/ai_api.py | 47 +++++++----- src/gitauditor/core/audit_log.py | 26 +++---- src/gitauditor/core/catalog.py | 8 ++- src/gitauditor/core/config.py | 7 +- src/gitauditor/core/git_ops.py | 19 ++--- src/gitauditor/core/heuristics.py | 9 ++- src/gitauditor/core/models.py | 44 ++++++------ src/gitauditor/core/policy_engine.py | 16 +++-- src/gitauditor/core/scanner.py | 5 +- src/gitauditor/core/semantic.py | 10 +-- src/gitauditor/core/ssh_audit.py | 14 ++-- tests/test_cli_commands.py | 21 ++++++ tests/test_point1_rebase_merges.py | 3 +- tests/test_point2_async_audit.py | 7 +- tests/test_point3_windows_rebase.py | 3 +- tests/test_point5_duplicates.py | 2 +- 39 files changed, 384 insertions(+), 316 deletions(-) create mode 100644 .coverage delete mode 100644 src/gitauditor.egg-info/PKG-INFO delete mode 100644 src/gitauditor.egg-info/SOURCES.txt delete mode 100644 src/gitauditor.egg-info/dependency_links.txt delete mode 100644 src/gitauditor.egg-info/entry_points.txt delete mode 100644 src/gitauditor.egg-info/requires.txt delete mode 100644 src/gitauditor.egg-info/top_level.txt create mode 100644 tests/test_cli_commands.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..733ee0286dd15dfc1ee18adc425c5d3744696e88 GIT binary patch literal 53248 zcmeI4UyR&F9mnlm-|pG#_3Yg$y@pWERhwQ;x!mOxnvj-~UehBKDlTb4^9LHAZ`XHc zvb(<6-Xup!n=NUB-~m(-qzVu`AW$X#y&xzc6p^ojB+JtMrYT91O5Y-V zlPymXB{`r_?uf4xRi)7<4i?#?lAb#tvBSj=uub}${(9lt`bX5y6x{rXI!X!HKmY_l z00dSIfz}>9Kd^SK{M5VIi}mFx)nQg8*^qj?^eJ?;hEBAb^3W^^s(5~tYd_=Ljmo?ejF|RP3o9P!c_NHj>wERF&@*dpLo~Ay zxS{JcOy`7CX@*X9*#?eqXvs$KI$J}srg?*=qmpr59dz1-mnj)%j|dsWw^GTK%7$Lt*c6{3T*67m7vYVkNjxFkZYn*oa>Av3lz~G>K z=BO|fj>npD!(b_*b{Wmm=@m?er>|@`e3Wz>2_HqZn(57oA;U|XQRk2~caE8{1cvSN zfo6JfM$+_swKlFS>0r*(yqRuW6BZLVG(gA~B-v)0jS3$s0dGUaJLdTIoHH7yMsDH# zHY75gaWywEIk=MPESZ2cz7*A3YjgR5b?fA(RAEMOZ^c2r%Y>4iT*i$iNp;5#nttL) z-pNTOCE8vx86$BDHD^;kH!!horRK!>tnoO~+sZ5Xfi-L7Rx~2GU*g}Mu14@P?ICc@ zL~!HkFWBL{dA;uZG$}sRlN-2q%}R=M6|8Z-&z&x1gvwve3i@?&`uv;iQkkD=tNbZv zanmuoM&4w>c6}PMKJQ#7E}D%Vv_mI!>yF8@2xWv}9k&Cs;yaWW&!s{!A~t`mR*{%b zP)g!Mqt*-$W#7qmco$pae9u?Oz5G(HH7Mr?Hg1$#&3FVy&dCbsN}z5hZH*5zU6yrC zV%x~3$V<~DQm1io;2jM4VN?V6h*7uCMCg)S@dc7L%oq4FdrP7ZHV^;-5C8!X009sH z0T2KI5C8!X0D;vHah;{(JdOaD#Kor*lXpPkbFxc`8(59KdvLr8oo-9tafi$A|CiOkJ>8!P1lQOw9Bi0x<>;+00ck)1V8`;KtPf#r8Oa^bHNq8F{^w1 zUp}a`?oLa7*ZRNzvr21wijq6m|9uCP)|ND7C+q*xjMCbbmf+>r|14!Lo$G(&F{O21 z%Ide*|HZ>fYe&k`JJ$btnx?+Y`oA!(w05Sfe%Jb6OW7)y*8l2zpn2E&zc*zs&|d%N zQ)aTX{?8p!THDg<&F=MoPs%jf>wm>oT9auhz3low`yPzZj`e?LN@?AprZXLh^*`SK zhl?B#009sH0T2KI5C8!X009sH0T5W#1hR5gD)RgPGJ9L14>k}00T2KI5C8!X009sH z0T2KI5CDNyNFbXns^a;7!yKznDapfz!GSdB8fyev4`HQQsK51$c zf1vcM|Lz_Cs~lA!N#`i0NRmPK$fZ+#D&@G79s3qf&1Pwr%lFc$Qt=DxwLF)kUy+`% z&)t%v_^5WJk)bTqym&j8@8OwyAI*#^6giyz?SE!PQg)hxb6lR-rp5lv=z#CPsAakE z@};xSZYk$76gM*R$K@VXZ5jT{gM)8B{)W}gElH)0gDC#^Rqg5vhV(+5U`o=7mu@}3 zSwFuyBcA_fCnfe4`xm>&e!{-To@J-m%j}2j&+M!0kL&_F$1o#gHvQZX6zF!1|J`v=0.12.0", "sqlmodel>=0.0.16", "tenacity>=8.0.0", + "azure-identity>=1.15.0", ] [project.scripts] @@ -24,3 +25,27 @@ gitauditor = "gitauditor.__main__:main" [tool.setuptools.packages.find] where = ["src"] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.1.0", + "pytest-asyncio>=0.23.5", + "ruff>=0.3.0", +] + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra -q --cov=src" +testpaths = [ + "tests", +] +asyncio_default_fixture_loop_scope = "function" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP"] +ignore = ["E501", "E402", "F841"] diff --git a/repomix-output.xml b/repomix-output.xml index a23092f..ee5cdfb 100644 --- a/repomix-output.xml +++ b/repomix-output.xml @@ -42,26 +42,72 @@ The content is organized as follows: +.github/ + workflows/ + ci.yml src/ - commands/ - __init__.py - amend_cmd.py - audit_cmd.py - repo_cmd.py - ssh_cmd.py - core/ - git_ops.py - ollama_api.py - scanner.py - ssh_audit.py - __main__.py - cli.py + gitauditor/ + commands/ + __init__.py + amend_cmd.py + audit_cmd.py + catalog_cmd.py + changelog_cmd.py + config_cmd.py + policy_cmd.py + repo_app.py + repo_cmd.py + review_cmd.py + ssh_cmd.py + worktree_cmd.py + core/ + ai_api.py + audit_log.py + catalog.py + config.py + enricher.py + git_ops.py + heuristics.py + models.py + policy_engine.py + scanner.py + semantic.py + ssh_audit.py + locales/ + en_US/ + LC_MESSAGES/ + gitauditor.mo + gitauditor.po + messages.mo + messages.po + pt_BR/ + LC_MESSAGES/ + gitauditor.mo + gitauditor.po + messages.mo + messages.po + gitauditor.pot + __main__.py + cli.py + gitauditor.egg-info/ + dependency_links.txt + entry_points.txt + PKG-INFO + requires.txt + SOURCES.txt + top_level.txt tests/ + test_enricher.py test_point1_rebase_merges.py test_point2_async_audit.py test_point3_windows_rebase.py test_point5_duplicates.py .gitignore +BLUEPRINT.md +pyproject.toml +README_pt.md README.md requirements.txt +test_azure_key.py +test_azure.py diff --git a/requirements.txt b/requirements.txt index 05502a9..5afc8d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ httpx>=0.27.0 rich>=13.7.1 pyyaml>=6.0.1 tenacity>=8.0.0 +azure-identity>=1.15.0 diff --git a/src/gitauditor.egg-info/PKG-INFO b/src/gitauditor.egg-info/PKG-INFO deleted file mode 100644 index b4d141d..0000000 --- a/src/gitauditor.egg-info/PKG-INFO +++ /dev/null @@ -1,66 +0,0 @@ -Metadata-Version: 2.4 -Name: gitauditor -Version: 0.1.0 -Summary: Manage, audit, and organize Git repositories locally. -Requires-Python: >=3.10 -Description-Content-Type: text/markdown -Requires-Dist: textual>=0.86.0 -Requires-Dist: GitPython>=3.1.43 -Requires-Dist: httpx>=0.27.0 -Requires-Dist: rich>=13.7.1 -Requires-Dist: pyyaml>=6.0.1 -Requires-Dist: typer>=0.12.0 -Requires-Dist: sqlmodel>=0.0.16 - -# GitAuditor 🛡️🤖 - -GitAuditor é uma ferramenta de **linha de comando (CLI)** construída em Python (utilizando a biblioteca `Rich`) focada em gerenciar, auditar e organizar repositórios Git na sua máquina local. Ele centraliza a visualização dos seus repositórios, avalia permissões SSH, encontra repositórios duplicados e integra-se nativamente com Inteligência Artificial (**Ollama**) para reescrever o histórico de commits de forma totalmente automatizada. - -## Funcionalidades Principais - -1. 🔍 **Auditoria Global Assíncrona:** Busca rapidamente por diretórios e repositórios Git ocultos em toda a sua máquina e avalia o status das conexões remotas via comandos assíncronos não-bloqueantes. -2. 🧹 **Varredura de Duplicatas e Branches:** Encontra repositórios clonados múltiplas vezes, independente de estarem usando HTTPS ou SSH, facilitando a limpeza do seu disco. -3. 🤖 **AI Commit Amend (Ollama):** Reescreva todo o seu histórico local! A IA avalia os `diffs` dos commits antigos e reescreve as mensagens (Rebase Interativo) baseando-se nas melhores práticas. Inclui suporte a pagição, lotes sequenciais (Batch Processing) e proteção nativa da árvore de merges. -4. 🔑 **Gerenciador de Identidades SSH:** Analisa sua pasta `~/.ssh`, lista as criptografias ativas e realiza testes de autenticação reais com os servidores do GitHub/GitLab. - -## Pré-requisitos - -1. **Python 3.10+** e **Git** instalado. -2. **Ollama:** Para usar a IA local, tenha o Ollama rodando (ex: `http://localhost:11434/`). - - O projeto utiliza por padrão o modelo `llama3`, mas você pode configurá-lo alterando no `src/core/ollama_api.py`. - -## Instalação e Execução - -Clone o repositório e crie um ambiente virtual: - -```bash -git clone https://github.com/refernandes/gitauditor.git -cd gitauditor - -# Criar e ativar o ambiente virtual (Linux/macOS) -python3 -m venv venv -source venv/bin/activate - -# Instalar dependências -pip install -r requirements.txt - -# Executar a aplicação -python -m src -``` - -## Arquitetura do Projeto (V2) - -A arquitetura do GitAuditor evoluiu de uma TUI monolítica para um CLI Modular (Clean Architecture): - -- `src/__main__.py`: Entrypoint e orquestrador. -- `src/cli.py`: Controlador principal do CLI, renderização de tabelas e painéis `Rich`. -- `src/commands/`: Comandos encapsulados (Audit, Repo, Amend, SSH). -- `src/core/`: Motores core (GitPython + Subprocess Async, Scanner Async, Ollama Caching via SHA-256). -- `tests/`: Bateria de testes isolados validando operações de Rebase, Push Async e Parsing. - -## Segurança - -O sistema foi rigorosamente auditado para evitar vulnerabilidades de Code Injection durante a criação de scripts de editor (`GIT_EDITOR`), e utiliza Sandboxing e Fallbacks multiplataforma para garantir estabilidade em Linux, macOS e Windows. - ---- -*Automatize sua gestão de versionamento com Inteligência e Precisão.* diff --git a/src/gitauditor.egg-info/SOURCES.txt b/src/gitauditor.egg-info/SOURCES.txt deleted file mode 100644 index 88f2b02..0000000 --- a/src/gitauditor.egg-info/SOURCES.txt +++ /dev/null @@ -1,23 +0,0 @@ -README.md -pyproject.toml -src/gitauditor/__main__.py -src/gitauditor/cli.py -src/gitauditor.egg-info/PKG-INFO -src/gitauditor.egg-info/SOURCES.txt -src/gitauditor.egg-info/dependency_links.txt -src/gitauditor.egg-info/entry_points.txt -src/gitauditor.egg-info/requires.txt -src/gitauditor.egg-info/top_level.txt -src/gitauditor/commands/__init__.py -src/gitauditor/commands/amend_cmd.py -src/gitauditor/commands/audit_cmd.py -src/gitauditor/commands/repo_cmd.py -src/gitauditor/commands/ssh_cmd.py -src/gitauditor/core/git_ops.py -src/gitauditor/core/ollama_api.py -src/gitauditor/core/scanner.py -src/gitauditor/core/ssh_audit.py -tests/test_point1_rebase_merges.py -tests/test_point2_async_audit.py -tests/test_point3_windows_rebase.py -tests/test_point5_duplicates.py \ No newline at end of file diff --git a/src/gitauditor.egg-info/dependency_links.txt b/src/gitauditor.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/gitauditor.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/gitauditor.egg-info/entry_points.txt b/src/gitauditor.egg-info/entry_points.txt deleted file mode 100644 index 0f96e0d..0000000 --- a/src/gitauditor.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -gitauditor = gitauditor.__main__:main diff --git a/src/gitauditor.egg-info/requires.txt b/src/gitauditor.egg-info/requires.txt deleted file mode 100644 index 3b53e7e..0000000 --- a/src/gitauditor.egg-info/requires.txt +++ /dev/null @@ -1,7 +0,0 @@ -textual>=0.86.0 -GitPython>=3.1.43 -httpx>=0.27.0 -rich>=13.7.1 -pyyaml>=6.0.1 -typer>=0.12.0 -sqlmodel>=0.0.16 diff --git a/src/gitauditor.egg-info/top_level.txt b/src/gitauditor.egg-info/top_level.txt deleted file mode 100644 index b959185..0000000 --- a/src/gitauditor.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -gitauditor diff --git a/src/gitauditor/cli.py b/src/gitauditor/cli.py index ff127de..84b9f05 100644 --- a/src/gitauditor/cli.py +++ b/src/gitauditor/cli.py @@ -1,23 +1,24 @@ -import os import asyncio -import typer -from rich.console import Console -from rich.table import Table -from rich.panel import Panel -from rich.prompt import Prompt # --- Inicialização da Internacionalização (i18n) --- import gettext import json +import os + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Prompt +from rich.table import Table lang_to_use = "pt_BR" try: config_path = os.path.expanduser("~/.gitauditor.json") if os.path.exists(config_path): - with open(config_path, "r") as f: + with open(config_path) as f: cfg = json.load(f) lang_to_use = cfg.get("lang", "pt_BR") -except: +except Exception: pass localedir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'locales') @@ -30,15 +31,14 @@ builtins.__dict__['_'] = lambda x: x # ---------------------------------------------------- -from gitauditor.core.scanner import GitScanner -from gitauditor.core.ai_api import AIClient - -from gitauditor.commands.ssh_cmd import handle_manage_ssh from gitauditor.commands.catalog_cmd import catalog_app -from gitauditor.commands.worktree_cmd import worktree_app from gitauditor.commands.config_cmd import config_command from gitauditor.commands.policy_cmd import policy_app from gitauditor.commands.repo_app import repo_app +from gitauditor.commands.ssh_cmd import handle_manage_ssh +from gitauditor.commands.worktree_cmd import worktree_app +from gitauditor.core.ai_api import AIClient +from gitauditor.core.scanner import GitScanner app = typer.Typer( help=_("GitAuditor - O seu assistente IA e motor de políticas para repositórios Git."), @@ -88,7 +88,7 @@ def run(self): f"[9] 🏷️ Filtrar Tabela (Atual: [bold green]{self.current_filter}[/bold green])" ) console.print("[0] 🚪 Sair") - + total_pages = (total_filtered + self.page_size - 1) // self.page_size if total_filtered > 0 else 1 if total_pages > 1: console.print(f"[dim]Página {self.current_page + 1}/{total_pages} - Digite 'n' para próxima, 'p' para anterior[/dim]") @@ -112,6 +112,7 @@ def run(self): else: self.current_page = total_pages - 1 elif choice == "1": + from gitauditor.commands.repo_cmd import handle_repo_details handle_repo_details(self) elif choice == "2": from gitauditor.commands.catalog_cmd import open_repo @@ -126,6 +127,7 @@ def run(self): Prompt.ask("\n[dim]Pressione ENTER para continuar[/dim]") elif choice == "4": from rich.prompt import Confirm + from gitauditor.commands.catalog_cmd import dedupe_repos plan = Confirm.ask("Rodar em modo seguro (Dry-Run)?", default=True) @@ -136,8 +138,8 @@ def run(self): console.print("[2] Criar Nova Worktree") wc = Prompt.ask("Opção", choices=["1", "2"]) from gitauditor.commands.worktree_cmd import ( - list_worktrees, create_worktree, + list_worktrees, ) q = Prompt.ask("Nome do repositório original") @@ -184,7 +186,6 @@ def _show_ai_menu(self): handle_ai_amend(self) elif ai_choice == "2": - import asyncio from gitauditor.commands.review_cmd import review_command if not self.repos: @@ -257,9 +258,10 @@ def _show_ai_menu(self): Prompt.ask("\n[dim]Pressione ENTER para continuar[/dim]") def _load_catalog(self, silent=False): + from sqlmodel import Session, select + from gitauditor.core.catalog import engine, init_db from gitauditor.core.models import Repo - from sqlmodel import Session, select init_db() try: @@ -383,7 +385,7 @@ def _show_repo_table(self) -> int: for idx, repo_path in enumerate(self.repos): status_obj = self.repo_status.get(repo_path, {"icon": "⚪", "reason": ""}) icon = status_obj if isinstance(status_obj, str) else status_obj["icon"] - + if self.current_filter == "Apenas OK" and "🟢" not in icon: continue if self.current_filter == "Apenas Negados" and "🔴" not in icon: @@ -393,7 +395,7 @@ def _show_repo_table(self) -> int: filtered_repos.append((idx, repo_path, status_obj)) total_filtered = len(filtered_repos) - + # Adjust page if out of bounds max_page = max(0, (total_filtered - 1) // self.page_size) if self.current_page > max_page: @@ -421,7 +423,7 @@ def _show_repo_table(self) -> int: console.print( f"[yellow]Nenhum repositório corresponde ao filtro: {self.current_filter}[/yellow]" ) - + return total_filtered def _action_filter_table(self): @@ -445,8 +447,6 @@ def _action_filter_table(self): self.current_page = 0 -app = typer.Typer(help="GitAuditor CLI - IA Manager", invoke_without_command=True) - # Registra os Sub-Apps Oficiais app.add_typer(catalog_app, name="catalog", help=_("Catálogo Local de Repositórios")) app.add_typer(repo_app, name="repo", help=_("Operações de Repositório (IA, Changelog, Amend)")) diff --git a/src/gitauditor/commands/amend_cmd.py b/src/gitauditor/commands/amend_cmd.py index 65b61cd..cc68c49 100644 --- a/src/gitauditor/commands/amend_cmd.py +++ b/src/gitauditor/commands/amend_cmd.py @@ -1,9 +1,11 @@ -import git import asyncio + +import git from rich.console import Console from rich.panel import Panel +from rich.prompt import IntPrompt, Prompt from rich.table import Table -from rich.prompt import Prompt, IntPrompt + from gitauditor.core.git_ops import GitService console = Console() @@ -183,7 +185,7 @@ def _process_single_amend( "[bold green]✅ Commit atualizado com sucesso via rebase![/bold green]" ) console.print(f"[dim]Backup guardado na branch: {backup_branch}[/dim]") - + if Prompt.ask("Deseja DESFAZER (Rollback) essa reescrita?", choices=["S", "N", "s", "n"], default="N").upper() == "S": GitService.rollback_amend(repo_path, backup_branch) console.print("[yellow]Rollback executado com sucesso! Histórico restaurado.[/yellow]") @@ -208,7 +210,7 @@ def _process_single_amend( "[bold green]✅ Commit atualizado com sucesso via rebase![/bold green]" ) console.print(f"[dim]Backup guardado na branch: {backup_branch}[/dim]") - + if Prompt.ask("Deseja DESFAZER (Rollback) essa reescrita?", choices=["S", "N", "s", "n"], default="N").upper() == "S": GitService.rollback_amend(repo_path, backup_branch) console.print("[yellow]Rollback executado com sucesso! Histórico restaurado.[/yellow]") diff --git a/src/gitauditor/commands/audit_cmd.py b/src/gitauditor/commands/audit_cmd.py index f730c73..880615d 100644 --- a/src/gitauditor/commands/audit_cmd.py +++ b/src/gitauditor/commands/audit_cmd.py @@ -1,10 +1,12 @@ import os import shutil from collections import defaultdict + from rich.console import Console from rich.panel import Panel +from rich.prompt import IntPrompt, Prompt from rich.table import Table -from rich.prompt import Prompt, IntPrompt + from gitauditor.core.git_ops import GitService console = Console() diff --git a/src/gitauditor/commands/catalog_cmd.py b/src/gitauditor/commands/catalog_cmd.py index fa84696..1cc3332 100644 --- a/src/gitauditor/commands/catalog_cmd.py +++ b/src/gitauditor/commands/catalog_cmd.py @@ -1,17 +1,18 @@ -import typer +import asyncio import os import platform -import asyncio import string -from datetime import datetime +from datetime import datetime, timezone + +import typer from rich.console import Console +from rich.table import Table from sqlmodel import Session, select from gitauditor.core.catalog import engine, init_db +from gitauditor.core.enricher import enrich_all from gitauditor.core.models import Repo from gitauditor.core.scanner import GitScanner -from gitauditor.core.enricher import enrich_all -from rich.table import Table console = Console() catalog_app = typer.Typer(help="Gerenciamento do Catálogo Local de Repositórios") @@ -51,7 +52,7 @@ def sync_catalog(): repo.owner = data["owner"] repo.canonical_name = data["canonical_name"] repo.status = data["status"] - repo.updated_at = datetime.utcnow() + repo.updated_at = datetime.now(timezone.utc) session.commit() @@ -144,35 +145,36 @@ def dedupe_repos( return import shutil + from rich.prompt import Confirm - + for c, reps in duplicados.items(): console.print(f"\n[bold magenta]Resolvendo duplicação para:[/bold magenta] {c}") for i, r in enumerate(reps): console.print(f"[{i}] Manter: {r.path}") - - choice = typer.prompt(f"Digite o número do repositório a MANTER (os outros serão excluídos). Digite -1 para pular", type=int, default=-1) + + choice = typer.prompt("Digite o número do repositório a MANTER (os outros serão excluídos). Digite -1 para pular", type=int, default=-1) if 0 <= choice < len(reps): to_keep = reps[choice] to_delete = [r for idx, r in enumerate(reps) if idx != choice] - + # GUARDRAIL: Lock / Dirty Check safe_to_delete = [] for d in to_delete: import subprocess - res = subprocess.run(["git", "status", "--porcelain"], cwd=d.path, capture_output=True, text=True) + res = subprocess.run(["git", "status", "--porcelain"], cwd=d.path, capture_output=True, text=True, timeout=15) if res.stdout.strip() != "": console.print(f"[red]⚠️ Bloqueio de Segurança:[/red] {d.path} tem mudanças não commitadas! Abortando exclusão deste diretório.") else: safe_to_delete.append(d) - + if not safe_to_delete: continue - + console.print("[yellow]Atenção: Os seguintes diretórios serão APAGADOS do disco:[/yellow]") for d in safe_to_delete: console.print(f"- {d.path}") - + if Confirm.ask("Tem certeza absoluta? (Não há lixeira)"): for d in safe_to_delete: try: @@ -241,12 +243,14 @@ def summarize_catalog( [P3] Analisa a árvore de pastas e manifestos para gerar metadados semânticos via IA. """ import asyncio + from datetime import datetime + + from sqlmodel import Session, select + + from gitauditor.core.ai_api import AIClient from gitauditor.core.catalog import engine, init_db from gitauditor.core.models import Repo - from sqlmodel import Session, select from gitauditor.core.semantic import extract_repo_context - from gitauditor.core.ai_api import AIClient - from datetime import datetime init_db() client = AIClient() @@ -279,7 +283,7 @@ async def analyze_all(): console.print("[green]✓ Cache válido. Hash não mudou.[/green]") continue - console.print(f"[dim]Construindo contexto e chamando Ollama...[/dim]") + console.print("[dim]Construindo contexto e chamando IA...[/dim]") ctx_str = f"TREE:\n{context['tree']}\n\nMANIFESTS:\n{context['manifests']}\n\nREADME:\n{context['readme']}" result = await client.analyze_repo_semantics(ctx_str) @@ -297,7 +301,7 @@ async def analyze_all(): # Governance repo.ai_model = client.model repo.ai_source_hash = current_hash - repo.ai_updated_at = datetime.utcnow() + repo.ai_updated_at = datetime.now(timezone.utc) session.add(repo) session.commit() @@ -319,19 +323,21 @@ async def analyze_all(): def tag_auto_catalog( path: str = typer.Option(None, help="Filtrar por nome do repositório"), no_ai: bool = typer.Option( - False, "--no-ai", help="Usar apenas heurística bruta (sem Ollama)" + False, "--no-ai", help="Usar apenas heurística bruta (sem IA)" ), ): """ [P3.2] Híbrido: Gera e aplica tags automaticamente (Heurística determinística + LLM). """ import asyncio - from gitauditor.core.catalog import engine, init_db - from gitauditor.core.models import Repo + from sqlmodel import Session, select + + from gitauditor.core.ai_api import AIClient + from gitauditor.core.catalog import engine, init_db from gitauditor.core.heuristics import generate_heuristic_tags + from gitauditor.core.models import Repo from gitauditor.core.semantic import extract_repo_context - from gitauditor.core.ai_api import AIClient init_db() client = AIClient() diff --git a/src/gitauditor/commands/changelog_cmd.py b/src/gitauditor/commands/changelog_cmd.py index 642afaf..6cbf06b 100644 --- a/src/gitauditor/commands/changelog_cmd.py +++ b/src/gitauditor/commands/changelog_cmd.py @@ -1,10 +1,11 @@ -import os import asyncio +import os + +import git import typer from rich.console import Console -from rich.panel import Panel from rich.markdown import Markdown -import git +from rich.panel import Panel console = Console() @@ -67,7 +68,7 @@ def changelog_command( commits_log = commits_log[:max_log_length] + "\n...[TRUNCATED]" console.print( - "[cyan]Chamando Ollama para gerar Changelog (isso pode levar alguns segundos)...[/cyan]" + "[cyan]Chamando IA para gerar Changelog (isso pode levar alguns segundos)...[/cyan]" ) from gitauditor.core.ai_api import AIClient diff --git a/src/gitauditor/commands/config_cmd.py b/src/gitauditor/commands/config_cmd.py index 34d676d..0b93e81 100644 --- a/src/gitauditor/commands/config_cmd.py +++ b/src/gitauditor/commands/config_cmd.py @@ -1,6 +1,6 @@ -import typer from rich.console import Console from rich.prompt import Prompt + from gitauditor.core.config import ConfigManager console = Console() @@ -20,17 +20,18 @@ def config_command(): "[dim]Escolha qual provedor de Inteligência Artificial você quer usar.[/dim]\n" ) - provider_choices = ["ollama", "openai", "openrouter"] + provider_choices = ["ollama", "openai", "openrouter", "azure"] current_provider = ai_config.get("provider", "ollama") console.print("Provedores disponíveis:") console.print(" [1] Ollama (Local, Gratuito, Seguro)") console.print(" [2] OpenAI (Nuvem, Pago, Muito inteligente)") console.print(" [3] OpenRouter (Nuvem, Multi-modelos, Pago/Gratuito)") + console.print(" [4] Azure AI (Nuvem Corporativa Microsoft)") choice = Prompt.ask( "Selecione o provedor", - choices=["1", "2", "3"], + choices=["1", "2", "3", "4"], default=str(provider_choices.index(current_provider) + 1) if current_provider in provider_choices else "1", @@ -79,6 +80,23 @@ def config_command(): ai_config["base_url"] = "https://openrouter.ai/api/v1" + elif selected_provider == "azure": + current_model = ai_config.get("model", "gpt-4o") + ai_config["model"] = Prompt.ask("Qual deployment name (modelo)?", default=current_model) + + current_url = ai_config.get("base_url", "https://renansousa-2956-resource.services.ai.azure.com/openai/v1") + ai_config["base_url"] = Prompt.ask("URL base do Azure AI", default=current_url) + + use_default_cred = Prompt.ask("Usar Entra ID (DefaultAzureCredential)? [S/n]", default="s") + if use_default_cred.lower() == 's': + ai_config["api_key"] = "azure_default_credential" + else: + current_key = ai_config.get("api_key", "") + mask = "*" * 10 + current_key[-4:] if len(current_key) > 4 else "" + new_key = Prompt.ask(f"Sua API Key do Azure [dim](Atual: {mask})[/dim]", password=True) + if new_key: + ai_config["api_key"] = new_key + # Language Selection console.print("\n[dim]Configuração de Idioma / Language Settings[/dim]") current_lang = config.get("lang", "pt_BR") diff --git a/src/gitauditor/commands/policy_cmd.py b/src/gitauditor/commands/policy_cmd.py index f1be146..a167775 100644 --- a/src/gitauditor/commands/policy_cmd.py +++ b/src/gitauditor/commands/policy_cmd.py @@ -1,14 +1,13 @@ -import os import json +import os + import typer -from typing import Optional from rich.console import Console from rich.table import Table -from rich.panel import Panel +from sqlmodel import Session, select from gitauditor.core.catalog import engine, init_db from gitauditor.core.models import Repo -from sqlmodel import Session, select from gitauditor.core.policy_engine import PolicyEngine console = Console() @@ -50,11 +49,11 @@ def find_repo_or_exit(query: str): @policy_app.command("check") def check_policy( - query: Optional[str] = typer.Argument(None, help="Nome do repositório a ser verificado"), + query: str | None = typer.Argument(None, help="Nome do repositório a ser verificado"), output_json: bool = typer.Option(False, "--json", help="Retorna o output como JSON estruturado") ): """Verifica a saúde de governança de um repositório (README, CI, Secrets, etc).""" - + if query: paths_to_check = [find_repo_or_exit(query)] else: @@ -81,8 +80,8 @@ def check_policy( # Audit logging for the command from gitauditor.core.audit_log import AuditLogger AuditLogger.log( - "policy_check", - "SUCCESS", + "policy_check", + "SUCCESS", f"Checou políticas para {len(paths_to_check)} repo(s).", details=json.dumps({"checked_count": len(paths_to_check)}) ) @@ -97,12 +96,12 @@ def check_policy( if "error" in report: console.print(f"[red]Erro ao checar {os.path.basename(path)}: {report['error']}[/red]") continue - + repo_name = os.path.basename(path) score = report["score"] - + color = "green" if score >= 80 else "yellow" if score >= 50 else "red" - + table = Table(title=f"Governance Report: {repo_name} (Score: [{color}]{score}/100[/{color}])", show_header=True) table.add_column("Critério", style="cyan") table.add_column("Status", justify="center") @@ -115,29 +114,30 @@ def _format_status(passed: bool) -> str: table.add_row("Gitignore", _format_status(report["checks"]["gitignore"])) table.add_row("CI/CD Pipeline", _format_status(report["checks"]["ci_cd"])) table.add_row("Community (CODEOWNERS/etc)", _format_status(report["checks"]["codeowners"] and report["checks"]["contributing"] and report["checks"]["security"])) - + env_status = "[red]❌ VAZADO[/red]" if report["checks"]["env_exposed"] else "[green]✅ Seguro[/green]" table.add_row("Segurança (.env commitado)", env_status) console.print(table) - + if report["critical"]: for crit in report["critical"]: console.print(f"[bold red]!! {crit} !![/bold red]") - + if report["warnings"]: console.print("[yellow]Warnings:[/yellow]") for w in report["warnings"]: console.print(f" - {w}") - + console.print("") # spacing @policy_app.command("log") def policy_log(limit: int = 20): """Exibe o histórico de auditoria local (comandos executados e IA).""" - from gitauditor.core.audit_log import audit_engine, AuditRecord, init_audit_db - from sqlmodel import Session, select from rich.table import Table + from sqlmodel import Session, select + + from gitauditor.core.audit_log import AuditRecord, audit_engine, init_audit_db init_audit_db() with Session(audit_engine) as session: diff --git a/src/gitauditor/commands/repo_app.py b/src/gitauditor/commands/repo_app.py index 4eec8a6..7e3868e 100644 --- a/src/gitauditor/commands/repo_app.py +++ b/src/gitauditor/commands/repo_app.py @@ -1,17 +1,17 @@ import typer -import os from rich.console import Console console = Console() repo_app = typer.Typer( - help=_("Operações de Repositório (Revisão, Changelog, Histórico, etc)"), - epilog=_(""" + help="Operações de Repositório (Revisão, Changelog, Histórico, etc)", + epilog=""" Exemplos práticos: $ gitauditor repo review . # Analisa o diff no diretório atual - $ gitauditor repo changelog --limit 10 # Gera notas de release para 10 commits - $ gitauditor repo amend # Reescreve histórico interativamente - $ gitauditor repo details # Visualiza detalhes de um projeto -""") + $ gitauditor repo changelog . # Gera notas de release para os novos commits + $ gitauditor repo analyze . # Avalia tech-stack, dependências e padrões + $ gitauditor repo reword . a1b2c3d # Reescreve a mensagem de um commit antigo interativamente + $ gitauditor repo history . # Exibe histórico visual de commits recentes +""" ) @repo_app.command("review") @@ -29,8 +29,8 @@ def repo_changelog(path: str = typer.Option(".", help="Caminho do repositório") @repo_app.command("amend") def repo_amend(): """Abre fluxo interativo para reescrever commits com IA.""" - from gitauditor.commands.amend_cmd import handle_ai_amend from gitauditor.cli import cli_state + from gitauditor.commands.amend_cmd import handle_ai_amend cli_state._load_catalog() cli_state._show_repo_table() handle_ai_amend(cli_state) @@ -38,8 +38,8 @@ def repo_amend(): @repo_app.command("details") def repo_details(): """Visualiza detalhes e gerencia um repositório interativamente.""" - from gitauditor.commands.repo_cmd import handle_repo_details from gitauditor.cli import cli_state + from gitauditor.commands.repo_cmd import handle_repo_details cli_state._load_catalog() cli_state._show_repo_table() handle_repo_details(cli_state) diff --git a/src/gitauditor/commands/repo_cmd.py b/src/gitauditor/commands/repo_cmd.py index e56777c..be88e34 100644 --- a/src/gitauditor/commands/repo_cmd.py +++ b/src/gitauditor/commands/repo_cmd.py @@ -1,9 +1,11 @@ import os import shutil + from rich.console import Console from rich.panel import Panel +from rich.prompt import IntPrompt, Prompt from rich.table import Table -from rich.prompt import Prompt, IntPrompt + from gitauditor.core.git_ops import GitService console = Console() diff --git a/src/gitauditor/commands/review_cmd.py b/src/gitauditor/commands/review_cmd.py index 78810b1..01646df 100644 --- a/src/gitauditor/commands/review_cmd.py +++ b/src/gitauditor/commands/review_cmd.py @@ -1,9 +1,9 @@ -import os import asyncio +import os + +import git import typer from rich.console import Console -from rich.panel import Panel -import git console = Console() @@ -56,7 +56,7 @@ def review_command( diff_text = diff_text[:max_diff_length] + "\n...[TRUNCATED]" console.print( - "[cyan]Chamando Ollama para review (isso pode levar alguns segundos)...[/cyan]" + "[cyan]Chamando IA para review (isso pode levar alguns segundos)...[/cyan]" ) from gitauditor.core.ai_api import AIClient diff --git a/src/gitauditor/commands/ssh_cmd.py b/src/gitauditor/commands/ssh_cmd.py index d799aab..a0a60c5 100644 --- a/src/gitauditor/commands/ssh_cmd.py +++ b/src/gitauditor/commands/ssh_cmd.py @@ -1,8 +1,10 @@ import asyncio + from rich.console import Console from rich.panel import Panel -from rich.table import Table from rich.prompt import Prompt +from rich.table import Table + from gitauditor.core.ssh_audit import IdentityManager console = Console() diff --git a/src/gitauditor/commands/worktree_cmd.py b/src/gitauditor/commands/worktree_cmd.py index a85aad8..36c5c58 100644 --- a/src/gitauditor/commands/worktree_cmd.py +++ b/src/gitauditor/commands/worktree_cmd.py @@ -1,8 +1,10 @@ -import typer import os import subprocess + +import typer from rich.console import Console from sqlmodel import Session, select + from gitauditor.core.catalog import engine, init_db from gitauditor.core.models import Repo @@ -74,6 +76,7 @@ def create_worktree(query: str, branch: str): cwd=path, capture_output=True, text=True, + timeout=15, ) if res.returncode == 0: console.print("[bold green]✅ Worktree criada com sucesso![/bold green]") @@ -86,17 +89,17 @@ def create_worktree(query: str, branch: str): @worktree_app.command("clean") def clean_worktrees(query: str, force: bool = typer.Option(False, "--force", "-f", help="Apaga sem perguntar (requer repositórios limpos)")): """Detecta worktrees órfãs, limpas ou sujas, e permite limpá-las com segurança.""" - import shutil - from rich.table import Table from rich.prompt import Confirm + from rich.table import Table + from gitauditor.core.audit_log import AuditLogger path = find_repo_or_exit(query) - + # Prune para remover refs mortas - subprocess.run(["git", "worktree", "prune"], cwd=path, capture_output=True) - - res = subprocess.run(["git", "worktree", "list", "--porcelain"], cwd=path, capture_output=True, text=True) + subprocess.run(["git", "worktree", "prune"], cwd=path, capture_output=True, timeout=15) + + res = subprocess.run(["git", "worktree", "list", "--porcelain"], cwd=path, capture_output=True, text=True, timeout=15) if res.returncode != 0: console.print("[red]Erro ao listar worktrees.[/red]") return @@ -116,7 +119,7 @@ def clean_worktrees(query: str, force: bool = typer.Option(False, "--force", "-f worktrees.append(current_wt) # Filtra a worktree principal (que costuma não ter a palavra worktree no seu .git, ou é a mesma raiz) - main_res = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=path, capture_output=True, text=True) + main_res = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=path, capture_output=True, text=True, timeout=15) main_root = main_res.stdout.strip() to_clean = [] @@ -142,12 +145,12 @@ def clean_worktrees(query: str, force: bool = typer.Option(False, "--force", "-f wt_size = sum(os.path.getsize(os.path.join(dirpath, filename)) for dirpath, _, filenames in os.walk(wt_path) for filename in filenames) except Exception: wt_size = 0 - + size_mb = wt_size / (1024 * 1024) total_bytes += wt_size - + # Verifica sujeira - status_res = subprocess.run(["git", "status", "--porcelain"], cwd=wt_path, capture_output=True, text=True) + status_res = subprocess.run(["git", "status", "--porcelain"], cwd=wt_path, capture_output=True, text=True, timeout=15) if status_res.stdout.strip() != "": status = "[yellow]Suja (Uncommitted)[/yellow]" else: @@ -157,13 +160,13 @@ def clean_worktrees(query: str, force: bool = typer.Option(False, "--force", "-f table.add_row(os.path.basename(wt_path), wt.get("branch", "detached").split("/")[-1], status, f"{size_mb:.1f} MB") console.print(table) - + if total_bytes == 0 and not to_clean: console.print("[dim]Nenhuma worktree secundária limpa para remover.[/dim]") return console.print(f"\n[bold green]Espaço recuperável estimado: {total_bytes / (1024*1024):.1f} MB[/bold green]") - + if not to_clean: console.print("[yellow]As worktrees encontradas estão SUJAS ou ausentes. Nenhuma ação automática será tomada.[/yellow]") return @@ -176,10 +179,10 @@ def clean_worktrees(query: str, force: bool = typer.Option(False, "--force", "-f removed = 0 for c_path, c_branch in to_clean: try: - subprocess.run(["git", "worktree", "remove", "-f", c_path], cwd=path, check=True, capture_output=True) + subprocess.run(["git", "worktree", "remove", "-f", c_path], cwd=path, check=True, capture_output=True, timeout=15) removed += 1 except subprocess.CalledProcessError as e: console.print(f"[red]Erro ao remover {c_path}:[/red] {e.stderr.decode()}") - + console.print(f"[bold green]Limpeza concluída! {removed} worktrees removidas.[/bold green]") AuditLogger.log("worktree_clean", "SUCCESS", f"Limpou {removed} worktrees em {os.path.basename(path)}", repo_path=path) diff --git a/src/gitauditor/core/ai_api.py b/src/gitauditor/core/ai_api.py index c17809d..8454d80 100644 --- a/src/gitauditor/core/ai_api.py +++ b/src/gitauditor/core/ai_api.py @@ -1,9 +1,11 @@ import json + import httpx -from typing import Optional +from rich import print from tenacity import retry, stop_after_attempt, wait_exponential -from gitauditor.core.config import ConfigManager + from gitauditor.core.audit_log import AuditLogger +from gitauditor.core.config import ConfigManager class AIClient: @@ -23,17 +25,19 @@ def __init__(self): self.base_url = self.ai_config.get( "base_url", "https://openrouter.ai/api/v1" ) + elif self.provider == "azure": + self.base_url = self.ai_config.get("base_url", "https://renansousa-2956-resource.services.ai.azure.com/openai/v1") else: self.base_url = self.ai_config.get("base_url", "") @retry( - stop=stop_after_attempt(3), + stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), - retry_error_callback=lambda _: None + retry_error_callback=lambda s: print(f"\n[bold red]Erro do LLM:[/bold red] {s.outcome.exception()}") or None ) async def _generate_structured( self, prompt: str, schema_dict: dict, timeout: float = 120.0 - ) -> Optional[dict]: + ) -> dict | None: """ Unified method to request structured JSON from different providers. Retries up to 3 times on failure. @@ -59,11 +63,18 @@ async def _generate_structured( raise Exception(f"Ollama API Error: {response.status_code} - {response.text}") else: - # OpenAI / OpenRouter Chat Completions API + # OpenAI / OpenRouter / Azure Chat Completions API headers = { - "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } + + if self.provider == "azure" and self.api_key == "azure_default_credential": + from azure.identity import DefaultAzureCredential + credential = DefaultAzureCredential() + token = credential.get_token("https://ai.azure.com/.default").token + headers["Authorization"] = f"Bearer {token}" + else: + headers["Authorization"] = f"Bearer {self.api_key}" if self.provider == "openrouter": headers["HTTP-Referer"] = "https://github.com/gitauditor" headers["X-Title"] = "GitAuditor" @@ -106,7 +117,7 @@ async def _generate_structured( async def analyze_commit_message( self, commit_msg: str, diff_text: str - ) -> Optional[str]: + ) -> str | None: prompt = ( "You are an expert Git hook enforcing conventional commits.\n" f"Original Message: {commit_msg}\n\n" @@ -119,7 +130,7 @@ async def analyze_commit_message( "properties": {"suggested_message": {"type": "string"}}, } res = await self._generate_structured(prompt, schema, timeout=30.0) - + status = "SUCCESS" if res else "ERROR" AuditLogger.log( command="ai_amend", @@ -129,10 +140,10 @@ async def analyze_commit_message( ai_model=self.model, details=json.dumps(res) if res else "RetryError or Invalid Response" ) - + return res.get("suggested_message") if res else None - async def analyze_repo_semantics(self, context_str: str) -> Optional[dict]: + async def analyze_repo_semantics(self, context_str: str) -> dict | None: from gitauditor.core.semantic import RepoSummarySchema prompt = ( @@ -144,7 +155,7 @@ async def analyze_repo_semantics(self, context_str: str) -> Optional[dict]: res = await self._generate_structured( prompt, RepoSummarySchema.model_json_schema() ) - + status = "SUCCESS" if res else "ERROR" AuditLogger.log( command="ai_summarize", @@ -157,7 +168,7 @@ async def analyze_repo_semantics(self, context_str: str) -> Optional[dict]: async def refine_repo_tags( self, context_str: str, heuristic_tags: list[str] - ) -> Optional[list[str]]: + ) -> list[str] | None: from gitauditor.core.semantic import RepoTagSchema prompt = ( @@ -167,7 +178,7 @@ async def refine_repo_tags( f"CONTEXT:\n{context_str}\n" ) res = await self._generate_structured(prompt, RepoTagSchema.model_json_schema()) - + status = "SUCCESS" if res else "ERROR" AuditLogger.log( command="ai_tagging", @@ -178,7 +189,7 @@ async def refine_repo_tags( ) return res.get("tags", heuristic_tags) if res else heuristic_tags - async def analyze_local_diff(self, diff_content: str) -> Optional[dict]: + async def analyze_local_diff(self, diff_content: str) -> dict | None: from gitauditor.core.semantic import RepoReviewSchema prompt = ( @@ -190,7 +201,7 @@ async def analyze_local_diff(self, diff_content: str) -> Optional[dict]: res = await self._generate_structured( prompt, RepoReviewSchema.model_json_schema() ) - + status = "SUCCESS" if res else "ERROR" AuditLogger.log( command="ai_review", @@ -201,7 +212,7 @@ async def analyze_local_diff(self, diff_content: str) -> Optional[dict]: ) return res - async def generate_changelog(self, commits_log: str) -> Optional[dict]: + async def generate_changelog(self, commits_log: str) -> dict | None: from gitauditor.core.semantic import RepoChangelogSchema prompt = ( @@ -213,7 +224,7 @@ async def generate_changelog(self, commits_log: str) -> Optional[dict]: res = await self._generate_structured( prompt, RepoChangelogSchema.model_json_schema() ) - + status = "SUCCESS" if res else "ERROR" AuditLogger.log( command="ai_changelog", diff --git a/src/gitauditor/core/audit_log.py b/src/gitauditor/core/audit_log.py index 6857d00..21491cd 100644 --- a/src/gitauditor/core/audit_log.py +++ b/src/gitauditor/core/audit_log.py @@ -1,7 +1,7 @@ -import os import datetime -from typing import Optional -from sqlmodel import Field, SQLModel, Session, create_engine +import os + +from sqlmodel import Field, Session, SQLModel, create_engine # Path setup for audit database HOME_DIR = os.path.expanduser("~") @@ -13,15 +13,15 @@ audit_engine = create_engine(sqlite_url) class AuditRecord(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - timestamp: datetime.datetime = Field(default_factory=datetime.datetime.utcnow) + id: int | None = Field(default=None, primary_key=True) + timestamp: datetime.datetime = Field(default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)) command: str - repo_path: Optional[str] = None - ai_provider: Optional[str] = None - ai_model: Optional[str] = None + repo_path: str | None = None + ai_provider: str | None = None + ai_model: str | None = None status: str # "SUCCESS", "ERROR", "WARNING" summary: str - details: Optional[str] = None # Could be JSON diff, exception trace, or raw output + details: str | None = None # Could be JSON diff, exception trace, or raw output def init_audit_db(): SQLModel.metadata.create_all(audit_engine) @@ -32,10 +32,10 @@ def log( command: str, status: str, summary: str, - repo_path: Optional[str] = None, - ai_provider: Optional[str] = None, - ai_model: Optional[str] = None, - details: Optional[str] = None + repo_path: str | None = None, + ai_provider: str | None = None, + ai_model: str | None = None, + details: str | None = None ): """Grava uma ação no log de auditoria persistente.""" try: diff --git a/src/gitauditor/core/catalog.py b/src/gitauditor/core/catalog.py index 52505b8..6bba63e 100644 --- a/src/gitauditor/core/catalog.py +++ b/src/gitauditor/core/catalog.py @@ -1,5 +1,6 @@ import os -from sqlmodel import create_engine, Session, SQLModel + +from sqlmodel import Session, SQLModel, create_engine # Importa para registrar no metadata do SQLModel @@ -15,13 +16,14 @@ def init_db(): SQLModel.metadata.create_all(engine) - + # Heurística simples de Migration: # Se uma nova versão adicionou colunas que não existem, o select vai falhar. # Como o banco é de cache efêmero, fazemos um Drop & Recreate para evitar crashs. + from sqlalchemy.exc import OperationalError from sqlmodel import Session, select + from gitauditor.core.models import Repo - from sqlalchemy.exc import OperationalError try: with Session(engine) as session: diff --git a/src/gitauditor/core/config.py b/src/gitauditor/core/config.py index 6885091..b057041 100644 --- a/src/gitauditor/core/config.py +++ b/src/gitauditor/core/config.py @@ -1,6 +1,5 @@ -import os import json -from pathlib import Path +import os CONFIG_DIR = os.path.expanduser("~/.gitauditor") CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") @@ -29,7 +28,7 @@ def load_config() -> dict: return config try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: + with open(CONFIG_FILE, encoding="utf-8") as f: return json.load(f) except Exception: return ConfigManager.get_default_config() @@ -40,7 +39,7 @@ def save_config(config: dict): os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True) with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, indent=4) - + # Security: ensure config.json is only readable/writable by the owner # since it stores sensitive API keys if hasattr(os, "chmod"): diff --git a/src/gitauditor/core/git_ops.py b/src/gitauditor/core/git_ops.py index 59bcbc2..37936d5 100644 --- a/src/gitauditor/core/git_ops.py +++ b/src/gitauditor/core/git_ops.py @@ -1,7 +1,7 @@ -import git -from typing import List, Dict import os +import git + class GitService: """Serviço para interagir com repositórios Git usando GitPython.""" @@ -19,7 +19,7 @@ def _sanitize_hash(commit_hash: str) -> str: return s @staticmethod - def get_repo_details(path: str) -> Dict: + def get_repo_details(path: str) -> dict: """Obtém detalhes de um repositório.""" try: repo = git.Repo(path) @@ -127,6 +127,7 @@ def start_interactive_rebase(path: str, commits_count: int = 5): env=env, check=True, capture_output=True, + timeout=15, ) except subprocess.CalledProcessError as e: raise Exception(f"Erro ao iniciar rebase: {e.stderr.decode()}") @@ -152,7 +153,7 @@ def continue_rebase(path: str): env["GIT_EDITOR"] = "true" try: subprocess.run( - ["git", "rebase", "--continue"], cwd=path, env=env, capture_output=True + ["git", "rebase", "--continue"], cwd=path, env=env, capture_output=True, timeout=15 ) except Exception: pass @@ -172,7 +173,7 @@ def get_latest_commit_info(path: str) -> dict: return info @staticmethod - def find_open_branches(path: str) -> List[str]: + def find_open_branches(path: str) -> list[str]: """Verifica branches locais existentes.""" try: repo = git.Repo(path) @@ -198,8 +199,8 @@ def extract_diff_for_commit(path: str, commit_hash: str) -> str: def reword_commit(path: str, commit_hash: str, new_message: str) -> bool: """Muda a mensagem de um commit antigo (mesmo longe no histórico) usando rebase interativo.""" commit_hash = GitService._sanitize_hash(commit_hash) - import tempfile import subprocess + import tempfile # Cria scripts Python temporários para atuar como editores não-interativos do Git seq_editor_fd, seq_editor_path = tempfile.mkstemp(text=True) @@ -217,7 +218,7 @@ def reword_commit(path: str, commit_hash: str, new_message: str) -> bool: # GUARDRAIL: Create a backup branch for rollback before destructive rebase import datetime - backup_branch_name = f"gitauditor-backup-{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}-{commit_hash[:7]}" + backup_branch_name = f"gitauditor-backup-{datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d%H%M%S')}-{commit_hash[:7]}" try: repo.create_head(backup_branch_name, "HEAD") except Exception: @@ -263,6 +264,7 @@ def reword_commit(path: str, commit_hash: str, new_message: str) -> bool: env=env, check=True, capture_output=True, + timeout=15, ) else: subprocess.run( @@ -278,11 +280,12 @@ def reword_commit(path: str, commit_hash: str, new_message: str) -> bool: env=env, check=True, capture_output=True, + timeout=15, ) return backup_branch_name except subprocess.CalledProcessError as e: - subprocess.run(["git", "rebase", "--abort"], cwd=path, capture_output=True) + subprocess.run(["git", "rebase", "--abort"], cwd=path, capture_output=True, timeout=15) raise Exception(f"Rebase failed: {e.stderr.decode()}") finally: if os.path.exists(seq_editor_path): diff --git a/src/gitauditor/core/heuristics.py b/src/gitauditor/core/heuristics.py index 37d5e1e..7e7f46f 100644 --- a/src/gitauditor/core/heuristics.py +++ b/src/gitauditor/core/heuristics.py @@ -1,8 +1,7 @@ import os -from typing import List -def generate_heuristic_tags(repo_path: str) -> List[str]: +def generate_heuristic_tags(repo_path: str) -> list[str]: """ P3.2: Deterministic auto-tagging based on path and file contents. Provides a solid baseline before LLM enrichment. @@ -41,7 +40,7 @@ def generate_heuristic_tags(repo_path: str) -> List[str]: pkg_json = os.path.join(repo_path, "package.json") if os.path.exists(pkg_json): try: - with open(pkg_json, "r", encoding="utf-8") as f: + with open(pkg_json, encoding="utf-8") as f: content = f.read().lower() if ( "react" in content @@ -66,10 +65,10 @@ def generate_heuristic_tags(repo_path: str) -> List[str]: try: content = "" if os.path.exists(req_txt): - with open(req_txt, "r", encoding="utf-8") as f: + with open(req_txt, encoding="utf-8") as f: content += f.read().lower() if os.path.exists(py_toml): - with open(py_toml, "r", encoding="utf-8") as f: + with open(py_toml, encoding="utf-8") as f: content += f.read().lower() if "fastapi" in content or "flask" in content or "django" in content: diff --git a/src/gitauditor/core/models.py b/src/gitauditor/core/models.py index 5061541..3f9dc78 100644 --- a/src/gitauditor/core/models.py +++ b/src/gitauditor/core/models.py @@ -1,38 +1,38 @@ -from sqlmodel import SQLModel, Field -from typing import Optional -from datetime import datetime +from datetime import datetime, timezone + +from sqlmodel import Field, SQLModel class Repo(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) + id: int | None = Field(default=None, primary_key=True) path: str = Field(index=True, unique=True) name: str = Field(index=True) - host: Optional[str] = Field(default=None, index=True) # Ex: github.com, gitlab.com - owner: Optional[str] = Field(default=None, index=True) # Ex: refernandes - canonical_name: Optional[str] = Field( + host: str | None = Field(default=None, index=True) # Ex: github.com, gitlab.com + owner: str | None = Field(default=None, index=True) # Ex: refernandes + canonical_name: str | None = Field( default=None, index=True ) # Ex: github.com/refernandes/gitauditor # Metadados enriquecidos - tags: Optional[str] = Field(default="") # Separados por vírgula ou JSON string - size_mb: Optional[float] = Field(default=0.0) + tags: str | None = Field(default="") # Separados por vírgula ou JSON string + size_mb: float | None = Field(default=0.0) # Saúde do Repositório - remote_url: Optional[str] = Field(default=None) + remote_url: str | None = Field(default=None) status: str = Field(default="Unknown") # OK, Local, Stale, Broken, Timeout - last_activity: Optional[datetime] = Field(default=None) - updated_at: datetime = Field(default_factory=datetime.utcnow) + last_activity: datetime | None = Field(default=None) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) # P3: Semantic Fields - ai_summary: Optional[str] = Field(default=None) - ai_tags: Optional[str] = Field(default=None) # CSV format - ai_stack: Optional[str] = Field(default=None) - ai_risk: Optional[str] = Field(default=None) + ai_summary: str | None = Field(default=None) + ai_tags: str | None = Field(default=None) # CSV format + ai_stack: str | None = Field(default=None) + ai_risk: str | None = Field(default=None) # P3: Governance Fields - ai_model: Optional[str] = Field(default=None) - ai_prompt_version: Optional[str] = Field(default=None) - ai_updated_at: Optional[datetime] = Field(default=None) - ai_confidence: Optional[float] = Field(default=None) - ai_error: Optional[str] = Field(default=None) - ai_source_hash: Optional[str] = Field(default=None) + ai_model: str | None = Field(default=None) + ai_prompt_version: str | None = Field(default=None) + ai_updated_at: datetime | None = Field(default=None) + ai_confidence: float | None = Field(default=None) + ai_error: str | None = Field(default=None) + ai_source_hash: str | None = Field(default=None) diff --git a/src/gitauditor/core/policy_engine.py b/src/gitauditor/core/policy_engine.py index 38c5dc4..70c13c3 100644 --- a/src/gitauditor/core/policy_engine.py +++ b/src/gitauditor/core/policy_engine.py @@ -1,11 +1,12 @@ import os -from typing import Dict, Any +from typing import Any + class PolicyEngine: @staticmethod - def check_repository(repo_path: str) -> Dict[str, Any]: + def check_repository(repo_path: str) -> dict[str, Any]: """Avalia a governança e saúde do repositório de forma passiva.""" - + report = { "score": 100, "checks": {}, @@ -64,10 +65,13 @@ def check_repository(repo_path: str) -> Dict[str, Any]: # 4. Critical Security Risks (.env check) # Verify if .env is tracked by git (not just existing on disk) import subprocess - res = subprocess.run(["git", "ls-files", ".env"], cwd=repo_path, capture_output=True, text=True) - is_env_tracked = res.stdout.strip() != "" + try: + res = subprocess.run(["git", "ls-files", ".env"], cwd=repo_path, capture_output=True, text=True, timeout=15) + is_env_tracked = res.stdout.strip() != "" + except Exception: + is_env_tracked = False report["checks"]["env_exposed"] = is_env_tracked - + if is_env_tracked: report["score"] -= 50 report["critical"].append("CRÍTICO: O arquivo '.env' está versionado no repositório! Risco de vazamento de credenciais.") diff --git a/src/gitauditor/core/scanner.py b/src/gitauditor/core/scanner.py index 95e703f..2a8de74 100644 --- a/src/gitauditor/core/scanner.py +++ b/src/gitauditor/core/scanner.py @@ -1,6 +1,5 @@ -import os import asyncio -from typing import List +import os # Reutilizando a lista de diretórios ignorados do seu script original IGNORED_DIRS = { @@ -44,7 +43,7 @@ def __init__(self, callback=None): self.callback = callback # Função chamada a cada repo encontrado self.semaphore = asyncio.Semaphore(4) # Limite de 4 raízes concorrentes - async def scan(self, root_dirs: List[str]) -> List[str]: + async def scan(self, root_dirs: list[str]) -> list[str]: """Inicia a varredura assíncrona nos diretórios raiz fornecidos.""" self.is_scanning = True self.found_repos = [] diff --git a/src/gitauditor/core/semantic.py b/src/gitauditor/core/semantic.py index d88273c..12742a1 100644 --- a/src/gitauditor/core/semantic.py +++ b/src/gitauditor/core/semantic.py @@ -1,7 +1,7 @@ -import os import hashlib -from typing import Dict +import os from pathlib import Path + from pydantic import BaseModel, Field # --------------------------------------------------------- @@ -107,7 +107,7 @@ class RepoChangelogSchema(BaseModel): # --------------------------------------------------------- -def extract_repo_context(repo_path: str) -> Dict[str, str]: +def extract_repo_context(repo_path: str) -> dict[str, str]: """ Extracts deterministic heuristics and hierarchical content from a repository. Returns a dict with 'tree', 'readme', 'manifests', and the 'source_hash'. @@ -141,7 +141,7 @@ def extract_repo_context(repo_path: str) -> Dict[str, str]: # Extract Manifests (Only in root or depth 1) if level <= 1 and f in MANIFEST_FILES: try: - with open(os.path.join(root, f), "r", encoding="utf-8") as f_obj: + with open(os.path.join(root, f), encoding="utf-8") as f_obj: manifests_content[f] = f_obj.read()[ :2048 ] # Truncate to 2KB to save tokens @@ -151,7 +151,7 @@ def extract_repo_context(repo_path: str) -> Dict[str, str]: # Extract README if level == 0 and f.lower().startswith("readme"): try: - with open(os.path.join(root, f), "r", encoding="utf-8") as f_obj: + with open(os.path.join(root, f), encoding="utf-8") as f_obj: readme_content = f_obj.read()[:3072] # Truncate to 3KB except Exception: pass diff --git a/src/gitauditor/core/ssh_audit.py b/src/gitauditor/core/ssh_audit.py index 0d248c7..a963ee5 100644 --- a/src/gitauditor/core/ssh_audit.py +++ b/src/gitauditor/core/ssh_audit.py @@ -1,14 +1,13 @@ -import os import glob +import os import subprocess -from typing import List, Dict class IdentityManager: """Gerencia e audita identidades Git e chaves SSH.""" @staticmethod - def get_global_git_config() -> Dict[str, str]: + def get_global_git_config() -> dict[str, str]: """Obtém as configurações globais de usuário do Git.""" configs = {"name": "Não configurado", "email": "Não configurado"} try: @@ -16,6 +15,7 @@ def get_global_git_config() -> Dict[str, str]: ["git", "config", "--global", "user.name"], capture_output=True, text=True, + timeout=15, ) if name_result.returncode == 0 and name_result.stdout.strip(): configs["name"] = name_result.stdout.strip() @@ -24,6 +24,7 @@ def get_global_git_config() -> Dict[str, str]: ["git", "config", "--global", "user.email"], capture_output=True, text=True, + timeout=15, ) if email_result.returncode == 0 and email_result.stdout.strip(): configs["email"] = email_result.stdout.strip() @@ -32,7 +33,7 @@ def get_global_git_config() -> Dict[str, str]: return configs @staticmethod - def list_ssh_keys() -> List[Dict[str, str]]: + def list_ssh_keys() -> list[dict[str, str]]: """Lista as chaves SSH na pasta ~/.ssh do usuário.""" ssh_dir = os.path.expanduser("~/.ssh") keys = [] @@ -70,11 +71,11 @@ def set_repo_identity( try: if name: subprocess.run( - ["git", "config", "user.name", name], cwd=repo_path, check=True + ["git", "config", "user.name", name], cwd=repo_path, check=True, timeout=15 ) if email: subprocess.run( - ["git", "config", "user.email", email], cwd=repo_path, check=True + ["git", "config", "user.email", email], cwd=repo_path, check=True, timeout=15 ) if ssh_key_path: # Usa core.sshCommand para forçar o Git a usar uma chave SSH específica @@ -83,6 +84,7 @@ def set_repo_identity( ["git", "config", "core.sshCommand", ssh_command], cwd=repo_path, check=True, + timeout=15, ) return True except subprocess.CalledProcessError: diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py new file mode 100644 index 0000000..c1bfcaf --- /dev/null +++ b/tests/test_cli_commands.py @@ -0,0 +1,21 @@ +from typer.testing import CliRunner + +from gitauditor.cli import app + +runner = CliRunner() + +def test_cli_help(): + """Testa se o comando principal da CLI responde com o menu de ajuda.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Manage, audit, and organize Git repositories locally" in result.stdout or "Usage:" in result.stdout + +def test_catalog_dashboard_command_help(): + """Testa a ajuda do comando dashboard no catalog.""" + # Assuming there's a command structure or just testing the main help + result = runner.invoke(app, ["dashboard", "--help"]) + if result.exit_code == 0: + assert "dashboard" in result.stdout.lower() + else: + # Some commands might be invoked differently + pass diff --git a/tests/test_point1_rebase_merges.py b/tests/test_point1_rebase_merges.py index c97e2e0..0ca0d43 100644 --- a/tests/test_point1_rebase_merges.py +++ b/tests/test_point1_rebase_merges.py @@ -1,8 +1,9 @@ import os import subprocess -import pytest import sys +import pytest + # Garante que src está no path sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from gitauditor.core.git_ops import GitService diff --git a/tests/test_point2_async_audit.py b/tests/test_point2_async_audit.py index f7d60bc..5d9f923 100644 --- a/tests/test_point2_async_audit.py +++ b/tests/test_point2_async_audit.py @@ -1,10 +1,11 @@ -import pytest import asyncio -import time -import sys import os +import sys +import time from unittest.mock import AsyncMock, patch +import pytest + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from gitauditor.cli import GitAuditorCLI diff --git a/tests/test_point3_windows_rebase.py b/tests/test_point3_windows_rebase.py index 31a5bd7..78acd78 100644 --- a/tests/test_point3_windows_rebase.py +++ b/tests/test_point3_windows_rebase.py @@ -1,8 +1,9 @@ import os import subprocess -import pytest import sys +import pytest + # Garante que src está no path sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from gitauditor.core.git_ops import GitService diff --git a/tests/test_point5_duplicates.py b/tests/test_point5_duplicates.py index 49af1a5..9b3c331 100644 --- a/tests/test_point5_duplicates.py +++ b/tests/test_point5_duplicates.py @@ -1,5 +1,5 @@ -import sys import os +import sys sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from gitauditor.commands.audit_cmd import normalize_git_url