gitpr-cli 0.0.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- __init__.py +1 -0
- ai_providers.py +65 -0
- cache.py +50 -0
- config.py +154 -0
- core.py +305 -0
- gitpr_cli-0.0.11.dist-info/METADATA +211 -0
- gitpr_cli-0.0.11.dist-info/RECORD +15 -0
- gitpr_cli-0.0.11.dist-info/WHEEL +5 -0
- gitpr_cli-0.0.11.dist-info/entry_points.txt +2 -0
- gitpr_cli-0.0.11.dist-info/licenses/LICENSE +502 -0
- gitpr_cli-0.0.11.dist-info/top_level.txt +9 -0
- linter_engine.py +122 -0
- main.py +299 -0
- security.py +42 -0
- updater.py +90 -0
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Módulo principal do GitPR
|
ai_providers.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import click
|
|
4
|
+
from google import genai
|
|
5
|
+
from openai import OpenAI
|
|
6
|
+
|
|
7
|
+
def call_ai_model(provider, api_key, api_model, prompt, system_instruction):
|
|
8
|
+
"""
|
|
9
|
+
Motor unificado para chamadas de IA.
|
|
10
|
+
Suporta 'gemini' e 'deepseek'.
|
|
11
|
+
"""
|
|
12
|
+
max_retries = 3
|
|
13
|
+
retry_delay = 2
|
|
14
|
+
|
|
15
|
+
for attempt in range(1, max_retries + 1):
|
|
16
|
+
try:
|
|
17
|
+
if provider == "gemini":
|
|
18
|
+
client = genai.Client(api_key=api_key)
|
|
19
|
+
response = client.models.generate_content(
|
|
20
|
+
model=api_model,
|
|
21
|
+
contents=prompt,
|
|
22
|
+
config={
|
|
23
|
+
"system_instruction": system_instruction,
|
|
24
|
+
"response_mime_type": "application/json",
|
|
25
|
+
"temperature": 0.0,
|
|
26
|
+
"top_p": 0.1,
|
|
27
|
+
"top_k": 1
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
result_text = response.text
|
|
31
|
+
|
|
32
|
+
elif provider == "deepseek":
|
|
33
|
+
# O DeepSeek é 100% compatível com a biblioteca da OpenAI
|
|
34
|
+
client = OpenAI(api_key=api_key, base_url="https://api.deepseek.com")
|
|
35
|
+
response = client.chat.completions.create(
|
|
36
|
+
model=api_model, # Ex: "deepseek-chat"
|
|
37
|
+
messages=[
|
|
38
|
+
{"role": "system", "content": system_instruction},
|
|
39
|
+
{"role": "user", "content": prompt}
|
|
40
|
+
],
|
|
41
|
+
response_format={"type": "json_object"},
|
|
42
|
+
temperature=0.0
|
|
43
|
+
)
|
|
44
|
+
result_text = response.choices[0].message.content
|
|
45
|
+
|
|
46
|
+
else:
|
|
47
|
+
click.secho(f"❌ Provedor de IA desconhecido: {provider}", fg="red")
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
# Tenta converter a resposta de texto para um dicionário JSON do Python
|
|
51
|
+
result_json = json.loads(result_text)
|
|
52
|
+
|
|
53
|
+
# 🛡️ ESCUDO: Se a IA retornar uma lista [ { ... } ] por engano
|
|
54
|
+
if isinstance(result_json, list):
|
|
55
|
+
result_json = result_json[0] if result_json else {}
|
|
56
|
+
|
|
57
|
+
return result_json
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
if attempt < max_retries:
|
|
61
|
+
click.secho(f"⚠️ Instabilidade na API ({provider.capitalize()}). A tentar novamente ({attempt}/{max_retries})...", fg="yellow", dim=True)
|
|
62
|
+
time.sleep(retry_delay)
|
|
63
|
+
else:
|
|
64
|
+
click.secho(f"❌ Erro crítico ao contactar a API do {provider.capitalize()} após {max_retries} tentativas: {str(e)}", fg="red", bold=True)
|
|
65
|
+
return None
|
cache.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
def get_cache_base_dir():
|
|
8
|
+
"""Retorna o caminho ~/.gitpr/cache/prompts/"""
|
|
9
|
+
path = Path.home() / ".gitpr" / "cache" / "prompts"
|
|
10
|
+
return path
|
|
11
|
+
|
|
12
|
+
def generate_md5(text):
|
|
13
|
+
"""Gera o hash MD5 de uma string."""
|
|
14
|
+
return hashlib.md5(text.encode('utf-8')).hexdigest()
|
|
15
|
+
|
|
16
|
+
def get_cached_response(action_folder, prompt_text):
|
|
17
|
+
"""Verifica se existe um cache válido para o prompt e retorna o conteúdo."""
|
|
18
|
+
md5_hash = generate_md5(prompt_text)
|
|
19
|
+
cache_file = get_cache_base_dir() / action_folder / f"{md5_hash}.json"
|
|
20
|
+
|
|
21
|
+
if cache_file.exists():
|
|
22
|
+
try:
|
|
23
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
24
|
+
data = json.load(f)
|
|
25
|
+
return data.get("response")
|
|
26
|
+
except (json.JSONDecodeError, IOError):
|
|
27
|
+
return None
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def save_cached_response(action_folder, action_type, prompt_text, response_dict):
|
|
31
|
+
"""Salva a resposta da IA no cache local."""
|
|
32
|
+
md5_hash = generate_md5(prompt_text)
|
|
33
|
+
folder_path = get_cache_base_dir() / action_folder
|
|
34
|
+
folder_path.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
cache_file = folder_path / f"{md5_hash}.json"
|
|
37
|
+
|
|
38
|
+
cache_data = {
|
|
39
|
+
"md5": md5_hash,
|
|
40
|
+
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
41
|
+
"action_type": action_type,
|
|
42
|
+
"prompt": prompt_text,
|
|
43
|
+
"response": response_dict
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
with open(cache_file, "w", encoding="utf-8") as f:
|
|
48
|
+
json.dump(cache_data, f, indent=2, ensure_ascii=False)
|
|
49
|
+
except IOError:
|
|
50
|
+
pass # Falha silenciosa no cache para não travar a ferramenta
|
config.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import socket
|
|
4
|
+
import click
|
|
5
|
+
import yaml
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dotenv import load_dotenv, set_key
|
|
8
|
+
from src.security import encrypt_data, decrypt_data, get_or_create_key
|
|
9
|
+
|
|
10
|
+
# Caminho para o arquivo .env global na pasta do utilizador (ex: ~/.gitpr/.env)
|
|
11
|
+
ENV_FILE = os.path.join(os.path.expanduser("~"), ".gitpr", ".env")
|
|
12
|
+
|
|
13
|
+
# Dicionário de configurações padrão para garantir que o .env esteja sempre completo
|
|
14
|
+
DEFAULT_CONFIG = {
|
|
15
|
+
"DEFAULT_AI_PROVIDER": "gemini",
|
|
16
|
+
"GEMINI_API_MODEL_PRIMARY": "gemini-pro-latest",
|
|
17
|
+
"GEMINI_API_MODEL_SECONDARY": "gemini-flash-lite-latest",
|
|
18
|
+
"DEEPSEEK_API_MODEL_PRIMARY": "deepseek-v4-pro",
|
|
19
|
+
"DEEPSEEK_API_MODEL_SECONDARY": "deepseek-v4-flash",
|
|
20
|
+
"OUTPUT_FILE_NAME": "{branch}_{datetime}_PR_DESC.md",
|
|
21
|
+
"OUTPUT_FILE_NAME_REVIEW": "{branch}_{datetime}_PR_REVIEW.txt",
|
|
22
|
+
"OUTPUT_FILE_NAME_FULLREVIEW": "{branch}_{datetime}_PR_FULLREVIEW.txt",
|
|
23
|
+
"OUTPUT_FILE_NAME_FILEREVIEW": "{branch}_{datetime}_FILE_REVIEW.txt"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def get_ai_provider():
|
|
27
|
+
"""Retorna o provedor de IA padrão configurado, ou 'gemini' como fallback."""
|
|
28
|
+
load_dotenv(ENV_FILE)
|
|
29
|
+
return os.getenv("DEFAULT_AI_PROVIDER", "gemini").lower()
|
|
30
|
+
|
|
31
|
+
def get_api_key(provider):
|
|
32
|
+
"""Lê e desencripta a chave de API correspondente ao provedor escolhido."""
|
|
33
|
+
load_dotenv(ENV_FILE)
|
|
34
|
+
|
|
35
|
+
if provider == "gemini":
|
|
36
|
+
encrypted_key = os.getenv("GEMINI_API_KEY_ENCRYPTED")
|
|
37
|
+
elif provider == "deepseek":
|
|
38
|
+
encrypted_key = os.getenv("DEEPSEEK_API_KEY_ENCRYPTED")
|
|
39
|
+
else:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
if encrypted_key:
|
|
43
|
+
return decrypt_data(encrypted_key)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def get_api_model(provider, task_complexity="advanced"):
|
|
47
|
+
"""
|
|
48
|
+
Retorna o modelo de IA baseado no provedor e na complexidade da tarefa.
|
|
49
|
+
'simple' usa modelos secundários (Flash/Lite) - mais barato.
|
|
50
|
+
'advanced' usa modelos primários (Pro) - mais robusto.
|
|
51
|
+
"""
|
|
52
|
+
load_dotenv(ENV_FILE)
|
|
53
|
+
|
|
54
|
+
suffix = "PRIMARY" if task_complexity == "advanced" else "SECONDARY"
|
|
55
|
+
env_var = f"{provider.upper()}_API_MODEL_{suffix}"
|
|
56
|
+
|
|
57
|
+
# Busca do .env, caso contrário usa o valor padrão do dicionário
|
|
58
|
+
return os.getenv(env_var, DEFAULT_CONFIG.get(env_var))
|
|
59
|
+
|
|
60
|
+
def setup_environment():
|
|
61
|
+
"""Garante que as chaves de encriptação, o provedor padrão e a chave da API estão configurados."""
|
|
62
|
+
# Garante que a pasta global existe
|
|
63
|
+
os.makedirs(os.path.dirname(ENV_FILE), exist_ok=True)
|
|
64
|
+
|
|
65
|
+
# Chama a função existente em security.py para garantir que a chave mestra existe
|
|
66
|
+
get_or_create_key()
|
|
67
|
+
|
|
68
|
+
load_dotenv(ENV_FILE)
|
|
69
|
+
|
|
70
|
+
# Auto-preenchimento de variáveis faltantes com valores padrão
|
|
71
|
+
changes_made = False
|
|
72
|
+
for key, value in DEFAULT_CONFIG.items():
|
|
73
|
+
if os.getenv(key) is None:
|
|
74
|
+
set_key(ENV_FILE, key, value)
|
|
75
|
+
changes_made = True
|
|
76
|
+
|
|
77
|
+
if changes_made:
|
|
78
|
+
load_dotenv(ENV_FILE) # Recarrega para garantir que os novos padrões estejam no ar
|
|
79
|
+
|
|
80
|
+
# Pergunta o provedor padrão se não existir
|
|
81
|
+
provider = os.getenv("DEFAULT_AI_PROVIDER")
|
|
82
|
+
if not provider:
|
|
83
|
+
click.secho("🤖 Bem-vindo ao GitPR! Vamos configurar o seu motor de IA.", fg="cyan", bold=True)
|
|
84
|
+
provider = click.prompt(
|
|
85
|
+
"Qual inteligência artificial deseja utilizar como padrão?",
|
|
86
|
+
type=click.Choice(['gemini', 'deepseek'], case_sensitive=False),
|
|
87
|
+
default='gemini'
|
|
88
|
+
).lower()
|
|
89
|
+
set_key(ENV_FILE, "DEFAULT_AI_PROVIDER", provider)
|
|
90
|
+
click.echo("")
|
|
91
|
+
|
|
92
|
+
# Verifica se a chave do provedor escolhido existe
|
|
93
|
+
api_key = get_api_key(provider)
|
|
94
|
+
if not api_key:
|
|
95
|
+
click.secho(f"🔑 Chave de API do {provider.capitalize()} não encontrada.", fg="yellow")
|
|
96
|
+
raw_key = click.prompt(f"Cole aqui a sua chave de API do {provider.capitalize()}", hide_input=True)
|
|
97
|
+
|
|
98
|
+
# Encripta e guarda com o prefixo correto
|
|
99
|
+
encrypted_key = encrypt_data(raw_key.strip())
|
|
100
|
+
env_var_name = f"{provider.upper()}_API_KEY_ENCRYPTED"
|
|
101
|
+
|
|
102
|
+
set_key(ENV_FILE, env_var_name, encrypted_key)
|
|
103
|
+
click.secho("✅ Chave guardada com segurança em disco (Encriptada)!", fg="green")
|
|
104
|
+
click.echo("")
|
|
105
|
+
|
|
106
|
+
def check_internet_connection(timeout=2):
|
|
107
|
+
"""Verifica se há conexão com a internet tentando conectar a um DNS global."""
|
|
108
|
+
try:
|
|
109
|
+
# Salva o timeout padrão do sistema
|
|
110
|
+
original_timeout = socket.getdefaulttimeout()
|
|
111
|
+
socket.setdefaulttimeout(timeout)
|
|
112
|
+
|
|
113
|
+
# Conecta e fecha o socket automaticamente usando 'with'
|
|
114
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
115
|
+
s.connect(("8.8.8.8", 53))
|
|
116
|
+
|
|
117
|
+
# CRÍTICO: Restaura o timeout para não quebrar a API do Gemini!
|
|
118
|
+
socket.setdefaulttimeout(original_timeout)
|
|
119
|
+
return True
|
|
120
|
+
except socket.error:
|
|
121
|
+
click.secho("\n❌ Erro: Sem conexão com a internet.", fg="red", bold=True)
|
|
122
|
+
click.secho("O GitPR precisa de acesso à rede para consultar a IA e verificar atualizações.", fg="yellow")
|
|
123
|
+
click.secho("Verifique sua conexão e tente novamente.\n", fg="white")
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_linter_rules():
|
|
128
|
+
"""
|
|
129
|
+
Carrega as regras do linter estático a partir do arquivo .gitpr.linter.yml.
|
|
130
|
+
Retorna uma lista de regras ou uma lista vazia se o arquivo não existir.
|
|
131
|
+
"""
|
|
132
|
+
file_path = os.path.join(os.getcwd(), ".gitpr.linter.yml")
|
|
133
|
+
|
|
134
|
+
# Se o arquivo não existir no projeto, não é um erro. Apenas não há regras a aplicar.
|
|
135
|
+
if not os.path.exists(file_path):
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
140
|
+
config = yaml.safe_load(f)
|
|
141
|
+
|
|
142
|
+
# Retorna a lista de regras ou vazio se o arquivo estiver em branco
|
|
143
|
+
if not config or "rules" not in config:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
return config.get("rules", [])
|
|
147
|
+
|
|
148
|
+
except yaml.YAMLError as e:
|
|
149
|
+
# Se o usuário errar a indentação ou aspas, avisamos sem estourar o terminal
|
|
150
|
+
click.secho(f"\n❌ Erro de sintaxe no arquivo .gitpr.linter.yml:\n{e}", fg="red")
|
|
151
|
+
return []
|
|
152
|
+
except Exception as e:
|
|
153
|
+
click.secho(f"\n❌ Erro inesperado ao ler as regras do linter: {e}", fg="red")
|
|
154
|
+
return []
|
core.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import stat
|
|
4
|
+
import click
|
|
5
|
+
import subprocess
|
|
6
|
+
import urllib.request
|
|
7
|
+
import urllib.error
|
|
8
|
+
from google import genai
|
|
9
|
+
from src.security import decrypt_data
|
|
10
|
+
from src.cache import get_cached_response, save_cached_response
|
|
11
|
+
from src.config import get_api_key, get_api_model
|
|
12
|
+
from src.ai_providers import call_ai_model
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_git_diff():
|
|
16
|
+
"""Executa 'git diff HEAD' e retorna a saída, alertando sobre arquivos não monitorados (untracked)."""
|
|
17
|
+
try:
|
|
18
|
+
# Verifica se existem arquivos novos não monitorados (untracked)
|
|
19
|
+
untracked_process = subprocess.run(
|
|
20
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
encoding="utf-8"
|
|
24
|
+
)
|
|
25
|
+
untracked_files = untracked_process.stdout.strip()
|
|
26
|
+
|
|
27
|
+
# Se houver arquivos novos, exibe um alerta educativo no console
|
|
28
|
+
if untracked_files:
|
|
29
|
+
click.secho("⚠️ Aviso: O Git detectou novos arquivos que não estão sendo monitorados:", fg="yellow")
|
|
30
|
+
for file in untracked_files.split('\n'):
|
|
31
|
+
click.secho(f" - {file}", fg="yellow", dim=True)
|
|
32
|
+
click.secho("💡 Dica: Use 'git add <arquivo>' para que eles sejam incluídos na análise do GitPR.", fg="cyan")
|
|
33
|
+
click.secho("📚 Entenda o motivo: https://github.com/natanfiuza/gitpr/blob/main/docs/untracked-files.md\n", fg="blue", underline=True)
|
|
34
|
+
|
|
35
|
+
# Executa o diff normal que captura arquivos monitorados e em staging
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["git", "diff", "HEAD"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
check=True
|
|
42
|
+
)
|
|
43
|
+
return result.stdout
|
|
44
|
+
except subprocess.CalledProcessError as e:
|
|
45
|
+
click.secho(f"❌ Erro ao executar o Git: {e.stderr}", fg="red")
|
|
46
|
+
return None
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
click.secho("❌ Git não encontrado. Certifique-se de que está instalado e no PATH.", fg="red")
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_current_branch():
|
|
53
|
+
"""Retorna o nome da branch atual."""
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
encoding="utf-8",
|
|
60
|
+
check=True
|
|
61
|
+
)
|
|
62
|
+
return result.stdout.strip()
|
|
63
|
+
except subprocess.CalledProcessError:
|
|
64
|
+
return "main" # Fallback
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_skill_context(action_type="pr"):
|
|
68
|
+
"""Lê o arquivo de contexto correto baseado na ação (PR/Commit ou Review)."""
|
|
69
|
+
|
|
70
|
+
# Define qual arquivo procurar
|
|
71
|
+
if action_type == "commit":
|
|
72
|
+
target_file = ".gitpr.commit.md"
|
|
73
|
+
elif action_type == "pr":
|
|
74
|
+
target_file = ".gitpr.pr.md"
|
|
75
|
+
elif action_type == "filereview": # NOVO!
|
|
76
|
+
target_file = ".gitpr.filereview.md"
|
|
77
|
+
else: # review ou fullreview
|
|
78
|
+
target_file = ".gitpr.review.md"
|
|
79
|
+
|
|
80
|
+
skill_file = os.path.join(os.getcwd(), target_file)
|
|
81
|
+
|
|
82
|
+
# Fallback para o arquivo antigo (para retrocompatibilidade com usuários da versão anterior)
|
|
83
|
+
legacy_file = os.path.join(os.getcwd(), ".gitpr.md")
|
|
84
|
+
|
|
85
|
+
# Verifica o novo primeiro; se não achar, tenta o antigo
|
|
86
|
+
file_to_load = skill_file if os.path.exists(skill_file) else (legacy_file if os.path.exists(legacy_file) else None)
|
|
87
|
+
|
|
88
|
+
if file_to_load:
|
|
89
|
+
try:
|
|
90
|
+
with open(file_to_load, "r", encoding="utf-8") as f:
|
|
91
|
+
conteudo = f.read()
|
|
92
|
+
nome_arquivo = os.path.basename(file_to_load)
|
|
93
|
+
click.secho(f"🧠 Arquivo {nome_arquivo} (Skill) encontrado e carregado!", fg="blue")
|
|
94
|
+
return conteudo
|
|
95
|
+
except Exception as e:
|
|
96
|
+
click.secho(f"⚠️ Aviso: Falha ao ler o arquivo {nome_arquivo} ({e})", fg="yellow")
|
|
97
|
+
|
|
98
|
+
# Retorna vazio se não existir
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
def generate_pr_content(action_folder, action_type, diff_text, provider="gemini"):
|
|
102
|
+
"""Envia o diff para a IA usando System Instruction e retorna um JSON parseado."""
|
|
103
|
+
if not diff_text or not diff_text.strip():
|
|
104
|
+
click.secho("⚠️ Nenhum diff encontrado. Faça alguma alteração antes de rodar o comando.", fg="yellow")
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# Configuração de pastas para o Cache
|
|
108
|
+
action_folder_map = {
|
|
109
|
+
"pr": "pr_desc",
|
|
110
|
+
"commit": "commit",
|
|
111
|
+
"review": "review",
|
|
112
|
+
"fullreview": "review",
|
|
113
|
+
"filereview": "review",
|
|
114
|
+
}
|
|
115
|
+
action_folder = action_folder_map.get(action_type, "misc")
|
|
116
|
+
|
|
117
|
+
# Busca o contexto do arquivo correspondente à ação (PR, Commit ou Review)
|
|
118
|
+
skill_context = get_skill_context(action_type)
|
|
119
|
+
|
|
120
|
+
# Definição da Complexidade da Tarefa (NOVO)
|
|
121
|
+
# Commits usam modelos mais rápidos/baratos. Reviews e PRs usam modelos avançados.
|
|
122
|
+
task_complexity = "simple" if action_type == "commit" else "advanced"
|
|
123
|
+
|
|
124
|
+
# Definição da Instrução de Sistema (Persona e Regras)
|
|
125
|
+
if action_type == "commit":
|
|
126
|
+
instrucao_sistema = skill_context if skill_context else "Você é um especialista em Git. Gere mensagens de commit concisas."
|
|
127
|
+
prompt = f"Gere APENAS um objeto JSON no formato {{\"commit_message\": \"...\"}} para este diff:\n{diff_text}"
|
|
128
|
+
|
|
129
|
+
elif action_type in ["review", "fullreview", "filereview"]:
|
|
130
|
+
instrucao_sistema = skill_context if skill_context else "Você é um Arquiteto de Software Sênior. Foque em apontar melhorias."
|
|
131
|
+
|
|
132
|
+
if action_type == "filereview":
|
|
133
|
+
prompt = f"Gere APENAS um objeto JSON no formato {{\"review\": \"...\"}} com a análise e melhorias para o código integral deste arquivo:\n{diff_text}"
|
|
134
|
+
else:
|
|
135
|
+
prompt = f"Gere APENAS um objeto JSON no formato {{\"review\": \"...\"}} apontando erros e melhorias para este diff:\n{diff_text}"
|
|
136
|
+
else: # pr
|
|
137
|
+
instrucao_sistema = skill_context if skill_context else "Você é um Tech Lead redigindo descrições de PR limpas e técnicas."
|
|
138
|
+
prompt = f"Gere APENAS um objeto JSON no formato {{\"commit_message\": \"...\", \"pr_description\": \"...\"}} para este diff:\n{diff_text}"
|
|
139
|
+
|
|
140
|
+
# TENTA RECUPERAR DO CACHE
|
|
141
|
+
cached_data = get_cached_response(action_folder, prompt)
|
|
142
|
+
if cached_data:
|
|
143
|
+
click.secho("⚡ Resposta recuperada do cache local.", fg="green", dim=True)
|
|
144
|
+
return cached_data
|
|
145
|
+
|
|
146
|
+
# Preparação das Chaves (Agora dinâmico por Provedor)
|
|
147
|
+
api_key = get_api_key(provider)
|
|
148
|
+
if not api_key:
|
|
149
|
+
click.secho(f"❌ Erro: Chave de API para o provedor '{provider.capitalize()}' não encontrada.", fg="red")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# Busca o Modelo Inteligente (NOVO)
|
|
153
|
+
# Envia a complexidade para o config.py devolver o modelo primário ou secundário
|
|
154
|
+
api_model = get_api_model(provider, task_complexity)
|
|
155
|
+
if not api_model:
|
|
156
|
+
click.secho(f"❌ Erro: Não foi possível determinar o modelo para o provedor '{provider}'.", fg="red")
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
# CHAMADA À API
|
|
160
|
+
click.secho(f"🤖 O GitPR está analisando o seu código usando {provider.capitalize()} ({api_model})...\n", fg="cyan")
|
|
161
|
+
|
|
162
|
+
result_json = call_ai_model(provider, api_key, api_model, prompt, instrucao_sistema)
|
|
163
|
+
|
|
164
|
+
# SALVA NO CACHE E RETORNA
|
|
165
|
+
if result_json:
|
|
166
|
+
save_cached_response(action_folder, action_type, prompt, result_json)
|
|
167
|
+
return result_json
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def generate_skill_template():
|
|
172
|
+
"""
|
|
173
|
+
Faz o download dos templates .gitpr.pr.md, .gitpr.review.md
|
|
174
|
+
e .gitpr.linter.yml diretamente do repositório oficial.
|
|
175
|
+
"""
|
|
176
|
+
click.secho("\n📥 Iniciando a configuração dos templates do GitPR...", fg="cyan", bold=True)
|
|
177
|
+
|
|
178
|
+
base_url = "https://raw.githubusercontent.com/natanfiuza/gitpr/main/templates/"
|
|
179
|
+
|
|
180
|
+
# Atualizado para contemplar os 3 arquivos
|
|
181
|
+
files_to_download = {
|
|
182
|
+
".gitpr.commit.md": "gitpr.commit.md",
|
|
183
|
+
".gitpr.pr.md": "gitpr.pr.md",
|
|
184
|
+
".gitpr.review.md": "gitpr.review.md",
|
|
185
|
+
".gitpr.linter.yml": "gitpr.linter.yml",
|
|
186
|
+
".gitpr.filereview.md": "gitpr.filereview.md",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
success_count = 0
|
|
190
|
+
|
|
191
|
+
for local_name, remote_name in files_to_download.items():
|
|
192
|
+
file_path = os.path.join(os.getcwd(), local_name)
|
|
193
|
+
url = base_url + remote_name
|
|
194
|
+
|
|
195
|
+
if os.path.exists(file_path):
|
|
196
|
+
click.secho(f"⚠️ O arquivo {local_name} já existe neste diretório. Ele não será sobrescrito.", fg="yellow")
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
click.echo(f"A descarregar {local_name}...")
|
|
201
|
+
with urllib.request.urlopen(url, timeout=5) as response:
|
|
202
|
+
content = response.read().decode('utf-8')
|
|
203
|
+
|
|
204
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
205
|
+
f.write(content)
|
|
206
|
+
|
|
207
|
+
success_count += 1
|
|
208
|
+
|
|
209
|
+
except urllib.error.URLError as e:
|
|
210
|
+
click.secho(f"❌ Erro de rede ao baixar {local_name}: {e.reason}", fg="red")
|
|
211
|
+
except Exception as e:
|
|
212
|
+
click.secho(f"❌ Falha ao processar {local_name}: {e}", fg="red")
|
|
213
|
+
|
|
214
|
+
if success_count > 0:
|
|
215
|
+
click.secho("\n✅ Templates base configurados com sucesso!", fg="green", bold=True)
|
|
216
|
+
click.echo("Você pode agora abrir os arquivos gerados e personalizar o comportamento da ferramenta para o seu projeto:\n")
|
|
217
|
+
click.echo(" 1. As regras de arquitetura para a IA no arquivo '.gitpr.pr.md' e '.gitpr.review.md'\n")
|
|
218
|
+
click.echo(" 2. As regras de regex locais no arquivo '.gitpr.linter.yml'\n")
|
|
219
|
+
else:
|
|
220
|
+
click.echo("\nNenhum arquivo novo foi baixado.")
|
|
221
|
+
|
|
222
|
+
def get_base_branch():
|
|
223
|
+
"""Descobre a branch principal remota (ex: main ou master)."""
|
|
224
|
+
try:
|
|
225
|
+
# Busca a referência da branch default do remote
|
|
226
|
+
result = subprocess.run(
|
|
227
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
228
|
+
capture_output=True, text=True, check=True
|
|
229
|
+
)
|
|
230
|
+
# O retorno é algo como 'refs/remotes/origin/main', então pegamos a última parte
|
|
231
|
+
return result.stdout.strip().split('/')[-1]
|
|
232
|
+
except subprocess.CalledProcessError:
|
|
233
|
+
click.secho("⚠️ Aviso: Branch principal remota não detectada. Assumindo 'main' como fallback padrão.", fg="yellow")
|
|
234
|
+
return "main" # Fallback padrão caso não encontre
|
|
235
|
+
|
|
236
|
+
def get_git_full_diff():
|
|
237
|
+
"""Faz o fetch e captura o diff entre a branch principal remota e o estado atual."""
|
|
238
|
+
click.secho("🔄 Sincronizando com o repositório remoto (git fetch)...", fg="cyan")
|
|
239
|
+
try:
|
|
240
|
+
# Faz o fetch para garantir que sabemos onde a origin/main está
|
|
241
|
+
subprocess.run(["git", "fetch", "origin"], check=True, capture_output=True)
|
|
242
|
+
|
|
243
|
+
base_branch = get_base_branch()
|
|
244
|
+
|
|
245
|
+
# Encontra o HASH do commit onde a sua branch nasceu (o ancestral comum)
|
|
246
|
+
merge_base_res = subprocess.run(
|
|
247
|
+
["git", "merge-base", f"origin/{base_branch}", "HEAD"],
|
|
248
|
+
capture_output=True, text=True, check=True
|
|
249
|
+
)
|
|
250
|
+
ancestor_hash = merge_base_res.stdout.strip()
|
|
251
|
+
|
|
252
|
+
# Faz o diff entre esse HASH e o seu WORKSPACE ATUAL (sem usar HEAD)
|
|
253
|
+
# Ao passar apenas o hash, o Git compara esse commit com os arquivos no seu disco.
|
|
254
|
+
result = subprocess.run(
|
|
255
|
+
["git", "diff", ancestor_hash],
|
|
256
|
+
capture_output=True,
|
|
257
|
+
text=True,
|
|
258
|
+
encoding="utf-8",
|
|
259
|
+
check=True
|
|
260
|
+
)
|
|
261
|
+
return result.stdout
|
|
262
|
+
|
|
263
|
+
except subprocess.CalledProcessError as e:
|
|
264
|
+
click.secho(f"❌ Erro ao calcular o diff: {e.stderr}", fg="red")
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def install_git_hooks():
|
|
268
|
+
"""Faz o download e instala os scripts de pre-commit e prepare-commit-msg."""
|
|
269
|
+
hooks_dir = os.path.join(os.getcwd(), ".git", "hooks")
|
|
270
|
+
|
|
271
|
+
if not os.path.exists(hooks_dir):
|
|
272
|
+
click.secho("❌ Erro: Pasta .git não encontrada. Execute na raiz do projeto.", fg="red")
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
# Mapeamento: Nome do Hook no Git -> Nome do Template no seu GitHub
|
|
276
|
+
hooks_to_install = {
|
|
277
|
+
"pre-commit": "pre-commit-template.sh",
|
|
278
|
+
"prepare-commit-msg": "prepare-commit-msg-template.sh"
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
base_url = "https://raw.githubusercontent.com/natanfiuza/gitpr/main/scripts/"
|
|
282
|
+
success_count = 0
|
|
283
|
+
|
|
284
|
+
for hook_name, template_name in hooks_to_install.items():
|
|
285
|
+
hook_path = os.path.join(hooks_dir, hook_name)
|
|
286
|
+
url = base_url + template_name
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
click.secho(f"📥 A descarregar {hook_name}...", fg="cyan")
|
|
290
|
+
|
|
291
|
+
with urllib.request.urlopen(url) as response:
|
|
292
|
+
content = response.read().decode('utf-8')
|
|
293
|
+
|
|
294
|
+
with open(hook_path, "w", encoding="utf-8") as f:
|
|
295
|
+
f.write(content)
|
|
296
|
+
|
|
297
|
+
# Atribui permissão de execução (chmod +x)
|
|
298
|
+
st = os.stat(hook_path)
|
|
299
|
+
os.chmod(hook_path, st.st_mode | stat.S_IEXEC)
|
|
300
|
+
|
|
301
|
+
success_count += 1
|
|
302
|
+
except Exception as e:
|
|
303
|
+
click.secho(f"⚠️ Falha ao instalar {hook_name}: {e}", fg="yellow")
|
|
304
|
+
|
|
305
|
+
return success_count == len(hooks_to_install)
|