import fs from 'fs'; import os from 'os'; import path from 'path'; import { Command } from 'commander'; import { simpleGit } from 'simple-git'; import { GitlabClient } from './gitlabClient'; import { teleportBotAgent } from './teleportBotAgent'; export interface StartTaskAgentOptions { /** ID (IID) de l’issue GitLab */ id: string; /** Chemin du dépôt (défaut: cwd) */ repoPath?: string; /** Branche de base (défaut: main) */ baseBranch?: string; /** Jeton GitLab (défaut: process.env.GITLAB_TOKEN) */ gitlabToken?: string; /** URL GitLab (défaut: https://gitlab.teleport.ftprod.fr/) */ gitlabUrl?: string; /** Répertoire de travail pour les données et destination (défaut: ~/.ftprod-ai/tbot) */ workDir?: string; /** Join token du Teleport application bot (pour générer TLS via TeleportBot) */ teleportToken?: string; } export interface StartTaskAgentResult { branch: string; commitSha: string; } /** * Démarre une tâche Git à partir d’une issue GitLab : * - récupère le titre de l’issue * - slugifie le titre, préfixe avec ID * - crée, commit et push la branche */ export async function startTaskAgent({ id, repoPath = process.cwd(), baseBranch = 'main', gitlabToken, gitlabUrl = 'https://gitlab.teleport.ftprod.fr/', workDir = os.homedir() + '/.ftprod-ai/tbot', }: StartTaskAgentOptions): Promise { const token = gitlabToken || process.env.GITLAB_TOKEN; if (!token) { throw new Error('Le jeton GitLab est requis (passer --gitlabToken ou définir GITLAB_TOKEN).'); } const git = simpleGit({ baseDir: repoPath }); // Configure Git pour utiliser le certificat TLS client généré par teleportBotAgent, si présent { const destDir = path.join(workDir, 'dest'); const sslCertPath = path.join(destDir, 'tls.crt'); const sslKeyPath = path.join(destDir, 'tls.key'); if ( fs.existsSync(sslCertPath) && fs.existsSync(sslKeyPath) && typeof git.addConfig === 'function' ) { await git.addConfig(`http.${gitlabUrl.replace(/\/+$/, '')}.sslcert`, sslCertPath); await git.addConfig(`http.${gitlabUrl.replace(/\/+$/, '')}.sslkey`, sslKeyPath); } } // Vérifie qu'il n'y a pas de modifications non commit const status = await git.status(); if (status.files.length > 0) { throw new Error( 'Le dépôt contient des modifications non enregistrées. Veuillez commit/ajouter à la mise en pause (stash) avant de lancer une tâche.' ); } // Récupère origin const remotes = await git.getRemotes(true); const origin = remotes.find((r) => r.name === 'origin' && (r.refs.fetch || r.refs.push)); if (!origin) { throw new Error('Remote origin introuvable dans le dépôt.'); } const remoteUrl = origin.refs.fetch || origin.refs.push!; // Extrait le path du projet (namespace/nom) depuis l’URL Git const parseProjectPath = (url: string): string => { let p = url; if (p.endsWith('.git')) p = p.slice(0, -4); if (p.startsWith('git@')) { const idx = p.indexOf(':'); if (idx !== -1) p = p.slice(idx + 1); } else if (p.startsWith('http://') || p.startsWith('https://')) { const parts = p.split('/'); p = parts.slice(3).join('/'); } return p; }; const projectPath = parseProjectPath(remoteUrl); // Encode the full project path (slashes -> %2F) for GitLab API const encodedProject = encodeURIComponent(projectPath); // Initialize GitLab API client (includes token, baseURL, cert/key from workDir) const client = new GitlabClient({ token, url: gitlabUrl, workDir }); // Récupère l’ID du projet via API GitLab let projectId: number; try { const pres = await client.instance.get<{ id?: unknown }>(`/projects/${encodedProject}`); const { id: pid } = pres.data; if (typeof pid !== 'number') { throw new Error(`API ${client.instance.defaults.baseURL}/projects/${encodedProject} returned invalid project id: ${JSON.stringify(pres.data)}`); } projectId = pid; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Impossible de récupérer l’ID du projet ${projectPath} : ${msg}`); } // Récupère l’issue via API GitLab let issueTitle: string; try { const res = await client.instance.get<{ title?: unknown; state?: unknown; }>(`/projects/${projectId}/issues/${id}`); const { status, headers: resHeaders, data } = res; const contentType = resHeaders['content-type'] || 'unknown'; const { title, state } = data as { title?: unknown; state?: unknown }; if (state === 'closed') { throw new Error(`L’issue #${id} est fermée.`); } if (typeof title !== 'string') { const raw = typeof data === 'string' ? data : JSON.stringify(data); const snippet = raw.slice(0, 200) + '...'; throw new Error( `API ${client.instance.defaults.baseURL}/projects/${projectId}/issues/${id} responded with status ${status} and content-type ${contentType}. Unexpected payload: ${snippet}` ); } issueTitle = title; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Impossible de récupérer l’issue #${id} : ${msg}`); } // Description : normalize title, extract first 5 words, lowercase, ASCII, hyphens const sanitize = (txt: string): string => txt .trim() .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9\s]+/g, ' ') // keep alnum and spaces .replace(/\s+/g, ' ') // collapse spaces .trim(); const cleaned = sanitize(issueTitle); const words = cleaned.split(' ').filter((w) => w.length > 0); const descWords = words.slice(0, 5); const descSlug = descWords.join('-'); const branch = `feat/gl-${id}-${descSlug}`; // Vérifie origin let hasOrigin = false; try { const rs = await git.getRemotes(true); hasOrigin = rs.some((r) => r.name === 'origin' && (r.refs.fetch || r.refs.push)); } catch { hasOrigin = false; } if (hasOrigin) { await git.fetch(); await git.checkout(baseBranch); await git.pull('origin', baseBranch); } else { console.warn('Aucun remote origin détecté, mode local : pas de fetch/pull/push.'); await git.checkout(baseBranch); } // Vérifie si une branche existe déjà pour cette tâche (locale ou distante) const allBranches = (await git.branch(['-a'])).all; const branchPrefix = `feat/gl-${id}-`; const localMatches = allBranches.filter( (b) => !b.startsWith('remotes/') && b.startsWith(branchPrefix) ); const remoteMatches = allBranches .filter((b) => b.startsWith(`remotes/origin/${branchPrefix}`)) .map((b) => b.replace('remotes/origin/', '')); if (localMatches.length > 0 || remoteMatches.length > 0) { const existingBranch = localMatches[0] ?? remoteMatches[0]; console.warn( `La branche ${existingBranch} existe déjà (${localMatches.length > 0 ? 'locale' : 'distante'}). Passage sur la branche existante.` ); if (localMatches.length > 0) { await git.checkout(existingBranch); } else { await git.checkoutBranch(existingBranch, `origin/${existingBranch}`); } // Push branch sur origin même sans nouveau commit if (hasOrigin) { await git.push('origin', existingBranch, { '--set-upstream': null }); } const currentHead = await git.revparse(['HEAD']); return { branch: existingBranch, commitSha: currentHead.trim() }; } // Crée, commit et push (même si pas de nouveau commit) await git.checkoutBranch(branch, baseBranch); await git.add('.'); const defaultMessage = `#${id} ${issueTitle}`; const commitObj = await git.commit(defaultMessage); let commitSha = commitObj.commit; if (!commitSha) { // pas de nouveau commit, utiliser HEAD actuel commitSha = (await git.revparse(['HEAD'])).trim(); } if (hasOrigin) { await git.push('origin', branch, { '--set-upstream': null }); // Lien de la branche à l’issue GitLab (create_branch) try { await client.instance.post( `/projects/${projectId}/issues/${id}/create_branch`, { branch, ref: baseBranch } ); } catch (err: unknown) { const msg2 = err instanceof Error ? err.message : String(err); console.warn( `Impossible de lier la branche ${branch} à l’issue #${id} : ${msg2}` ); } try { await client.instance.put(`/projects/${projectId}/issues/${id}`, { labels: 'In progress', }); } catch (err: unknown) { const msg3 = err instanceof Error ? err.message : String(err); console.warn( `Impossible d’ajouter le label "In progress" à l’issue #${id} : ${msg3}` ); } } return { branch, commitSha }; } /** CLI registration metadata for startTaskAgent */ export const cli = { command: 'start-task', description: 'Démarre une nouvelle tâche Git à partir d’une issue GitLab', builder: (cmd: Command) => cmd.argument('', 'ID de l’issue GitLab (IID, numéro de la tâche)') .option('--repoPath ', 'Chemin du dépôt (défaut: cwd)') .option('--baseBranch ', 'Branche de base (défaut: main)') .option('--gitlabToken ', 'Jeton GitLab (défaut: GITLAB_TOKEN env)') .option('--gitlabUrl ', 'URL GitLab (défaut: https://gitlab.teleport.ftprod.fr/)') .option('--workDir ', "Répertoire de travail pour 'data' et 'dest'") .option('--teleportToken ', 'Join token du Teleport application bot (pour générer TLS via TeleportBot)'), handler: async ( id: string, opts: Omit, ) => { try { const targetWorkDir = opts.workDir || os.homedir() + '/.ftprod-ai/tbot'; const crtPath = path.join(targetWorkDir, 'dest', 'tls.crt'); const keyPath = path.join(targetWorkDir, 'dest', 'tls.key'); if (!fs.existsSync(crtPath) || !fs.existsSync(keyPath)) { if (!opts.teleportToken) { console.error( `TLS certificates manquantes dans ${targetWorkDir}. Veuillez spécifier --teleportToken ou exécuter 'start-app' pour générer les certificats.` ); process.exit(1); } console.log('TLS certificates manquantes, génération via teleportBotAgent...'); await teleportBotAgent({ appName: "gitlab", joinToken: opts.teleportToken, workDir: targetWorkDir, }); } // eslint-disable-next-line @typescript-eslint/no-var-requires const { startTaskAgent: run } = require('./startTaskAgent'); const res = await run({ id, ...opts }); console.log(`Branche créée: ${res.branch}, commit SHA: ${res.commitSha}`); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); if (msg.includes('SSL routines') && msg.includes('certificate expired')) { const targetWorkDir = opts.workDir || os.homedir() + '/.ftprod-ai/tbot'; console.log(`Erreur SSL détectée (certificat expiré): ${msg}`); console.log('Régénération des certificats via teleportBotAgent...'); try { await teleportBotAgent({ appName: 'gitlab', joinToken: opts.teleportToken, workDir: targetWorkDir, }); // eslint-disable-next-line @typescript-eslint/no-var-requires const { startTaskAgent: run } = require('./startTaskAgent'); const res = await run({ id, ...opts }); console.log(`Branche créée: ${res.branch}, commit SHA: ${res.commitSha}`); return; } catch (e2: unknown) { const msg2 = e2 instanceof Error ? e2.message : String(e2); console.error('Erreur après régénération des certificats:', msg2); process.exit(1); } } console.error('Erreur démarrage tâche:', msg); process.exit(1); } }, };