From 633b19f80dff850bd50046fbfa5f50d9b516cdf7 Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Sat, 28 Feb 2026 04:24:47 -0300 Subject: [PATCH] =?UTF-8?q?Integrar=20reposit=C3=B3rios=20Git=20na=20execu?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20agentes=20e=20pipelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Módulo git-integration: clone/pull, commit/push automático, listagem de repos - Seletor de repositório nos modais de execução (agente e pipeline) - Seletor de branch carregado dinamicamente ao escolher repo - Campo de diretório escondido quando repositório selecionado - Auto-commit e push ao final da execução com mensagem descritiva - Instrução injetada para agentes não fazerem operações git - Rotas API: GET /repos, GET /repos/:name/branches - Pipeline: commit automático ao final de todos os steps --- public/app.html | 26 +++++++ public/css/styles.css | 3 + public/js/api.js | 15 +++- public/js/app.js | 60 ++++++++++++++- public/js/components/agents.js | 5 ++ public/js/components/pipelines.js | 9 ++- src/agents/git-integration.js | 117 ++++++++++++++++++++++++++++++ src/agents/manager.js | 22 ++++++ src/agents/pipeline.js | 14 ++++ src/routes/api.js | 47 ++++++++++-- 10 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 src/agents/git-integration.js diff --git a/public/app.html b/public/app.html index 0c32d63..38798f6 100644 --- a/public/app.html +++ b/public/app.html @@ -999,6 +999,19 @@
+ +
+ + +
+

Se selecionado, o agente trabalha no repositório e faz commit/push automático ao finalizar.

+
+ +
+ +
+ + +
+

Se selecionado, todos os agentes trabalham no repositório e o commit/push é automático ao final.

+
+ +
0) body.contextFiles = contextFiles; return API.request('POST', `/agents/${id}/execute`, body); }, @@ -83,9 +84,10 @@ const API = { create(data) { return API.request('POST', '/pipelines', data); }, update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); }, delete(id) { return API.request('DELETE', `/pipelines/${id}`); }, - execute(id, input, workingDirectory, contextFiles) { + execute(id, input, workingDirectory, contextFiles, repoName, repoBranch) { const body = { input }; - if (workingDirectory) body.workingDirectory = workingDirectory; + if (repoName) { body.repoName = repoName; if (repoBranch) body.repoBranch = repoBranch; } + else if (workingDirectory) body.workingDirectory = workingDirectory; if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles; return API.request('POST', `/pipelines/${id}/execute`, body); }, @@ -142,6 +144,11 @@ const API = { }, }, + repos: { + list() { return API.request('GET', '/repos'); }, + branches(name) { return API.request('GET', `/repos/${encodeURIComponent(name)}/branches`); }, + }, + files: { list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); }, delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); }, diff --git a/public/js/app.js b/public/js/app.js index e15cc61..605b7cb 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -37,6 +37,7 @@ const App = { App._executeDropzone = Utils.initDropzone('execute-dropzone', 'execute-files', 'execute-file-list'); App._pipelineDropzone = Utils.initDropzone('pipeline-execute-dropzone', 'pipeline-execute-files', 'pipeline-execute-file-list'); + App._initRepoSelectors(); const initialSection = location.hash.replace('#', '') || 'dashboard'; App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard'); @@ -860,6 +861,61 @@ const App = { }); }, + _reposCache: null, + + async _loadRepos(selectId) { + const select = document.getElementById(selectId); + if (!select) return; + try { + if (!App._reposCache) App._reposCache = await API.repos.list(); + const current = select.value; + select.innerHTML = ''; + App._reposCache.forEach(r => { + select.insertAdjacentHTML('beforeend', + `` + ); + }); + if (current) select.value = current; + } catch { } + }, + + _initRepoSelectors() { + const pairs = [ + ['execute-repo', 'execute-repo-branch', 'execute-workdir-group'], + ['pipeline-execute-repo', 'pipeline-execute-repo-branch', 'pipeline-execute-workdir-group'], + ]; + pairs.forEach(([repoId, branchId, workdirGroupId]) => { + const repoSelect = document.getElementById(repoId); + const branchSelect = document.getElementById(branchId); + const workdirGroup = document.getElementById(workdirGroupId); + if (!repoSelect) return; + + repoSelect.addEventListener('change', async () => { + const repoName = repoSelect.value; + if (repoName) { + if (workdirGroup) workdirGroup.style.display = 'none'; + if (branchSelect) { + branchSelect.style.display = ''; + branchSelect.innerHTML = ''; + try { + const branches = await API.repos.branches(repoName); + branches.forEach(b => { + branchSelect.insertAdjacentHTML('beforeend', ``); + }); + } catch { } + } + } else { + if (workdirGroup) workdirGroup.style.display = ''; + if (branchSelect) branchSelect.style.display = 'none'; + } + }); + + repoSelect.addEventListener('focus', () => { + if (repoSelect.options.length <= 1) App._loadRepos(repoId); + }); + }); + }, + async _handleExecute() { const agentId = document.getElementById('execute-agent-select')?.value || document.getElementById('execute-agent-id')?.value; @@ -877,6 +933,8 @@ const App = { const instructions = document.getElementById('execute-instructions')?.value.trim() || ''; const workingDirectory = document.getElementById('execute-workdir')?.value.trim() || ''; + const repoName = document.getElementById('execute-repo')?.value || ''; + const repoBranch = document.getElementById('execute-repo-branch')?.value || ''; try { const selectEl = document.getElementById('execute-agent-select'); @@ -893,7 +951,7 @@ const App = { Terminal.disableChat(); App._lastAgentName = agentName; - await API.agents.execute(agentId, task, instructions, contextFiles, workingDirectory); + await API.agents.execute(agentId, task, instructions, contextFiles, workingDirectory, repoName, repoBranch); if (dropzone) dropzone.reset(); Modal.close('execute-modal-overlay'); diff --git a/public/js/components/agents.js b/public/js/components/agents.js index 2aadb7e..b2f75dc 100644 --- a/public/js/components/agents.js +++ b/public/js/components/agents.js @@ -374,6 +374,11 @@ const AgentsUI = { AgentsUI._loadSavedTasks(); + const repoSelect = document.getElementById('execute-repo'); + if (repoSelect) { repoSelect.value = ''; repoSelect.dispatchEvent(new Event('change')); } + App._reposCache = null; + App._loadRepos('execute-repo'); + Modal.open('execute-modal-overlay'); } catch (err) { Toast.error(`Erro ao abrir modal de execução: ${err.message}`); diff --git a/public/js/components/pipelines.js b/public/js/components/pipelines.js index f5ddf3a..bc91d61 100644 --- a/public/js/components/pipelines.js +++ b/public/js/components/pipelines.js @@ -476,6 +476,11 @@ const PipelinesUI = { if (App._pipelineDropzone) App._pipelineDropzone.reset(); + const repoSelect = document.getElementById('pipeline-execute-repo'); + if (repoSelect) { repoSelect.value = ''; repoSelect.dispatchEvent(new Event('change')); } + App._reposCache = null; + App._loadRepos('pipeline-execute-repo'); + Modal.open('pipeline-execute-modal-overlay'); }, @@ -503,7 +508,9 @@ const PipelinesUI = { contextFiles = uploadResult.files; } - await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles); + const repoName = document.getElementById('pipeline-execute-repo')?.value || ''; + const repoBranch = document.getElementById('pipeline-execute-repo-branch')?.value || ''; + await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles, repoName, repoBranch); if (dropzone) dropzone.reset(); Modal.close('pipeline-execute-modal-overlay'); App.navigateTo('terminal'); diff --git a/src/agents/git-integration.js b/src/agents/git-integration.js new file mode 100644 index 0000000..798de13 --- /dev/null +++ b/src/agents/git-integration.js @@ -0,0 +1,117 @@ +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { join, basename } from 'path'; + +const PROJECTS_DIR = '/home/projetos'; +const GITEA_URL = () => process.env.GITEA_URL || 'http://gitea:3000'; +const GITEA_USER = () => process.env.GITEA_USER || 'fred'; +const GITEA_PASS = () => process.env.GITEA_PASS || ''; +const DOMAIN = () => process.env.DOMAIN || 'nitro-cloud.duckdns.org'; + +function exec(cmd, cwd) { + return new Promise((resolve, reject) => { + const proc = spawn('sh', ['-c', cmd], { + cwd, + env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' }, + }); + let stdout = '', stderr = ''; + proc.stdout.on('data', d => stdout += d); + proc.stderr.on('data', d => stderr += d); + proc.on('close', code => + code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`)) + ); + }); +} + +function authHeader() { + return 'Basic ' + Buffer.from(`${GITEA_USER()}:${GITEA_PASS()}`).toString('base64'); +} + +function repoCloneUrl(repoName) { + return `${GITEA_URL().replace('://', `://${GITEA_USER()}:${GITEA_PASS()}@`)}/${GITEA_USER()}/${repoName}.git`; +} + +export async function listRepos() { + const url = `${GITEA_URL()}/api/v1/user/repos?limit=50&sort=updated`; + const res = await fetch(url, { headers: { Authorization: authHeader() } }); + if (!res.ok) throw new Error('Erro ao listar repositórios'); + const repos = await res.json(); + return repos.map(r => ({ + name: r.name, + fullName: r.full_name, + description: r.description || '', + defaultBranch: r.default_branch || 'main', + updatedAt: r.updated_at, + htmlUrl: r.html_url, + cloneUrl: r.clone_url, + empty: r.empty, + })); +} + +export async function listBranches(repoName) { + const url = `${GITEA_URL()}/api/v1/repos/${GITEA_USER()}/${repoName}/branches?limit=50`; + const res = await fetch(url, { headers: { Authorization: authHeader() } }); + if (!res.ok) return []; + const branches = await res.json(); + return branches.map(b => b.name); +} + +export async function cloneOrPull(repoName, branch) { + const targetDir = join(PROJECTS_DIR, repoName); + const cloneUrl = repoCloneUrl(repoName); + + if (existsSync(join(targetDir, '.git'))) { + await exec(`git remote set-url origin "${cloneUrl}"`, targetDir); + await exec('git fetch origin', targetDir); + if (branch) { + try { + await exec(`git checkout ${branch}`, targetDir); + } catch { + await exec(`git checkout -b ${branch} origin/${branch}`, targetDir); + } + await exec(`git reset --hard origin/${branch}`, targetDir); + } else { + const currentBranch = await exec('git rev-parse --abbrev-ref HEAD', targetDir); + await exec(`git reset --hard origin/${currentBranch}`, targetDir); + } + return { dir: targetDir, action: 'pull' }; + } + + const branchArg = branch ? `-b ${branch}` : ''; + await exec(`git clone ${branchArg} "${cloneUrl}" "${targetDir}"`); + return { dir: targetDir, action: 'clone' }; +} + +export async function commitAndPush(repoDir, agentName, taskSummary) { + try { + const status = await exec('git status --porcelain', repoDir); + if (!status) return { changed: false }; + + await exec('git add -A', repoDir); + + const summary = taskSummary + ? taskSummary.slice(0, 100).replace(/"/g, '\\"') + : 'Alterações automáticas'; + + const message = `${summary}\n\nExecutado por: ${agentName}`; + await exec( + `git -c user.name="Agents Orchestrator" -c user.email="agents@${DOMAIN()}" commit -m "${message}"`, + repoDir + ); + + await exec('git push origin HEAD', repoDir); + + const commitHash = await exec('git rev-parse --short HEAD', repoDir); + const branch = await exec('git rev-parse --abbrev-ref HEAD', repoDir); + const repoName = basename(repoDir); + const commitUrl = `https://git.${DOMAIN()}/${GITEA_USER()}/${repoName}/commit/${commitHash}`; + + return { changed: true, commitHash, branch, commitUrl, filesChanged: status.split('\n').length }; + } catch (err) { + return { changed: false, error: err.message }; + } +} + +export function getProjectDir(repoName) { + return join(PROJECTS_DIR, repoName); +} diff --git a/src/agents/manager.js b/src/agents/manager.js index 6e81e98..9005332 100644 --- a/src/agents/manager.js +++ b/src/agents/manager.js @@ -4,6 +4,7 @@ import { agentsStore, schedulesStore, executionsStore, notificationsStore, secre import * as executor from './executor.js'; import * as scheduler from './scheduler.js'; import { generateAgentReport } from '../reports/generator.js'; +import * as gitIntegration from './git-integration.js'; const DEFAULT_CONFIG = { model: 'claude-sonnet-4-6', @@ -180,6 +181,10 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata = effectiveInstructions += `\n\n\n${agentList}\n`; } + if (metadata.repoName) { + effectiveInstructions += `\n\n\nVocê está trabalhando no repositório "${metadata.repoName}". NÃO faça git init, git commit, git push ou qualquer operação git. O sistema fará commit e push automaticamente ao final da execução. Foque apenas no código.\n`; + } + const effectiveConfig = { ...agent.config }; if (metadata.workingDirectoryOverride) { effectiveConfig.workingDirectory = metadata.workingDirectoryOverride; @@ -246,6 +251,23 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata = if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename }); } } catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); } + if (metadata.repoName && result.result) { + const repoDir = gitIntegration.getProjectDir(metadata.repoName); + gitIntegration.commitAndPush(repoDir, agent.agent_name, taskText.slice(0, 100)) + .then(gitResult => { + if (gitResult.changed) { + console.log(`[manager] Auto-commit: ${gitResult.commitHash} em ${metadata.repoName}`); + if (cb) cb({ + type: 'execution_output', executionId: execId, agentId, + data: { type: 'success', content: `Git: commit ${gitResult.commitHash} pushed para ${metadata.repoName} (${gitResult.filesChanged} arquivos) → ${gitResult.commitUrl}` }, + }); + } else if (gitResult.error) { + console.error(`[manager] Erro no auto-commit:`, gitResult.error); + } + }) + .catch(err => console.error(`[manager] Erro no auto-commit:`, err.message)); + } + if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result }); const isPipelineStep = !!metadata.pipelineExecutionId; diff --git a/src/agents/pipeline.js b/src/agents/pipeline.js index 9642083..72096c9 100644 --- a/src/agents/pipeline.js +++ b/src/agents/pipeline.js @@ -1,6 +1,7 @@ import { v4 as uuidv4 } from 'uuid'; import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js'; import * as executor from './executor.js'; +import * as gitIntegration from './git-integration.js'; import { mem } from '../cache/index.js'; import { generatePipelineReport } from '../reports/generator.js'; @@ -293,6 +294,19 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti }); if (!pipelineState.canceled) { + if (options.repoName) { + try { + const repoDir = gitIntegration.getProjectDir(options.repoName); + const gitResult = await gitIntegration.commitAndPush(repoDir, pl.name, `Pipeline: ${pl.name}`); + if (gitResult.changed && wsCallback) { + wsCallback({ + type: 'pipeline_step_output', pipelineId, stepIndex: steps.length - 1, + data: { type: 'success', content: `Git: commit ${gitResult.commitHash} pushed para ${options.repoName} (${gitResult.filesChanged} arquivos) → ${gitResult.commitUrl}` }, + }); + } + } catch (e) { console.error('[pipeline] Erro no auto-commit:', e.message); } + } + try { const updated = executionsStore.getById(historyRecord.id); if (updated) { diff --git a/src/routes/api.js b/src/routes/api.js index 301e287..4426b3d 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -8,6 +8,7 @@ import * as manager from '../agents/manager.js'; import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js'; import * as scheduler from '../agents/scheduler.js'; import * as pipeline from '../agents/pipeline.js'; +import * as gitIntegration from '../agents/git-integration.js'; import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js'; import { invalidateAgentMapCache } from '../agents/pipeline.js'; import { cached } from '../cache/index.js'; @@ -165,15 +166,24 @@ function buildContextFilesPrompt(contextFiles) { return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`; } -router.post('/agents/:id/execute', (req, res) => { +router.post('/agents/:id/execute', async (req, res) => { try { - const { task, instructions, contextFiles, workingDirectory } = req.body; + const { task, instructions, contextFiles, workingDirectory, repoName, repoBranch } = req.body; if (!task) return res.status(400).json({ error: 'task é obrigatório' }); const clientId = req.headers['x-client-id'] || null; const filesPrompt = buildContextFilesPrompt(contextFiles); const fullTask = task + filesPrompt; const metadata = {}; - if (workingDirectory) metadata.workingDirectoryOverride = workingDirectory; + + if (repoName) { + const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null); + metadata.workingDirectoryOverride = syncResult.dir; + metadata.repoName = repoName; + metadata.repoBranch = repoBranch || null; + } else if (workingDirectory) { + metadata.workingDirectoryOverride = workingDirectory; + } + const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId), metadata); res.status(202).json({ executionId, status: 'started' }); } catch (err) { @@ -464,11 +474,20 @@ router.delete('/pipelines/:id', (req, res) => { router.post('/pipelines/:id/execute', async (req, res) => { try { - const { input, workingDirectory, contextFiles } = req.body; + const { input, workingDirectory, contextFiles, repoName, repoBranch } = req.body; if (!input) return res.status(400).json({ error: 'input é obrigatório' }); const clientId = req.headers['x-client-id'] || null; const options = {}; - if (workingDirectory) options.workingDirectory = workingDirectory; + + if (repoName) { + const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null); + options.workingDirectory = syncResult.dir; + options.repoName = repoName; + options.repoBranch = repoBranch || null; + } else if (workingDirectory) { + options.workingDirectory = workingDirectory; + } + const filesPrompt = buildContextFilesPrompt(contextFiles); const fullInput = input + filesPrompt; const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options); @@ -1160,6 +1179,24 @@ router.delete('/files', (req, res) => { } }); +router.get('/repos', async (req, res) => { + try { + const repos = await gitIntegration.listRepos(); + res.json(repos); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.get('/repos/:name/branches', async (req, res) => { + try { + const branches = await gitIntegration.listBranches(req.params.name); + res.json(branches); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + router.post('/files/publish', async (req, res) => { const { path: projectPath } = req.body; if (!projectPath) return res.status(400).json({ error: 'path é obrigatório' });