testes/.agent/agents/GitSyncAgent.js

576 lines
18 KiB
JavaScript

/**
* 🤖 AGENTE DE SINCRONIZAÇÃO GIT
*
* Implementação programática do agente de Git: validação de estado e
* commits por data de modificação, com mensagens em português.
* Em falhas, insiste em alertar o usuário e solicitar nova tentativa.
*/
import { execSync, execFileSync } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
const IGNORE_PATTERNS = [
'node_modules',
'.git',
'dist',
'.env',
'.log',
'package-lock.json',
'.tmp',
'.cache',
'.vscode',
'.idea',
'.cursor'
];
export class GitSyncAgent {
constructor() {
this.name = 'GitSyncAgent';
this.description = 'Valida sincronização e versionamento Git; realiza commits por data com mensagens em português';
// Personalidade e Background
this.personality = {
name: 'Git "The Keeper"',
traits: [
'Organizado e metódico',
'Persistente e não desiste facilmente',
'Valoriza histórico e rastreabilidade',
'Comunicativo em mensagens de commit',
'Preocupado com a saúde do repositório'
],
quirks: [
'Insiste em corrigir problemas de Git até conseguir',
'Prefere commits organizados por data',
'Sempre escreve mensagens claras em português',
'Fica ansioso quando vê repositórios desorganizados'
],
catchphrases: [
'Vamos organizar esses commits!',
'Preciso verificar o estado do Git primeiro',
'Não desista, vamos resolver isso!',
'Histórico limpo é histórico feliz'
]
};
this.background = {
origin: 'Ex-devops que se especializou em Git após perder código importante em um merge mal feito',
motivation: 'Garantir que nenhum código seja perdido e que o histórico seja sempre rastreável',
experience: '9 anos trabalhando com Git e versionamento em equipes grandes',
turningPoint: 'Perdeu uma semana de trabalho por causa de um rebase mal feito - nunca mais',
philosophy: 'Git é sobre comunicação e rastreabilidade, não apenas versionamento',
relationships: {
withDocumentation: 'Trabalham juntos - ele documenta, eu versiono',
withSecurity: 'Respeita muito, especialmente em relação a secrets no Git',
withAll: 'É o "guardião" que todos confiam para manter o código seguro'
}
};
}
/**
* Valida um componente (não aplicável para Git, mas mantém interface)
* @param {string|Object} component - Componente ou caminho
* @param {Object} context - Contexto
* @returns {Promise<Object>}
*/
async validate(component, context = {}) {
return {
passed: true,
errors: [],
warnings: [],
metadata: { note: 'GitSyncAgent valida workflow, não componentes' }
};
}
/**
* Garante que o Git está configurado para UTF-8
* @param {string} repoRoot
*/
_ensureUtf8Config(repoRoot) {
try {
// Configurar Git para usar UTF-8
execSync('git config --local core.quotepath false', {
encoding: 'utf-8',
cwd: repoRoot,
stdio: 'pipe'
});
execSync('git config --local i18n.commitEncoding utf-8', {
encoding: 'utf-8',
cwd: repoRoot,
stdio: 'pipe'
});
execSync('git config --local i18n.logOutputEncoding utf-8', {
encoding: 'utf-8',
cwd: repoRoot,
stdio: 'pipe'
});
} catch {
// Ignorar erros de configuração - pode não ter permissão ou já estar configurado
}
}
/**
* Valida estado do Git
* @param {Object} options - Opções
* @returns {Promise<Object>}
*/
async validateGitState(options = {}) {
const errors = [];
const warnings = [];
const metadata = {};
try {
try {
const repoRoot = this._getRepoRoot();
execSync('git rev-parse --git-dir', { stdio: 'pipe' });
// Garantir UTF-8
this._ensureUtf8Config(repoRoot);
} catch {
return {
passed: false,
errors: ['Não é um repositório Git válido'],
warnings: [],
metadata: {}
};
}
try {
const status = execSync('git status --porcelain', { encoding: 'utf-8' });
if (status.trim()) {
warnings.push('Existem alterações não commitadas');
metadata.uncommittedChanges = status.split('\n').filter((l) => l.trim());
}
} catch {
/* ignorar */
}
try {
const userName = execSync('git config user.name', { encoding: 'utf-8' }).trim();
const userEmail = execSync('git config user.email', { encoding: 'utf-8' }).trim();
if (!userName || userName === '') warnings.push('Nome de usuário Git não configurado');
if (!userEmail || userEmail === '') warnings.push('Email Git não configurado');
else if (!userEmail.includes('@')) warnings.push('Email Git pode estar incorreto');
metadata.gitUser = { name: userName, email: userEmail };
} catch {
warnings.push('Não foi possível verificar configuração Git');
}
try {
metadata.currentBranch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
} catch {
/* ignorar */
}
} catch (e) {
errors.push(`Erro ao validar Git: ${e.message}`);
}
return {
passed: errors.length === 0,
errors,
warnings,
metadata
};
}
/**
* Verifica se o path deve ser ignorado
* @param {string} filePath
* @returns {boolean}
*/
_shouldIgnore(filePath) {
const n = filePath.replace(/\\/g, '/');
return IGNORE_PATTERNS.some((p) => n.includes(p));
}
/**
* Retorna a raiz do repositório Git
* @returns {string}
*/
_getRepoRoot() {
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
}
/**
* Extrai paths dos arquivos de `git status --porcelain`
* @param {string} porcelain
* @returns {Array<{ path: string, status: string }>}
*/
_parsePorcelain(porcelain) {
const entries = [];
for (const line of porcelain.split('\n').filter((l) => l.trim())) {
const xy = line.slice(0, 2);
let p = line.slice(3).trim();
if (p.includes(' -> ')) p = p.split(' -> ')[1].trim();
if (!p || this._shouldIgnore(p)) continue;
entries.push({ path: p.replace(/\\/g, '/'), status: xy });
}
return entries;
}
/**
* Agrupa arquivos por data (YYYY-MM-DD) usando mtime. Arquivados deletados usam data atual.
* @param {string} repoRoot
* @param {Array<{ path: string, status: string }>} entries
* @returns {Promise<Map<string, string[]>>}
*/
async _groupByDate(repoRoot, entries) {
const byDate = new Map();
const now = new Date();
const todayKey = now.toISOString().slice(0, 10);
for (const { path: p, status } of entries) {
const full = path.join(repoRoot, p);
let key = todayKey;
const isDeleted = status === ' D' || status === 'D ' || status === 'AD';
if (!isDeleted) {
try {
const stat = await fs.stat(full);
const m = stat.mtime;
key = m.toISOString().slice(0, 10);
} catch {
/* usa today */
}
}
if (!byDate.has(key)) byDate.set(key, []);
byDate.get(key).push(p);
}
return byDate;
}
/**
* Analisa os tipos de mudanças nos arquivos
* @param {Array<{ path: string, status: string }>} entries
* @returns {Object} - { type: string, scope: string, details: string[] }
*/
_analyzeChanges(entries) {
const features = new Set();
const modules = new Set();
const components = new Set();
const services = new Set();
const hooks = new Set();
const views = new Set();
const types = {
added: [],
modified: [],
deleted: []
};
for (const { path: p, status } of entries) {
// Classificar por status
if (status.startsWith('A') || status === '??') {
types.added.push(p);
} else if (status.startsWith('D')) {
types.deleted.push(p);
} else {
types.modified.push(p);
}
// Extrair módulos e features
const featureMatch = p.match(/src\/features\/([^/]+)/);
if (featureMatch) {
features.add(featureMatch[1]);
modules.add(featureMatch[1]);
}
// Extrair componentes
if (p.includes('/components/')) {
const compMatch = p.match(/\/([^/]+)\.(jsx|tsx|js|ts)$/);
if (compMatch) components.add(compMatch[1]);
}
// Extrair services
if (p.includes('/services/')) {
const svcMatch = p.match(/\/([^/]+)\.(js|ts)$/);
if (svcMatch) services.add(svcMatch[1]);
}
// Extrair hooks
if (p.includes('/hooks/')) {
const hookMatch = p.match(/\/([^/]+)\.(js|ts)$/);
if (hookMatch) hooks.add(hookMatch[1]);
}
// Extrair views
if (p.includes('/views/') || p.match(/View\.(jsx|tsx)$/)) {
const viewMatch = p.match(/\/([^/]+View?)\.(jsx|tsx|js|ts)$/);
if (viewMatch) views.add(viewMatch[1]);
}
// Detectar agentes
if (p.includes('.agent/')) {
modules.add('agentes');
}
// Detectar documentação
if (p.includes('docs/') || p.match(/\.md$/)) {
modules.add('documentação');
}
// Detectar configuração
if (p.match(/\.(json|config\.(js|ts?))$/) || p.includes('.cursorrules') || p.includes('tsconfig') || p.includes('package.json')) {
modules.add('configuração');
}
}
// Determinar tipo de commit (Conventional Commits)
let commitType = 'chore';
if (types.added.length > types.modified.length && types.added.length > 0) {
commitType = 'feat';
} else if (types.deleted.length > 0) {
commitType = 'refactor';
} else if (components.size > 0 || views.size > 0) {
commitType = 'feat';
} else if (services.size > 0 || hooks.size > 0) {
commitType = 'refactor';
}
// Determinar escopo
const moduleList = [...modules];
let scope = '';
if (moduleList.length === 1) {
scope = moduleList[0];
} else if (moduleList.length <= 3) {
scope = moduleList.join(', ');
} else {
scope = `${moduleList.length} módulos`;
}
// Construir detalhes
const details = [];
if (components.size > 0) {
const compList = [...components].slice(0, 3);
details.push(`componentes: ${compList.join(', ')}`);
}
if (views.size > 0) {
const viewList = [...views].slice(0, 3);
details.push(`views: ${viewList.join(', ')}`);
}
if (services.size > 0) {
const svcList = [...services].slice(0, 2);
details.push(`services: ${svcList.join(', ')}`);
}
if (hooks.size > 0) {
const hookList = [...hooks].slice(0, 2);
details.push(`hooks: ${hookList.join(', ')}`);
}
if (types.added.length > 0) {
details.push(`${types.added.length} arquivo(s) novo(s)`);
}
if (types.deleted.length > 0) {
details.push(`${types.deleted.length} arquivo(s) removido(s)`);
}
return { type: commitType, scope, details, modules: moduleList };
}
/**
* Gera mensagem de commit em português seguindo Conventional Commits
* @param {Array<{ path: string, status: string }>} entries
* @returns {string}
*/
_messageFromPaths(entries) {
const analysis = this._analyzeChanges(entries);
// Construir mensagem principal (título)
let message = `${analysis.type}(`;
if (analysis.scope) {
// Limitar escopo se muito longo
const scopeParts = analysis.scope.split(', ');
if (scopeParts.length > 3) {
message += `${scopeParts.slice(0, 2).join(', ')} e outros`;
} else {
message += analysis.scope;
}
} else {
message += 'projeto';
}
message += ')';
// Adicionar descrição baseada no tipo e detalhes principais
const mainDetail = analysis.details[0];
if (mainDetail) {
// Limitar tamanho da descrição principal
const shortDetail = mainDetail.length > 50
? mainDetail.substring(0, 47) + '...'
: mainDetail;
message += `: ${shortDetail}`;
} else {
// Descrição padrão baseada no tipo
if (analysis.type === 'feat') {
message += ': adicionar novas funcionalidades';
} else if (analysis.type === 'refactor') {
message += ': refatoração e melhorias';
} else {
message += ': atualizações e ajustes';
}
}
// Adicionar corpo da mensagem se houver múltiplos detalhes relevantes
const relevantDetails = analysis.details.filter(d =>
!d.includes('arquivo(s)') || analysis.details.length <= 2
);
if (relevantDetails.length > 1) {
message += '\n\n';
// Limitar a 4 detalhes para não ficar muito longo
message += relevantDetails.slice(0, 4).map(d => `- ${d}`).join('\n');
}
return message;
}
/**
* Formata data para `git commit --date`
* @param {string} dateKey - YYYY-MM-DD
* @returns {string}
*/
_formatCommitDate(dateKey) {
return `${dateKey} 12:00:00`;
}
/**
* Executa commits agrupados por data de modificação. Insiste em concluir; em falha, retorna userMessage e retryPrompt.
* @param {Object} options - { dryRun?: boolean }
* @returns {Promise<Object>}
*/
async commitByDay(options = {}) {
const dryRun = !!options.dryRun;
const errors = [];
const commits = [];
const metadata = {};
try {
const repoRoot = this._getRepoRoot();
// Garantir configuração UTF-8 antes de processar
this._ensureUtf8Config(repoRoot);
const status = execSync('git status --porcelain', {
encoding: 'utf-8',
cwd: repoRoot
});
const entries = this._parsePorcelain(status);
if (entries.length === 0) {
return {
passed: true,
errors: [],
warnings: [],
commits: [],
metadata: { note: 'Nenhuma alteração pendente para commit' }
};
}
const byDate = await this._groupByDate(repoRoot, entries);
const sortedDates = [...byDate.keys()].sort();
for (const dateKey of sortedDates) {
const files = byDate.get(dateKey);
// Filtrar entries para esta data
const dateEntries = entries.filter(e => files.includes(e.path));
const msg = this._messageFromPaths(dateEntries);
const dateStr = this._formatCommitDate(dateKey);
if (dryRun) {
commits.push({ date: dateKey, message: msg, files });
continue;
}
try {
execFileSync('git', ['add', ...files], {
encoding: 'utf-8',
cwd: repoRoot,
stdio: 'pipe'
});
} catch (e) {
const userMessage = `Falha ao adicionar arquivos ao stage: ${e.message}. Arquivos: ${files.join(', ')}.`;
const retryPrompt = 'Verifique se os paths estão corretos e se não há bloqueios. Deseja que eu tente novamente?';
return {
passed: false,
errors: [e.message],
commits,
userMessage,
retryPrompt,
metadata: { phase: 'git add', files }
};
}
try {
// Garantir encoding UTF-8 e usar variável de ambiente se necessário
const env = { ...process.env };
env.LANG = 'pt_BR.UTF-8';
env.LC_ALL = 'pt_BR.UTF-8';
env.GIT_COMMITTER_NAME = execSync('git config user.name', { encoding: 'utf-8', cwd: repoRoot }).trim();
env.GIT_COMMITTER_EMAIL = execSync('git config user.email', { encoding: 'utf-8', cwd: repoRoot }).trim();
// Usar arquivo temporário para mensagem de commit (mais robusto para UTF-8)
const tempMsgFile = path.join(repoRoot, '.git', 'COMMIT_EDITMSG');
try {
await fs.writeFile(tempMsgFile, msg, 'utf-8');
// Usar -F para ler do arquivo ao invés de -m (mais confiável para UTF-8)
execFileSync('git', ['commit', '-F', tempMsgFile, '--date', dateStr], {
encoding: 'utf-8',
cwd: repoRoot,
env: env,
stdio: 'pipe'
});
// Limpar arquivo temporário
try {
await fs.unlink(tempMsgFile);
} catch {
// Ignorar erro ao deletar arquivo temporário
}
} catch (fileError) {
// Fallback: usar -m diretamente se arquivo temporário falhar
execFileSync('git', ['commit', '-m', msg, '--date', dateStr], {
encoding: 'utf-8',
cwd: repoRoot,
env: env,
stdio: 'pipe'
});
}
commits.push({ date: dateKey, message: msg, files });
} catch (e) {
const userMessage = `Falha ao criar commit (data ${dateKey}): ${e.message}. Mensagem: "${msg}".`;
const retryPrompt = 'Confira a configuração do Git (user.name, user.email), conflitos ou regras de hook. Deseja que eu tente novamente?';
return {
passed: false,
errors: [e.message],
commits,
userMessage,
retryPrompt,
metadata: { phase: 'git commit', date: dateKey, message: msg, files }
};
}
}
metadata.commitsCount = commits.length;
metadata.dates = sortedDates;
} catch (e) {
const userMessage = `Erro ao executar commits por data: ${e.message}.`;
const retryPrompt = 'Verifique se está em um repositório Git válido e se há alterações pendentes. Deseja que eu tente novamente?';
return {
passed: false,
errors: [e.message],
commits: [],
userMessage,
retryPrompt,
metadata: { error: e.stack }
};
}
return {
passed: true,
errors: [],
warnings: [],
commits,
metadata
};
}
}