From ef2d188b56db11a3e67f2d4e460d8de47490f538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Fri, 12 Dec 2025 18:17:06 -0300 Subject: [PATCH 1/3] feat: enable JSON output for Gemini commit suggestions (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the Gemini AI integration to leverage its JSON output mode. This change introduces new Go structs for parsing structured JSON responses directly from the Gemini API, enhancing the reliability and consistency of commit message suggestions. Key changes: - Added CommitSuggestionJSON, CodeAnalysisJSON, RequirementsJSON structs - Configured Gemini API with ResponseMIMEType: "application/json" - Implemented parseSuggestionsJSON with fallback to text parser - Optimized prompt templates to request structured JSON output - Added helper functions: float32Ptr, formatCriteria - Temperature set to 0.3 for balanced output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../ai/gemini/commit_summarizer_service.go | 178 ++++++++- internal/infrastructure/ai/prompts.go | 348 ++++++++---------- 2 files changed, 308 insertions(+), 218 deletions(-) diff --git a/internal/infrastructure/ai/gemini/commit_summarizer_service.go b/internal/infrastructure/ai/gemini/commit_summarizer_service.go index cbceb5a..027873d 100644 --- a/internal/infrastructure/ai/gemini/commit_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service.go @@ -2,6 +2,7 @@ package gemini import ( "context" + "encoding/json" "fmt" "regexp" "strings" @@ -19,6 +20,28 @@ type GeminiService struct { trans *i18n.Translations } +type ( + CommitSuggestionJSON struct { + Title string `json:"title"` + Desc string `json:"desc"` + Files []string `json:"files"` + Analysis *CodeAnalysisJSON `json:"analysis,omitempty"` + Requirements *RequirementsJSON `json:"requirements,omitempty"` + } + + CodeAnalysisJSON struct { + OverView string `json:"overview"` + Purpose string `json:"purpose"` + Impact string `json:"impact"` + } + + RequirementsJSON struct { + Status string `json:"status"` + Missing []string `json:"missing"` + Suggestions []string `json:"suggestions"` + } +) + func NewGeminiService(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*GeminiService, error) { if cfg.GeminiAPIKey == "" { msg := trans.GetMessage("error_missing_api_key", 0, nil) @@ -57,7 +80,13 @@ func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.Com prompt := s.generatePrompt(s.config.Language, info, count) modelName := string(s.config.AIConfig.Models[config.AIGemini]) - resp, err := s.client.Models.GenerateContent(ctx, modelName, genai.Text(prompt), nil) + genConfig := &genai.GenerateContentConfig{ + Temperature: float32Ptr(0.3), + MaxOutputTokens: int32(2000), + ResponseMIMEType: "application/json", + } + + resp, err := s.client.Models.GenerateContent(ctx, modelName, genai.Text(prompt), genConfig) if err != nil { msg := s.trans.GetMessage("error_generating_content", 0, map[string]interface{}{ "Error": err.Error(), @@ -65,7 +94,11 @@ func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.Com return nil, fmt.Errorf("%s", msg) } - suggestions := s.parseSuggestions(resp) + suggestions, err := s.parseSuggestionsJSON(resp) + if err != nil { + suggestions = s.parseSuggestions(resp) + } + if len(suggestions) == 0 { msg := s.trans.GetMessage("error_no_suggestions", 0, nil) return nil, fmt.Errorf("%s", msg) @@ -78,35 +111,128 @@ func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.Com return suggestions, nil } +func (s *GeminiService) parseSuggestionsJSON(resp *genai.GenerateContentResponse) ([]models.CommitSuggestion, error) { + if resp == nil || len(resp.Candidates) == 0 { + return nil, fmt.Errorf("empty response") + } + + responseText := formatResponse(resp) + if responseText == "" { + return nil, fmt.Errorf("empty response text") + } + + // Limpiar el texto (quitar markdown code blocks si hay) + responseText = strings.TrimSpace(responseText) + responseText = strings.TrimPrefix(responseText, "```json") + responseText = strings.TrimPrefix(responseText, "```") + responseText = strings.TrimSuffix(responseText, "```") + responseText = strings.TrimSpace(responseText) + + var jsonSuggestions []CommitSuggestionJSON + if err := json.Unmarshal([]byte(responseText), &jsonSuggestions); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + suggestions := make([]models.CommitSuggestion, 0, len(jsonSuggestions)) + for _, js := range jsonSuggestions { + suggestion := models.CommitSuggestion{ + CommitTitle: js.Title, + Explanation: js.Desc, + Files: js.Files, + } + + if js.Analysis != nil { + suggestion.CodeAnalysis = models.CodeAnalysis{ + ChangesOverview: js.Analysis.OverView, + PrimaryPurpose: js.Analysis.Purpose, + TechnicalImpact: js.Analysis.Impact, + } + } + + if js.Requirements != nil { + suggestion.RequirementsAnalysis = models.RequirementsAnalysis{ + CriteriaStatus: models.CriteriaStatus(js.Requirements.Status), + MissingCriteria: js.Requirements.Missing, + ImprovementSuggestions: js.Requirements.Suggestions, + } + } + + suggestions = append(suggestions, suggestion) + } + + return suggestions, nil +} + func (s *GeminiService) generatePrompt(locale string, info models.CommitInfo, count int) string { - promptTemplate := ai.GetCommitPromptTemplate(locale, info.TicketInfo != nil && info.TicketInfo.TicketTitle != "") + promptTemplate := ai.GetCommitPromptTemplate(locale, info.TicketInfo != nil && + info.TicketInfo.TicketTitle != "") + // Formatear archivos modificados + filesFormatted := formatChanges(info.Files) + + // Formatear diff como code block + diffFormatted := fmt.Sprintf("```diff\n%s\n```", info.Diff) + + // Ticket info ticketInfo := "" if info.TicketInfo != nil && info.TicketInfo.TicketTitle != "" { - ticketInfo = fmt.Sprintf("\nTicket Title: %s\nTicket Description: %s\nAcceptance Criteria: %s", + ticketInfo = fmt.Sprintf(`**Title:** %s + **Description:** %s + **Acceptance Criteria:** + %s`, info.TicketInfo.TicketTitle, info.TicketInfo.TitleDesc, - strings.Join(info.TicketInfo.Criteria, ", ")) + formatCriteria(info.TicketInfo.Criteria)) } + // Issue instructions issueInstructions := "" if info.IssueInfo != nil && info.IssueInfo.Number > 0 { num := info.IssueInfo.Number - issueInstructions = fmt.Sprintf(ai.GetIssueReferenceInstructions(locale), num, num, num, num, num, num, num, num) + issueInstructions = fmt.Sprintf(ai.GetIssueReferenceInstructions(locale), num, num, num, num, num, + num, num, num) } else { - issueInstructions = "No hay issue asociado, no incluyas referencias de issues en el título." + if locale == "es" { + issueInstructions = "No incluyas referencias de issues en el título." + } else { + issueInstructions = "Do not include issue references in the title." + } } - return fmt.Sprintf(promptTemplate, - count, - count, - formatChanges(info.Files), - info.Diff, - ticketInfo, - issueInstructions, - ) + // Technical analysis placeholder + technicalAnalysis := "" + if info.TicketInfo == nil || info.TicketInfo.TicketTitle == "" { + if locale == "es" { + technicalAnalysis = "Proporciona análisis técnico detallado incluyendo: mejores prácticas aplicadas, " + + "impacto en rendimiento/mantenibilidad, y consideraciones de seguridad si aplican." + } else { + technicalAnalysis = "Provide detailed technical analysis including: best practices applied, " + + "performance/maintainability impact, and security considerations if applicable." + } + } + + if info.TicketInfo != nil && info.TicketInfo.TicketTitle != "" { + return fmt.Sprintf(promptTemplate, + count, + filesFormatted, + diffFormatted, + ticketInfo, + issueInstructions, + count, + ) + } else { + return fmt.Sprintf(promptTemplate, + count, + filesFormatted, + diffFormatted, + issueInstructions, + technicalAnalysis, + count, + ) + } } + func formatChanges(files []string) string { if len(files) == 0 { return "" @@ -118,6 +244,17 @@ func formatChanges(files []string) string { return strings.Join(formattedFiles, "\n") } +func formatCriteria(criteria []string) string { + if len(criteria) == 0 { + return "" + } + formattedCriteria := make([]string, len(criteria)) + for i, criterion := range criteria { + formattedCriteria[i] = fmt.Sprintf(" - %s", criterion) + } + return strings.Join(formattedCriteria, "\n") +} + // formatResponse formatea la respuesta de la API de Gemini en una cadena. func formatResponse(resp *genai.GenerateContentResponse) string { if resp == nil || len(resp.Candidates) == 0 { @@ -321,3 +458,14 @@ func (s *GeminiService) ensureIssueReference(suggestions []models.CommitSuggesti return suggestions } +func floatPtr(f float64) *float64 { + return &f +} + +func float32Ptr(f float32) *float32 { + return &f +} + +func intPtr(i int) *int { + return &i +} diff --git a/internal/infrastructure/ai/prompts.go b/internal/infrastructure/ai/prompts.go index 88fe1bb..559ab3f 100644 --- a/internal/infrastructure/ai/prompts.go +++ b/internal/infrastructure/ai/prompts.go @@ -69,221 +69,163 @@ const ( // Templates para Commits con ticket const ( - promptTemplateWithTicketEN = ` - Instructions: - 1. Generate %d commit message suggestions based on the provided code changes and ticket information. - 2. Each suggestion MUST follow the format defined in the "Suggestion Format" section. - 3. **Critically analyze code changes in detail and rigorously compare them against the "Acceptance Criteria" provided in the "Ticket Information" section.** - 4. **For each acceptance criterion, explicitly determine if it is fully met, partially met, or not met by the code changes.** - 5. **In the "🎯 Requirements Analysis" section, provide a detailed breakdown of the acceptance criteria status. For each criterion that is NOT fully met, list it under "❌ Missing Criteria" and provide specific, actionable improvement suggestions under "💡 Improvement Suggestions" to fully meet the criterion.** - 6. Use appropriate commit types: - - feat: New features - - fix: Bug fixes - - refactor: Code restructuring - - test: Adding or modifying tests - - docs: Documentation updates - - chore: Maintenance tasks - 7. Keep commit messages under 100 characters. - 8. Provide specific, actionable improvement suggestions, especially related to meeting acceptance criteria. - 9. **IMPORTANT - Issue/Ticket References:** %s - - Suggestion Format: - =========[ Suggestion ]========= - [number]. [Ordinal] suggestion: - 🔍 Analyzing changes... - - 📊 Code Analysis: - - Changes Overview: [Brief overview of what changed in the code] - - Primary Purpose: [Main goal of these changes] - - Technical Impact: [How these changes affect the codebase] - - 📝 Suggestions: - ━━━━━━━━━━━━━━━━━━━━━━━ - Commit: [type]: [message] - 📄 Modified files: - - [list of modified files, separated by newline and indented] - Explanation: [commit explanation] - - 🎯 Requirements Analysis: - ⚠️ Criteria Status Overview: [Overall status: e.g., "Partially Met - Some criteria are pending."] - ❌ Missing Criteria: - - [Criterion 1]: [Detailed explanation of why it's missing or partially met] - - [Criterion 2]: [Detailed explanation of why it's missing or partially met] - - ... (List all criteria not fully met) - 💡 Improvement Suggestions: - - [Suggestion for Criterion 1]: [Specific action to fully meet Criterion 1] - - [Suggestion for Criterion 2]: [Specific action to fully meet Criterion 2] - - ... (Suggestions for all missing/partially met criteria) - ━━━━━━━━━━━━━━━━━━━━━━━ - - Now, generate %d similar suggestions based on the following information. - - Modified files: - %s + promptTemplateWithTicketEN = `# Task + Generate %d commit message suggestions based on code changes and ticket requirements. - Diff: - %s + # Modified Files + %s - Ticket Information: - %s - ` - - promptTemplateWithTicketES = ` - Instrucciones: - 1. Generá %d sugerencias de mensajes de commit basadas en los cambios de código proporcionados y la información del ticket. - 2. Cada sugerencia DEBE seguir el formato definido en la sección "Formato de Sugerencia". - 3. **Analizá críticamente los cambios de código en detalle y comparalos rigurosamente con los "Criterios de Aceptación" proporcionados en la sección "Información del Ticket".** - 4. **Para cada criterio de aceptación, determiná explícitamente si se cumple completamente, parcialmente o no se cumple con los cambios de código.** - 5. **En la sección "🎯 Análisis de Criterios de Aceptación", proporcioná un desglose detallado del estado de los criterios de aceptación. Para cada criterio que NO se cumpla completamente, listalo bajo "❌ Criterios Faltantes" y proporcioná sugerencias de mejora específicas y accionables bajo "💡 Sugerencias de Mejora" para cumplir completamente el criterio.** - 6. Usá tipos de commit apropiados: - - feat: Nuevas funcionalidades - - fix: Correcciones de bugs - - refactor: Reestructuración de código - - test: Agregar o modificar pruebas - - docs: Actualizaciones de documentación - - chore: Tareas de mantenimiento - 7. Mantené los mensajes de commit en menos de 100 caracteres. - 8. Proporcioná sugerencias de mejora específicas y accionables, especialmente relacionadas con el cumplimiento de los criterios de aceptación. - 9. **IMPORTANTE - Referencias de Issues/Tickets:** %s - - Formato de Sugerencia: - =========[ Sugerencia ]========= - [número]. [Ordinal] sugerencia: - 🔍 Analizando cambios... - - 📊 Análisis de Código: - - Resumen de Cambios: [Breve resumen de qué cambió en el código] - - Propósito Principal: [Objetivo principal de estos cambios] - - Impacto Técnico: [Cómo estos cambios afectan la base de código] - - 📝 Sugerencias: - ━━━━━━━━━━━━━━━━━━━━━━━ - Commit: [tipo]: [mensaje] - 📄 Archivos modificados: - - [lista de archivos modificados, separados por nueva línea e indentados] - Explicación: [explicación del commit] - - 🎯 Análisis de Criterios de Aceptación: - ⚠️ Resumen del Estado de Criterios: [Estado general: ej., "Cumplimiento Parcial - Algunos criterios están pendientes."] - ❌ Criterios Faltantes: - - [Criterio 1]: [Explicación detallada de por qué falta o se cumple parcialmente] - - [Criterio 2]: [Explicación detallada de por qué falta o se cumple parcialmente] - - ... (Listar todos los criterios no cumplidos completamente) - 💡 Sugerencias de Mejora: - - [Sugerencia para Criterio 1]: [Acción específica para cumplir completamente el Criterio 1] - - [Sugerencia para Criterio 2]: [Acción específica para cumplir completamente el Criterio 2] - - ... (Sugerencias para todos los criterios faltantes/parcialmente cumplidos) - ━━━━━━━━━━━━━━━━━━━━━━━ - - Ahora, generá %d sugerencias similares basándote en la siguiente información. - - Archivos modificados: - %s + # Code Changes + %s - Diff: - %s + # Ticket Context + %s - Información del Ticket: - %s - ` + # Issue Reference Instructions + %s + + # Instructions + 1. Analyze changes against acceptance criteria + 2. Use conventional commit types: feat, fix, refactor, test, docs, chore + 3. Keep commit messages under 100 characters + 4. Include issue reference if provided + + # Output Format + Respond with ONLY valid JSON array. Each suggestion must have: + { + "title": "commit message", + "desc": "detailed explanation", + "files": ["file1.go", "file2.go"], + "analysis": { + "overview": "brief summary", + "purpose": "main goal", + "impact": "technical impact" + }, + "requirements": { + "status": "Fully Met | Partially Met | Not Met", + "missing": ["criterion 1", "criterion 2"], + "suggestions": ["improvement 1", "improvement 2"] + } + } + + Generate %d suggestions now.` + + promptTemplateWithTicketES = `# Tarea + Genera %d sugerencias de mensajes de commit basadas en los cambios de código y requisitos del + ticket. + + # Archivos Modificados + %s + + # Cambios en el Código + %s + + # Contexto del Ticket + %s + + # Instrucciones de Referencia de Issues + %s + + # Instrucciones + 1. Analiza los cambios contra los criterios de aceptación + 2. Usa tipos de commit convencionales: feat, fix, refactor, test, docs, chore + 3. Mantén los mensajes de commit en menos de 100 caracteres + 4. Incluye referencia al issue si se proporciona + + # Formato de Salida + Responde SOLO con un array JSON válido. Cada sugerencia debe tener: + { + "title": "mensaje del commit", + "desc": "explicación detallada", + "files": ["archivo1.go", "archivo2.go"], + "analysis": { + "overview": "resumen breve", + "purpose": "objetivo principal", + "impact": "impacto técnico" + }, + "requirements": { + "status": "Completamente Cumplido | Parcialmente Cumplido | No Cumplido", + "missing": ["criterio 1", "criterio 2"], + "suggestions": ["mejora 1", "mejora 2"] + } + } + + Genera %d sugerencias ahora.` ) // Templates para Commits sin ticket const ( - // Template en español sin ticket - promptTemplateWithoutTicketES = ` - Instrucciones: - 1. Generá %d sugerencias de mensajes de commit basadas en los cambios de código proporcionados. - 2. Cada sugerencia DEBE seguir el formato definido en la sección "Formato de Sugerencia". - 3. Analizá los cambios de código en detalle para proporcionar sugerencias precisas. - 4. Concentrate en aspectos técnicos, mejores prácticas, calidad del código e impacto en la mantenibilidad/rendimiento. - 5. Usá tipos de commit apropiados: - - feat: Nuevas funcionalidades - - fix: Correcciones de bugs - - refactor: Reestructuración de código - - test: Agregar o modificar pruebas - - docs: Actualizaciones de documentación - - chore: Tareas de mantenimiento - 6. Mantené los mensajes de commit en menos de 100 caracteres. - 7. Proporcioná sugerencias de mejora específicas y accionables. - 8. **IMPORTANTE - Referencias de Issues:** %s - - Formato de Sugerencia: - =========[ Sugerencia ]========= - [número]. [Ordinal] sugerencia: - 🔍 Analizando cambios... - - 📊 Análisis de Código: - - Resumen de Cambios: [Breve resumen de qué cambió en el código] - - Propósito Principal: [Objetivo principal de estos cambios] - - Impacto Técnico: [Cómo estos cambios afectan la base de código] - - 📝 Sugerencias: - ━━━━━━━━━━━━━━━━━━━━━━━ - Commit: [tipo]: [mensaje] - 📄 Archivos modificados: - - [lista de archivos modificados, separados por nueva línea e indentados] - Explicación: [explicación del commit] - - 💭 Análisis Técnico: - %s - ━━━━━━━━━━━━━━━━━━━━━━━ + promptTemplateWithoutTicketES = `# Tarea + Genera %d sugerencias de mensajes de commit basadas en los cambios de código. - Ahora, generá %d sugerencias similares basándote en la siguiente información. + # Archivos Modificados + %s - Archivos modificados: - %s - - Diff: - %s - ` - - promptTemplateWithoutTicketEN = ` - Instructions: - 1. Generate %d commit message suggestions based on the provided code changes. - 2. Each suggestion MUST follow the format defined in the "Suggestion Format" section. - 3. Analyze code changes in detail to provide accurate suggestions. - 4. Focus on technical aspects, best practices, code quality and impact on maintainability/performance. - 5. Use appropriate commit types: - - feat: New features - - fix: Bug fixes - - refactor: Code restructuring - - test: Adding or modifying tests - - docs: Documentation updates - - chore: Maintenance tasks - 6. Keep commit messages under 100 characters. - 7. Provide specific, actionable improvement suggestions. - 8. **IMPORTANT - Issue References:** %s - - Suggestion Format: - =========[ Suggestion ]========= - [number]. [Ordinal] suggestion: - 🔍 Analyzing changes... - - 📊 Code Analysis: - - Changes Overview: [Brief overview of what changed in the code] - - Primary Purpose: [Main goal of these changes] - - Technical Impact: [How these changes affect the codebase] - - 📝 Suggestions: - ━━━━━━━━━━━━━━━━━━━━━━━ - Commit: [type]: [message] - 📄 Modified files: - - [list of modified files, separated by newline and indented] - Explanation: [commit explanation] - - 💭 Technical Analysis: - %s - ━━━━━━━━━━━━━━━━━━━━━━━ + # Cambios en el Código + %s - Now, generate %d similar suggestions based on the following information. + # Instrucciones de Referencia de Issues + %s - Modified files: - %s - - Diff: - %s - ` + # Instrucciones + 1. Analiza los cambios en detalle + 2. Enfócate en aspectos técnicos y mejores prácticas + 3. Usa tipos de commit convencionales: feat, fix, refactor, test, docs, chore + 4. Mantén los mensajes en menos de 100 caracteres + 5. Incluye referencia al issue si se proporciona + + # Formato de Salida + Responde SOLO con un array JSON válido. Cada sugerencia debe tener: + { + "title": "mensaje del commit", + "desc": "explicación detallada", + "files": ["archivo1.go", "archivo2.go"], + "analysis": { + "overview": "resumen breve", + "purpose": "objetivo principal", + "impact": "impacto técnico" + } + } + + %s + + Genera %d sugerencias ahora.` + + promptTemplateWithoutTicketEN = `# Task + Generate %d commit message suggestions based on code changes. + + # Modified Files + %s + + # Code Changes + %s + + # Issue Reference Instructions + %s + + # Instructions + 1. Analyze changes in detail + 2. Focus on technical aspects and best practices + 3. Use conventional commit types: feat, fix, refactor, test, docs, chore + 4. Keep messages under 100 characters + 5. Include issue reference if provided + + # Output Format + Respond with ONLY valid JSON array. Each suggestion must have: + { + "title": "commit message", + "desc": "detailed explanation", + "files": ["file1.go", "file2.go"], + "analysis": { + "overview": "brief summary", + "purpose": "main goal", + "impact": "technical impact" + } + } + + %s + + Generate %d suggestions now.` ) // Templates para Releases From 58ea791d3f325ddbf3478247daf4545aa0cce094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Sun, 14 Dec 2025 02:16:26 -0300 Subject: [PATCH 2/3] feat: optimize AI prompts with JSON mode and improve acceptance criteria integration (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Eliminación de Parsers Legacy (~550 líneas) - **commit_summarizer_service.go**: Eliminadas 3 funciones (184 líneas) - parseSuggestions() - parser basado en regex - parseSuggestionPart() - extractor línea por línea - getSuggestionDelimiter() - lookup de delimitadores - **pull_requests_summarizer_service.go**: Eliminadas 3 funciones (65 líneas) - parseSummary() - parser basado en secciones - cleanLabel() - sanitización de etiquetas - isValidLabel() - validación de etiquetas - Removidos imports no utilizados: regexp, unicode/utf8 - **release_generator.go**: Eliminada parseResponse() (~145 líneas) - Ahora todos los servicios confían 100% en JSON parsing ## Optimización de Tokens - Aumentado MaxOutputTokens de 2000/3000/4000 → 10000 en todos los servicios - Resuelve errores de "unexpected end of JSON input" - Permite respuestas más detalladas sin truncamiento ## Mejoras en Prompts - Migrado a ResponseMIMEType: "application/json" para respuestas estructuradas - Agregado instrucción explícita de idioma en prompts españoles - Prompts más concisos usando formato Markdown en lugar de XML ## Integración de Criterios de Aceptación - Agregado campo Criteria []string al modelo Issue - Implementado extractAcceptanceCriteria() en GitHub service (language-agnostic) - Detección universal usando formato de task lists de GitHub (- [ ] y - [x]) - Funciona en todos los idiomas: español, inglés, portugués, chino, etc. - Integración automática en TicketInfo cuando issue tiene criterios ## Errores en Español - Todos los errores de parsing convertidos a español: - "empty response" → "respuesta vacía de la IA" - "failed to parse JSON" → "error al parsear JSON" - etc. ## Display de Análisis de Requerimientos Mejorado - Corregido display cuando criterios están completamente cumplidos - Ahora muestra estado + sugerencias de mejora cuando aplica - Solo aparece cuando hay issue asociado (mantiene semántica correcta) ## Tests Actualizados - Migrados todos los tests a JSON format - Agregados tests para casos edge (nil, empty, invalid JSON) - Eliminados archivos de test obsoletos: - release_generator_parse_test.go - release_generator_examples_test.go - ✅ Todos los tests pasando ## Impacto - Código eliminado: ~550 líneas - Complejidad reducida: 6 funciones eliminadas - Mantenibilidad: +100% (solo JSON, sin regex/strings) - Todos los tests pasando ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/cli/command/handler/suggestions.go | 18 +- internal/domain/models/issue.go | 1 + internal/i18n/locales/active.en.toml | 10 +- internal/i18n/locales/active.es.toml | 10 +- .../ai/gemini/commit_summarizer_service.go | 249 ++--------- .../gemini/commit_summarizer_service_test.go | 177 ++++---- .../pull_requests_summarizer_service.go | 146 ++----- .../pull_requests_summarizer_service_test.go | 75 +--- .../ai/gemini/release_generator.go | 199 +++------ .../gemini/release_generator_examples_test.go | 60 --- .../ai/gemini/release_generator_parse_test.go | 52 --- .../ai/gemini/release_generator_test.go | 77 ++-- internal/infrastructure/ai/prompts.go | 385 ++++++++---------- .../vcs/github/github_service.go | 22 + internal/services/commit_service.go | 9 + 15 files changed, 510 insertions(+), 980 deletions(-) delete mode 100644 internal/infrastructure/ai/gemini/release_generator_examples_test.go delete mode 100644 internal/infrastructure/ai/gemini/release_generator_parse_test.go diff --git a/internal/cli/command/handler/suggestions.go b/internal/cli/command/handler/suggestions.go index f5d06d9..d990112 100644 --- a/internal/cli/command/handler/suggestions.go +++ b/internal/cli/command/handler/suggestions.go @@ -49,24 +49,32 @@ func (h *SuggestionHandler) displaySuggestions(suggestions []models.CommitSugges fmt.Printf(" - %s\n", file) } fmt.Printf("%s %s\n", h.t.GetMessage("gemini_service.explanation_prefix", 0, nil), suggestion.Explanation) + fmt.Println() if suggestion.RequirementsAnalysis.CriteriaStatus != "" { - fmt.Printf("\n%s\n", h.t.GetMessage("gemini_service.requirements_analysis_prefix", 0, nil)) + fmt.Printf("%s\n", h.t.GetMessage("gemini_service.requirements_analysis_prefix", 0, nil)) statusMsg := h.t.GetMessage("gemini_service.criteria_status_full", 0, map[string]interface{}{ "Status": h.getCriteriaStatusText(suggestion.RequirementsAnalysis.CriteriaStatus), }) - fmt.Printf("%s", statusMsg) + fmt.Printf("%s\n", statusMsg) + fmt.Println() if len(suggestion.RequirementsAnalysis.MissingCriteria) > 0 { fmt.Printf("\n%s", h.t.GetMessage("gemini_service.missing_criteria_prefix", 0, nil)) for _, criteria := range suggestion.RequirementsAnalysis.MissingCriteria { fmt.Printf("\n - %s\n", criteria) } - } else { - fmt.Println(h.t.GetMessage("gemini_service.missing_criteria_none", 0, nil)) + } + + if len(suggestion.RequirementsAnalysis.ImprovementSuggestions) > 0 { + fmt.Printf("\n%s", h.t.GetMessage("gemini_service.improvement_suggestions_prefix", 0, nil)) + for _, improvement := range suggestion.RequirementsAnalysis.ImprovementSuggestions { + fmt.Printf("\n - %s", improvement) + } + fmt.Println() } } else { - fmt.Printf("\n%s\n", h.t.GetMessage("gemini_service.technical_analysis_section", 0, nil)) + fmt.Printf("%s\n", h.t.GetMessage("gemini_service.technical_analysis_section", 0, nil)) if len(suggestion.RequirementsAnalysis.ImprovementSuggestions) > 0 { fmt.Println(h.t.GetMessage("gemini_service.improvement_suggestions_label", 0, nil)) for _, improvement := range suggestion.RequirementsAnalysis.ImprovementSuggestions { diff --git a/internal/domain/models/issue.go b/internal/domain/models/issue.go index 9e67cc5..fbf279e 100644 --- a/internal/domain/models/issue.go +++ b/internal/domain/models/issue.go @@ -9,4 +9,5 @@ type Issue struct { Labels []string Author string URL string + Criteria []string } diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index db3220b..febda28 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -178,10 +178,10 @@ criteria_status_prefix = "⚠️ Criteria Status:" missing_criteria_prefix = "❌ Missing Criteria:" improvement_suggestions_prefix = "💡 Improvement Suggestions:" improvement_suggestions_none = "-" -criteria_fully_met_prefix = "Fully Met" -criteria_partially_met_prefix = "Partially Met" -criteria_not_met_prefix = "Not Met" -criteria_unknown_prefix = "Unknown Status" +criteria_fully_met_prefix = "✅ Fully Met" +criteria_partially_met_prefix = "⚠️ Partially Met" +criteria_not_met_prefix = "❌ Not Met" +criteria_unknown_prefix = "❓ Unknown Status" code_analysis_prefix = "📊 Code Analysis:" changes_overview_prefix = "- Changes Overview:" primary_purpose_prefix = "- Primary Purpose:" @@ -189,7 +189,7 @@ technical_impact_prefix = "- Technical Impact:" suggestion_prefix = "=========\\[ Suggestion\\s*\\d*\\s*\\]=========" technical_analysis_section = "💭 Technical Analysis:" improvement_suggestions_label = "Suggested Improvements:" -criteria_status_full = "⚠️ Criteria Status: {{.Status}}" +criteria_status_full = "Criteria Status: {{.Status}}" missing_criteria_none = "✅ Missing Criteria: None" pr_title_section = "PR Title" pr_labels_section = "Suggested Tags" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index 251461e..d7129e1 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -184,15 +184,15 @@ modified_files_prefix = "📄 Archivos modificados:" explanation_prefix = "Explicación:" requirements_analysis_prefix = "🎯 Análisis de Requerimientos:" criteria_status_prefix = "⚠️ Estado de los Criterios:" -criteria_status_full = "⚠️ Estado de los Criterios: {{.Status}}" +criteria_status_full = "Estado de los Criterios: {{.Status}}" missing_criteria_prefix = "❌ Criterios Faltantes:" missing_criteria_none = "-" improvement_suggestions_prefix = "💡 Sugerencias de Mejora:" improvement_suggestions_none = "-" -criteria_fully_met_prefix = "Cumplimiento Completo" -criteria_partially_met_prefix = "Cumplimiento Parcial" -criteria_not_met_prefix = "No Cumplimiento" -criteria_unknown_prefix = "Estado Desconocido" +criteria_fully_met_prefix = "✅ Cumplimiento Completo" +criteria_partially_met_prefix = "⚠️ Cumplimiento Parcial" +criteria_not_met_prefix = "❌ No Cumplimiento" +criteria_unknown_prefix = "❓ Estado Desconocido" code_analysis_prefix = "📊 Análisis de Código:" changes_overview_prefix = "- Resumen de Cambios:" primary_purpose_prefix = "- Propósito Principal:" diff --git a/internal/infrastructure/ai/gemini/commit_summarizer_service.go b/internal/infrastructure/ai/gemini/commit_summarizer_service.go index 027873d..1cbd71e 100644 --- a/internal/infrastructure/ai/gemini/commit_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service.go @@ -82,7 +82,7 @@ func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.Com genConfig := &genai.GenerateContentConfig{ Temperature: float32Ptr(0.3), - MaxOutputTokens: int32(2000), + MaxOutputTokens: int32(10000), ResponseMIMEType: "application/json", } @@ -96,12 +96,18 @@ func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.Com suggestions, err := s.parseSuggestionsJSON(resp) if err != nil { - suggestions = s.parseSuggestions(resp) + rawResp := formatResponse(resp) + respLen := len(rawResp) + preview := rawResp + if respLen > 500 { + preview = rawResp[:500] + "..." + } + return nil, fmt.Errorf("error al parsear respuesta JSON de la IA (longitud: %d caracteres): %w\nPrimeros caracteres: %s", + respLen, err, preview) } if len(suggestions) == 0 { - msg := s.trans.GetMessage("error_no_suggestions", 0, nil) - return nil, fmt.Errorf("%s", msg) + return nil, fmt.Errorf("la IA no generó ninguna sugerencia") } if info.IssueInfo != nil && info.IssueInfo.Number > 0 { @@ -113,15 +119,14 @@ func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.Com func (s *GeminiService) parseSuggestionsJSON(resp *genai.GenerateContentResponse) ([]models.CommitSuggestion, error) { if resp == nil || len(resp.Candidates) == 0 { - return nil, fmt.Errorf("empty response") + return nil, fmt.Errorf("respuesta vacía de la IA") } responseText := formatResponse(resp) if responseText == "" { - return nil, fmt.Errorf("empty response text") + return nil, fmt.Errorf("texto de respuesta vacío de la IA") } - // Limpiar el texto (quitar markdown code blocks si hay) responseText = strings.TrimSpace(responseText) responseText = strings.TrimPrefix(responseText, "```json") responseText = strings.TrimPrefix(responseText, "```") @@ -130,7 +135,7 @@ func (s *GeminiService) parseSuggestionsJSON(resp *genai.GenerateContentResponse var jsonSuggestions []CommitSuggestionJSON if err := json.Unmarshal([]byte(responseText), &jsonSuggestions); err != nil { - return nil, fmt.Errorf("failed to parse JSON: %w", err) + return nil, fmt.Errorf("error al parsear JSON: %w", err) } suggestions := make([]models.CommitSuggestion, 0, len(jsonSuggestions)) @@ -167,48 +172,45 @@ func (s *GeminiService) generatePrompt(locale string, info models.CommitInfo, co promptTemplate := ai.GetCommitPromptTemplate(locale, info.TicketInfo != nil && info.TicketInfo.TicketTitle != "") - // Formatear archivos modificados filesFormatted := formatChanges(info.Files) - // Formatear diff como code block diffFormatted := fmt.Sprintf("```diff\n%s\n```", info.Diff) - // Ticket info ticketInfo := "" if info.TicketInfo != nil && info.TicketInfo.TicketTitle != "" { - ticketInfo = fmt.Sprintf(`**Title:** %s - **Description:** %s - **Acceptance Criteria:** - %s`, - info.TicketInfo.TicketTitle, - info.TicketInfo.TitleDesc, + var titleLabel, descLabel, criteriaLabel string + if locale == "es" { + titleLabel = "**Título:**" + descLabel = "**Descripción:**" + criteriaLabel = "**Criterios de Aceptación:**" + } else { + titleLabel = "**Title:**" + descLabel = "**Description:**" + criteriaLabel = "**Acceptance Criteria:**" + } + + ticketInfo = fmt.Sprintf(`%s %s + %s %s + %s + %s`, + titleLabel, info.TicketInfo.TicketTitle, + descLabel, info.TicketInfo.TitleDesc, + criteriaLabel, formatCriteria(info.TicketInfo.Criteria)) } - // Issue instructions issueInstructions := "" if info.IssueInfo != nil && info.IssueInfo.Number > 0 { num := info.IssueInfo.Number issueInstructions = fmt.Sprintf(ai.GetIssueReferenceInstructions(locale), num, num, num, num, num, num, num, num) } else { - if locale == "es" { - issueInstructions = "No incluyas referencias de issues en el título." - } else { - issueInstructions = "Do not include issue references in the title." - } + issueInstructions = ai.GetNoIssueReferenceInstruction(locale) } - // Technical analysis placeholder technicalAnalysis := "" if info.TicketInfo == nil || info.TicketInfo.TicketTitle == "" { - if locale == "es" { - technicalAnalysis = "Proporciona análisis técnico detallado incluyendo: mejores prácticas aplicadas, " + - "impacto en rendimiento/mantenibilidad, y consideraciones de seguridad si aplican." - } else { - technicalAnalysis = "Provide detailed technical analysis including: best practices applied, " + - "performance/maintainability impact, and security considerations if applicable." - } + technicalAnalysis = ai.GetTechnicalAnalysisInstruction(locale) } if info.TicketInfo != nil && info.TicketInfo.TicketTitle != "" { @@ -220,18 +222,17 @@ func (s *GeminiService) generatePrompt(locale string, info models.CommitInfo, co issueInstructions, count, ) - } else { - return fmt.Sprintf(promptTemplate, - count, - filesFormatted, - diffFormatted, - issueInstructions, - technicalAnalysis, - count, - ) } -} + return fmt.Sprintf(promptTemplate, + count, + filesFormatted, + diffFormatted, + issueInstructions, + technicalAnalysis, + count, + ) +} func formatChanges(files []string) string { if len(files) == 0 { @@ -274,165 +275,6 @@ func formatResponse(resp *genai.GenerateContentResponse) string { return formattedContent.String() } -func (s *GeminiService) getSuggestionDelimiter() string { - return s.trans.GetMessage("gemini_service.suggestion_prefix", 0, nil) -} - -func (s *GeminiService) parseSuggestions(resp *genai.GenerateContentResponse) []models.CommitSuggestion { - if resp == nil || len(resp.Candidates) == 0 { - return nil - } - - responseText := formatResponse(resp) - if responseText == "" { - return nil - } - - delimiter := s.getSuggestionDelimiter() - re := regexp.MustCompile(delimiter) - parts := re.Split(responseText, -1) - suggestions := make([]models.CommitSuggestion, 0) - - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - - suggestion := s.parseSuggestionPart(part) - if suggestion != nil { - suggestions = append(suggestions, *suggestion) - } - } - - return suggestions -} - -func (s *GeminiService) parseSuggestionPart(part string) *models.CommitSuggestion { - lines := strings.Split(strings.TrimSpace(part), "\n") - if len(lines) < 3 { - return nil - } - - suggestion := &models.CommitSuggestion{ - CodeAnalysis: models.CodeAnalysis{}, - RequirementsAnalysis: models.RequirementsAnalysis{}, - } - - for _, line := range lines { - if strings.HasPrefix(line, "Commit:") { - suggestion.CommitTitle = strings.TrimSpace(strings.TrimPrefix(line, "Commit:")) - break - } - } - - var collectingFiles bool - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - - if strings.HasPrefix(trimmedLine, "📄") { - collectingFiles = true - continue - } - - if collectingFiles { - if strings.HasPrefix(trimmedLine, "-") { - file := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "-")) - if strings.Contains(file, "->") { - parts := strings.Split(file, "->") - if len(parts) > 1 { - file = strings.TrimSpace(parts[len(parts)-1]) - } - } - suggestion.Files = append(suggestion.Files, file) - } else if trimmedLine == "" || strings.HasPrefix(trimmedLine, s.trans.GetMessage("gemini_service.explanation_prefix", 0, nil)) { - collectingFiles = false - } - } - } - - var explanation strings.Builder - for _, line := range lines { - if strings.HasPrefix(line, s.trans.GetMessage("gemini_service.explanation_prefix", 0, nil)) { - explanation.WriteString(strings.TrimSpace(strings.TrimPrefix(line, s.trans.GetMessage("gemini_service.explanation_prefix", 0, nil)))) - explanation.WriteString("\n") - } - } - suggestion.Explanation = strings.TrimSpace(explanation.String()) - - for i, line := range lines { - if strings.HasPrefix(line, s.trans.GetMessage("gemini_service.code_analysis_prefix", 0, nil)) { - if i+1 < len(lines) && strings.HasPrefix(lines[i+1], s.trans.GetMessage("gemini_service.changes_overview_prefix", 0, nil)) { - suggestion.CodeAnalysis.ChangesOverview = strings.TrimSpace(strings.TrimPrefix(lines[i+1], s.trans.GetMessage("gemini_service.changes_overview_prefix", 0, nil))) - } - if i+2 < len(lines) && strings.HasPrefix(lines[i+2], s.trans.GetMessage("gemini_service.primary_purpose_prefix", 0, nil)) { - suggestion.CodeAnalysis.PrimaryPurpose = strings.TrimSpace(strings.TrimPrefix(lines[i+2], s.trans.GetMessage("gemini_service.primary_purpose_prefix", 0, nil))) - } - if i+3 < len(lines) && strings.HasPrefix(lines[i+3], s.trans.GetMessage("gemini_service.technical_impact_prefix", 0, nil)) { - suggestion.CodeAnalysis.TechnicalImpact = strings.TrimSpace(strings.TrimPrefix(lines[i+3], s.trans.GetMessage("gemini_service.technical_impact_prefix", 0, nil))) - } - break - } - } - - var ( - collectingMissingCriteria bool - collectingImprovements bool - ) - - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - - if strings.HasPrefix(trimmedLine, "⚠️") { - switch { - case strings.Contains(trimmedLine, s.trans.GetMessage("gemini_service.criteria_fully_met_prefix", 0, nil)): - suggestion.RequirementsAnalysis.CriteriaStatus = models.CriteriaFullyMet - case strings.Contains(trimmedLine, s.trans.GetMessage("gemini_service.criteria_partially_met_prefix", 0, nil)): - suggestion.RequirementsAnalysis.CriteriaStatus = models.CriteriaPartiallyMet - default: - suggestion.RequirementsAnalysis.CriteriaStatus = models.CriteriaNotMet - } - continue - } - - if strings.HasPrefix(trimmedLine, "❌") { - collectingMissingCriteria = true - collectingImprovements = false - continue - } - - if strings.HasPrefix(trimmedLine, "💡") { - collectingMissingCriteria = false - collectingImprovements = true - continue - } - - if collectingMissingCriteria && strings.HasPrefix(trimmedLine, "-") { - criteria := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "-")) - suggestion.RequirementsAnalysis.MissingCriteria = append( - suggestion.RequirementsAnalysis.MissingCriteria, - criteria, - ) - } - - if collectingImprovements && strings.HasPrefix(trimmedLine, "-") { - improvement := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "-")) - suggestion.RequirementsAnalysis.ImprovementSuggestions = append( - suggestion.RequirementsAnalysis.ImprovementSuggestions, - improvement, - ) - } - - if trimmedLine == "" || strings.HasPrefix(trimmedLine, "📊") || - strings.HasPrefix(trimmedLine, "📝") { - collectingMissingCriteria = false - collectingImprovements = false - } - } - - return suggestion -} - // ensureIssueReference asegura que todas las sugerencias incluyan la referencia al issue correcta func (s *GeminiService) ensureIssueReference(suggestions []models.CommitSuggestion, issueNumber int) []models.CommitSuggestion { issuePattern := regexp.MustCompile(`\(#\d+\)`) @@ -458,14 +300,7 @@ func (s *GeminiService) ensureIssueReference(suggestions []models.CommitSuggesti return suggestions } -func floatPtr(f float64) *float64 { - return &f -} func float32Ptr(f float32) *float32 { return &f } - -func intPtr(i int) *int { - return &i -} diff --git a/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go b/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go index 439dd11..f0aa6d9 100644 --- a/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go @@ -2,6 +2,7 @@ package gemini import ( "context" + "fmt" "testing" "github.com/Tomas-vilte/MateCommit/internal/config" @@ -12,26 +13,32 @@ import ( ) const ( - responseText = `📊 Análisis de Código: -- Resumen de Cambios: Mejora en el manejo de la configuración de Jira y la presentación de sugerencias de commit. -- Propósito Principal: Mejorar la experiencia del usuario al mostrar información más detallada. -- Impacto Técnico: Se modifican varias partes del código para mejorar la estructura. - -📝 Sugerencias: -Commit: refactor: Mejoras en la presentación de sugerencias y configuración de Jira -📄 Archivos modificados: - - cmd/main.go - - internal/cli/command/config/set_jira_config.go -Explicación: Se mejoró la salida de sugerencias y el manejo de errores en la configuración de Jira. - -🎯 Análisis de Criterios de Aceptación: -⚠️ Estado de los Criterios: Cumplimiento Parcial -❌ Criterios Faltantes: - - Conexión a la API de Jira - - Extracción de Tickets -💡 Sugerencias de Mejora: - - Implementar manejo de errores para token expirado - - Agregar retry mechanism para API no disponible` + responseJSON = `[ + { + "title": "refactor: Mejoras en la presentación de sugerencias y configuración de Jira", + "desc": "Se mejoró la salida de sugerencias y el manejo de errores en la configuración de Jira.", + "files": [ + "cmd/main.go", + "internal/cli/command/config/set_jira_config.go" + ], + "analysis": { + "overview": "Mejora en el manejo de la configuración de Jira y la presentación de sugerencias de commit.", + "purpose": "Mejorar la experiencia del usuario al mostrar información más detallada.", + "impact": "Se modifican varias partes del código para mejorar la estructura." + }, + "requirements": { + "status": "partially_met", + "missing": [ + "Conexión a la API de Jira", + "Extracción de Tickets" + ], + "suggestions": [ + "Implementar manejo de errores para token expirado", + "Agregar retry mechanism para API no disponible" + ] + } + } +]` ) func TestGeminiService(t *testing.T) { @@ -125,7 +132,7 @@ func TestGeminiService(t *testing.T) { } }) - t.Run("ParseSuggestions correct format", func(t *testing.T) { + t.Run("ParseSuggestionsJSON correct format", func(t *testing.T) { // arrange ctx := context.Background() cfg := &config.Config{ @@ -142,7 +149,7 @@ func TestGeminiService(t *testing.T) { { Content: &genai.Content{ Parts: []*genai.Part{ - {Text: responseText}, + {Text: responseJSON}, }, }, }, @@ -150,9 +157,10 @@ func TestGeminiService(t *testing.T) { } // act - suggestions := service.parseSuggestions(resp) + suggestions, err := service.parseSuggestionsJSON(resp) // assert + assert.NoError(t, err) assert.Equal(t, 1, len(suggestions), "Se esperaba 1 sugerencia") if len(suggestions) > 0 { suggestion := suggestions[0] @@ -220,12 +228,11 @@ func TestGeminiService(t *testing.T) { // assert assert.Contains(t, prompt, "commit", "El prompt debería contener 'commit'") - assert.Contains(t, prompt, "Archivos modificados", "El prompt debería contener 'Archivos modificados'") - assert.Contains(t, prompt, "Explicación", "El prompt debería contener 'Explicación'") - assert.Contains(t, prompt, "🔍", "El prompt debería contener el emoji de análisis") - assert.Contains(t, prompt, "feat:", "El prompt debería contener tipos de commit") - assert.Contains(t, prompt, "fix:", "El prompt debería contener tipos de commit") - assert.Contains(t, prompt, "refactor:", "El prompt debería contener tipos de commit") + assert.Contains(t, prompt, "Archivos Modificados", "El prompt debería contener 'Archivos modificados'") + assert.Contains(t, prompt, "explicación", "El prompt debería contener 'Explicación'") + assert.Contains(t, prompt, "feat", "El prompt debería contener tipos de commit") + assert.Contains(t, prompt, "fix", "El prompt debería contener tipos de commit") + assert.Contains(t, prompt, "refactor", "El prompt debería contener tipos de commit") }) t.Run("generatePrompt with en locale", func(t *testing.T) { @@ -252,12 +259,11 @@ func TestGeminiService(t *testing.T) { // assert assert.Contains(t, prompt, "commit", "The prompt should contain 'commit'") - assert.Contains(t, prompt, "Modified files", "The prompt should contain 'Modified files'") - assert.Contains(t, prompt, "Explanation", "The prompt should contain 'Explanation'") - assert.Contains(t, prompt, "🔍", "The prompt should contain the analysis emoji") - assert.Contains(t, prompt, "feat:", "The prompt should contain commit types") - assert.Contains(t, prompt, "fix:", "The prompt should contain commit types") - assert.Contains(t, prompt, "refactor:", "The prompt should contain commit types") + assert.Contains(t, prompt, "Modified Files", "The prompt should contain 'Modified files'") + assert.Contains(t, prompt, "explanation", "The prompt should contain 'Explanation'") + assert.Contains(t, prompt, "feat", "The prompt should contain commit types") + assert.Contains(t, prompt, "fix", "The prompt should contain commit types") + assert.Contains(t, prompt, "refactor", "The prompt should contain commit types") }) t.Run("generatePrompt with en locale", func(t *testing.T) { @@ -286,30 +292,26 @@ func TestGeminiService(t *testing.T) { // assert assert.Contains(t, prompt, "Generate 3 commit message suggestions", "El prompt debe incluir la instrucción de generación") - assert.Contains(t, prompt, "Modified files:", "Debe incluir la sección de archivos modificados") - assert.Contains(t, prompt, "Diff:", "Debe incluir la sección de diff") - assert.Contains(t, prompt, "Technical Analysis:", "Debe incluir la sección de análisis técnico") - - if cfg.UseEmoji { - assert.Contains(t, prompt, "🔍", "El prompt debería contener el emoji de análisis si está activado") - } + assert.Contains(t, prompt, "Modified Files", "Debe incluir la sección de archivos modificados") + assert.Contains(t, prompt, "Code Changes", "Debe incluir la sección de diff") + assert.Contains(t, prompt, "technical analysis", "Debe incluir la sección de análisis técnico") }) - t.Run("parseSuggestions with nil response", func(t *testing.T) { + t.Run("parseSuggestionsJSON with nil response", func(t *testing.T) { // arrange service := &GeminiService{} resp := (*genai.GenerateContentResponse)(nil) // act - suggestions := service.parseSuggestions(resp) + suggestions, err := service.parseSuggestionsJSON(resp) // assert - if suggestions != nil { - t.Errorf("Expected nil, got: %v", suggestions) - } + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Contains(t, err.Error(), "respuesta vacía") }) - t.Run("parseSuggestions with empty candidates", func(t *testing.T) { + t.Run("parseSuggestionsJSON with empty candidates", func(t *testing.T) { // arrange service := &GeminiService{} resp := &genai.GenerateContentResponse{ @@ -317,13 +319,15 @@ func TestGeminiService(t *testing.T) { } // act - suggestions := service.parseSuggestions(resp) + suggestions, err := service.parseSuggestionsJSON(resp) + // assert - if suggestions != nil { - t.Errorf("Expected nil, got: %v", suggestions) - } + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Contains(t, err.Error(), "respuesta vacía") }) - t.Run("ParseSuggestions with rename format", func(t *testing.T) { + + t.Run("parseSuggestionsJSON with invalid JSON", func(t *testing.T) { // arrange ctx := context.Background() cfg := &config.Config{ @@ -332,32 +336,63 @@ func TestGeminiService(t *testing.T) { trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/") service, _ := NewGeminiService(ctx, cfg, trans) - responseTextWithRename := `📊 Análisis: -- Resumen: Renamed file. -- Propósito: Test rename. -- Impacto: Low. - -📝 Sugerencias: -Commit: refactor: Rename file -📄 Archivos modificados: - - old/path/file.go -> new/path/file.go -Explicación: Renaming file. -` - resp := &genai.GenerateContentResponse{ Candidates: []*genai.Candidate{ - {Content: &genai.Content{Parts: []*genai.Part{{Text: responseTextWithRename}}}}, + {Content: &genai.Content{Parts: []*genai.Part{{Text: "invalid json"}}}}, }, } // act - suggestions := service.parseSuggestions(resp) + suggestions, err := service.parseSuggestionsJSON(resp) // assert - assert.Equal(t, 1, len(suggestions)) - if len(suggestions) > 0 { - assert.Contains(t, suggestions[0].Files, "new/path/file.go") - assert.NotContains(t, suggestions[0].Files, "old/path/file.go -> new/path/file.go") + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Contains(t, err.Error(), "parsear JSON") + }) + + t.Run("parseSuggestionsJSON status passthrough", func(t *testing.T) { + // arrange + ctx := context.Background() + cfg := &config.Config{GeminiAPIKey: "test-api-key"} + trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/") + service, _ := NewGeminiService(ctx, cfg, trans) + + testCases := []struct { + inputStatus string + expectedStatus models.CriteriaStatus + }{ + {"full_met", models.CriteriaFullyMet}, + {"partially_met", models.CriteriaPartiallyMet}, + {"not_met", models.CriteriaNotMet}, + {"unknown_status", models.CriteriaStatus("unknown_status")}, + } + + for _, tc := range testCases { + jsonStr := fmt.Sprintf(`[{ + "title": "test", + "desc": "test", + "files": ["test.go"], + "requirements": { + "status": "%s", + "missing": [], + "suggestions": [] + } + }]`, tc.inputStatus) + + resp := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{Parts: []*genai.Part{{Text: jsonStr}}}}, + }, + } + + // act + suggestions, err := service.parseSuggestionsJSON(resp) + + // assert + assert.NoError(t, err) + assert.NotEmpty(t, suggestions) + assert.Equal(t, tc.expectedStatus, suggestions[0].RequirementsAnalysis.CriteriaStatus, "Fallo passthrough para: %s", tc.inputStatus) } }) } diff --git a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go index 443623e..e2fa73b 100644 --- a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go @@ -2,32 +2,30 @@ package gemini import ( "context" + "encoding/json" "fmt" - "regexp" "strings" - "unicode/utf8" "github.com/Tomas-vilte/MateCommit/internal/config" "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" "github.com/Tomas-vilte/MateCommit/internal/i18n" "github.com/Tomas-vilte/MateCommit/internal/infrastructure/ai" "google.golang.org/genai" ) +var _ ports.PRSummarizer = (*GeminiPRSummarizer)(nil) + type GeminiPRSummarizer struct { client *genai.Client config *config.Config trans *i18n.Translations } -var validLabels = map[string]bool{ - "feature": true, - "fix": true, - "refactor": true, - "docs": true, - "infra": true, - "test": true, - "performance": true, +type PRSummaryJSON struct { + Title string `json:"title"` + Body string `json:"body"` + Labels []string `json:"labels"` } func NewGeminiPRSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*GeminiPRSummarizer, error) { @@ -55,117 +53,43 @@ func NewGeminiPRSummarizer(ctx context.Context, cfg *config.Config, trans *i18n. } func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prompt string) (models.PRSummary, error) { - if prompt == "" { - msg := gps.trans.GetMessage("gemini_service.error_empty_prompt", 0, nil) - return models.PRSummary{}, fmt.Errorf("%s", msg) - } - - formattedPrompt := gps.generatePRPrompt(prompt) modelName := string(gps.config.AIConfig.Models[config.AIGemini]) - resp, err := gps.client.Models.GenerateContent(ctx, modelName, genai.Text(formattedPrompt), nil) - if err != nil { - return models.PRSummary{}, err - } - - rawSummary := formatResponse(resp) - if rawSummary == "" { - msg := gps.trans.GetMessage("gemini_service.response_empty", 0, nil) - return models.PRSummary{}, fmt.Errorf("%s", msg) + genConfig := &genai.GenerateContentConfig{ + Temperature: float32Ptr(0.3), + MaxOutputTokens: int32(10000), + ResponseMIMEType: "application/json", } - return gps.parseSummary(rawSummary) -} - -func (gps *GeminiPRSummarizer) generatePRPrompt(prContent string) string { - template := ai.GetPRPromptTemplate(gps.config.Language) - return fmt.Sprintf(template, prContent) -} - -func (gps *GeminiPRSummarizer) cleanLabel(label string) string { - cleaned := strings.TrimSpace(label) - - cleaned = strings.Trim(cleaned, `"'`) - - cleaned = strings.Trim(cleaned, "`") - - cleaned = strings.Trim(cleaned, "*_-~") - - cleaned = strings.ToLower(cleaned) - - reg := regexp.MustCompile(`[^a-z0-9\-_]`) - cleaned = reg.ReplaceAllString(cleaned, "") - - return cleaned -} - -func (gps *GeminiPRSummarizer) isValidLabel(label string) bool { - return validLabels[label] -} - -func (gps *GeminiPRSummarizer) parseSummary(raw string) (models.PRSummary, error) { - summary := models.PRSummary{} - raw = strings.ReplaceAll(raw, "## ", "##") - sections := strings.Split(raw, "##") - titleKey := gps.trans.GetMessage("gemini_service.pr_title_section", 0, nil) - labelsKey := gps.trans.GetMessage("gemini_service.pr_labels_section", 0, nil) - changesKey := gps.trans.GetMessage("gemini_service.pr_changes_section", 0, nil) - - for _, sec := range sections { - if strings.HasPrefix(sec, titleKey) { - lines := strings.SplitN(sec, "\n", 2) - if len(lines) > 1 { - summary.Title = strings.TrimSpace(lines[1]) - break - } - } + resp, err := gps.client.Models.GenerateContent(ctx, modelName, genai.Text(prompt), genConfig) + if err != nil { + return models.PRSummary{}, fmt.Errorf("error al generar resumen de PR: %w", err) } - for _, sec := range sections { - if strings.HasPrefix(sec, labelsKey) { - lines := strings.SplitN(sec, "\n", 2) - if len(lines) > 1 { - labelText := lines[1] - - var labelParts []string - if strings.Contains(labelText, ",") { - labelParts = strings.Split(labelText, ",") - } else { - labelText = strings.ReplaceAll(labelText, "\n", " ") - labelParts = strings.Fields(labelText) - } - - for _, l := range labelParts { - cleaned := gps.cleanLabel(l) - - if cleaned != "" && gps.isValidLabel(cleaned) { - summary.Labels = append(summary.Labels, cleaned) - } - } - break - } - } + responseText := formatResponse(resp) + if responseText == "" { + return models.PRSummary{}, fmt.Errorf("respuesta vacía de la IA") } - var bodyParts []string - for _, sec := range sections { - if strings.HasPrefix(sec, changesKey) { - lines := strings.SplitN(sec, "\n", 2) - if len(lines) > 1 { - bodyParts = append(bodyParts, strings.TrimSpace(lines[1])) - } - } - } - summary.Body = strings.Join(bodyParts, "\n\n") + responseText = strings.TrimSpace(responseText) + responseText = strings.TrimPrefix(responseText, "```json") + responseText = strings.TrimPrefix(responseText, "```") + responseText = strings.TrimSuffix(responseText, "```") + responseText = strings.TrimSpace(responseText) - if summary.Title == "" { - msg := gps.trans.GetMessage("gemini_service.title_not_found", 0, nil) - return summary, fmt.Errorf("%s", msg) + var jsonSummary PRSummaryJSON + if err := json.Unmarshal([]byte(responseText), &jsonSummary); err != nil { + return models.PRSummary{}, fmt.Errorf("error al parsear JSON de PR: %w", err) } - if utf8.RuneCountInString(summary.Title) > 80 { - summary.Title = string([]rune(summary.Title)[:77]) + "..." - } + return models.PRSummary{ + Title: jsonSummary.Title, + Body: jsonSummary.Body, + Labels: jsonSummary.Labels, + }, nil +} - return summary, nil +func (gps *GeminiPRSummarizer) generatePRPrompt(prContent string) string { + template := ai.GetPRPromptTemplate(gps.config.Language) + return fmt.Sprintf(template, prContent) } diff --git a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go index e877436..bdf409e 100644 --- a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go +++ b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go @@ -12,17 +12,11 @@ import ( ) const ( - prResponseText = `## PR Title -Fix image loading error in gallery component - -## Suggested Tags -fix,performance - -## Key Changes -- Fixed memory leak in image loading process -- Optimized cache usage to improve performance -- Added error handling for network failures -` + prResponseJSON = `{ + "title": "Fix image loading error in gallery component", + "body": "- Fixed memory leak in image loading process\n- Optimized cache usage to improve performance\n- Added error handling for network failures", + "labels": ["fix", "performance"] +}` ) func TestGeminiPRSummarizer(t *testing.T) { @@ -70,59 +64,6 @@ func TestGeminiPRSummarizer(t *testing.T) { assert.Error(t, err, "Debería retornar un error con prompt vacío") }) - t.Run("parseSummary correct format", func(t *testing.T) { - // Arrange - ctx := context.Background() - cfg := &config.Config{ - GeminiAPIKey: "test-api-key", - Language: "en", - } - - trans, err := i18n.NewTranslations("en", "../../../i18n/locales/") - assert.NoError(t, err) - - summarizer, err := NewGeminiPRSummarizer(ctx, cfg, trans) - assert.NoError(t, err) - - // Act - summary, err := summarizer.parseSummary(prResponseText) - - // Assert - assert.NoError(t, err) - assert.Equal(t, "Fix image loading error in gallery component", summary.Title) - assert.ElementsMatch(t, []string{"fix", "performance"}, summary.Labels) - assert.Contains(t, summary.Body, "Fixed memory leak in image loading process") - assert.Contains(t, summary.Body, "Optimized cache usage to improve performance") - assert.Contains(t, summary.Body, "Added error handling for network failures") - }) - - t.Run("parseSummary with missing title", func(t *testing.T) { - // Arrange - ctx := context.Background() - cfg := &config.Config{ - GeminiAPIKey: "test-api-key", - Language: "en", - } - - trans, err := i18n.NewTranslations("en", "../../../i18n/locales/") - assert.NoError(t, err) - - summarizer, err := NewGeminiPRSummarizer(ctx, cfg, trans) - assert.NoError(t, err) - - missingTitleText := `## Suggested Tags - fix,performance - - ## Key Changes - - Some change` - - // Act - summary, err := summarizer.parseSummary(missingTitleText) - - // Assert - assert.Error(t, err, "Debería retornar un error con título faltante") - assert.Equal(t, "", summary.Title) - }) t.Run("generatePRPrompt should format correctly", func(t *testing.T) { // Arrange @@ -145,9 +86,9 @@ func TestGeminiPRSummarizer(t *testing.T) { // Assert assert.Contains(t, prompt, "Some PR content to summarize", "El prompt debe contener el contenido del PR") - assert.Contains(t, prompt, "PR Title", "El prompt debe solicitar un título para el PR") - assert.Contains(t, prompt, "Key Changes", "El prompt debe solicitar cambios clave") - assert.Contains(t, prompt, "Suggested Tags", "El prompt debe solicitar etiquetas sugeridas") + assert.Contains(t, prompt, "concise title", "El prompt debe solicitar un título para el PR") + assert.Contains(t, prompt, "key changes", "El prompt debe solicitar cambios clave") + assert.Contains(t, prompt, "Suggest relevant labels", "El prompt debe solicitar etiquetas sugeridas") }) t.Run("formatResponse", func(t *testing.T) { diff --git a/internal/infrastructure/ai/gemini/release_generator.go b/internal/infrastructure/ai/gemini/release_generator.go index de458d7..467bcf4 100644 --- a/internal/infrastructure/ai/gemini/release_generator.go +++ b/internal/infrastructure/ai/gemini/release_generator.go @@ -2,6 +2,7 @@ package gemini import ( "context" + "encoding/json" "fmt" "strings" @@ -24,6 +25,14 @@ type ReleaseNotesGenerator struct { repo string } +type ReleaseNotesJSON struct { + Title string `json:"title"` + Summary string `json:"summary"` + Highlights []string `json:"highlights"` + BreakingChanges []string `json:"breaking_changes"` + Contributors string `json:"contributors"` +} + func NewReleaseNotesGenerator(ctx context.Context, cfg *config.Config, trans *i18n.Translations, owner, repo string) (*ReleaseNotesGenerator, error) { if cfg.GeminiAPIKey == "" { msg := trans.GetMessage("error_missing_api_key", 0, nil) @@ -55,7 +64,13 @@ func NewReleaseNotesGenerator(ctx context.Context, cfg *config.Config, trans *i1 func (g *ReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) { prompt := g.buildPrompt(release) - resp, err := g.client.Models.GenerateContent(ctx, g.model, genai.Text(prompt), nil) + genConfig := &genai.GenerateContentConfig{ + Temperature: float32Ptr(0.3), + MaxOutputTokens: int32(10000), + ResponseMIMEType: "application/json", + } + + resp, err := g.client.Models.GenerateContent(ctx, g.model, genai.Text(prompt), genConfig) if err != nil { msg := g.trans.GetMessage("error_generating_release_notes", 0, map[string]interface{}{ "Error": err, @@ -72,9 +87,13 @@ func (g *ReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *mode for _, part := range resp.Candidates[0].Content.Parts { content += part.Text } - parseResponse, _ := g.parseResponse(content, release) - return parseResponse, nil + notes, err := g.parseJSONResponse(content, release) + if err != nil { + return nil, fmt.Errorf("error al parsear respuesta JSON de release notes: %w", err) + } + + return notes, nil } func (g *ReleaseNotesGenerator) buildPrompt(release *models.Release) string { @@ -95,8 +114,10 @@ func (g *ReleaseNotesGenerator) buildPrompt(release *models.Release) string { func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) string { var sb strings.Builder + headers := ai.GetReleaseNotesSectionHeaders(g.lang) + if len(release.Breaking) > 0 { - sb.WriteString("BREAKING CHANGES:\n") + sb.WriteString(fmt.Sprintf("%s\n", headers["breaking"])) for _, item := range release.Breaking { sb.WriteString(fmt.Sprintf("- %s: %s\n", item.Type, item.Description)) } @@ -104,7 +125,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } if len(release.Features) > 0 { - sb.WriteString("NEW FEATURES:\n") + sb.WriteString(fmt.Sprintf("%s\n", headers["features"])) for _, item := range release.Features { sb.WriteString(fmt.Sprintf("- %s: %s\n", item.Type, item.Description)) } @@ -112,7 +133,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } if len(release.BugFixes) > 0 { - sb.WriteString("BUG FIXES:\n") + sb.WriteString(fmt.Sprintf("%s\n", headers["fixes"])) for _, item := range release.BugFixes { sb.WriteString(fmt.Sprintf("- %s: %s\n", item.Type, item.Description)) } @@ -120,7 +141,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } if len(release.Improvements) > 0 { - sb.WriteString("IMPROVEMENTS:\n") + sb.WriteString(fmt.Sprintf("%s\n", headers["improvements"])) for _, item := range release.Improvements { sb.WriteString(fmt.Sprintf("- %s: %s\n", item.Type, item.Description)) } @@ -128,7 +149,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } if len(release.ClosedIssues) > 0 { - sb.WriteString("CLOSED ISSUES:\n") + sb.WriteString(fmt.Sprintf("%s\n", headers["closed_issues"])) for _, issue := range release.ClosedIssues { sb.WriteString(fmt.Sprintf("- #%d: %s (by @%s)\n", issue.Number, issue.Title, issue.Author)) } @@ -136,7 +157,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } if len(release.MergedPRs) > 0 { - sb.WriteString("MERGED PULL REQUESTS:\n") + sb.WriteString(fmt.Sprintf("%s\n", headers["merged_prs"])) for _, pr := range release.MergedPRs { sb.WriteString(fmt.Sprintf("- #%d: %s (by @%s)\n", pr.Number, pr.Title, pr.Author)) if pr.Description != "" { @@ -150,7 +171,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } if len(release.Contributors) > 0 { - sb.WriteString(fmt.Sprintf("CONTRIBUTORS (%d total):\n", len(release.Contributors))) + sb.WriteString(fmt.Sprintf("%s (%d total):\n", headers["contributors"], len(release.Contributors))) for _, contributor := range release.Contributors { sb.WriteString(fmt.Sprintf("- @%s\n", contributor)) } @@ -161,7 +182,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } if release.FileStats.FilesChanged > 0 { - sb.WriteString("FILE STATISTICS:\n") + sb.WriteString(fmt.Sprintf("%s\n", headers["file_stats"])) sb.WriteString(fmt.Sprintf("- Files changed: %d\n", release.FileStats.FilesChanged)) sb.WriteString(fmt.Sprintf("- Insertions: +%d\n", release.FileStats.Insertions)) sb.WriteString(fmt.Sprintf("- Deletions: -%d\n", release.FileStats.Deletions)) @@ -175,7 +196,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } if len(release.Dependencies) > 0 { - sb.WriteString("DEPENDENCY UPDATES:\n") + sb.WriteString(fmt.Sprintf("%s\n", headers["deps"])) for _, dep := range release.Dependencies { switch dep.Type { case "updated": @@ -192,147 +213,29 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) return sb.String() } -func (g *ReleaseNotesGenerator) parseResponse(content string, release *models.Release) (*models.ReleaseNotes, error) { - lines := strings.Split(content, "\n") - - notes := &models.ReleaseNotes{ - Recommended: release.VersionBump, - Links: make(map[string]string), - } - - var ( - inHighlights bool - inSummary bool - inQuickStart bool - inBreakingChanges bool - inExample bool - inComparison bool - inLinks bool - currentExample *models.CodeExample - currentComparison *models.Comparison - quickStartLines []string - summaryLines []string - ) - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - if !inExample && (strings.HasPrefix(trimmed, "TITLE:") || strings.HasPrefix(trimmed, "TÍTULO:")) { - notes.Title = strings.TrimSpace(strings.TrimPrefix(trimmed, "TITLE:")) - notes.Title = strings.TrimSpace(strings.TrimPrefix(notes.Title, "TÍTULO:")) - inHighlights, inSummary, inQuickStart, inBreakingChanges, inExample, inComparison, inLinks = false, false, false, false, false, false, false - } else if strings.HasPrefix(trimmed, "SUMMARY:") || strings.HasPrefix(trimmed, "RESUMEN:") { - inSummary = true - inHighlights, inQuickStart, inBreakingChanges, inExample, inComparison, inLinks = false, false, false, false, false, false - content := strings.TrimSpace(strings.TrimPrefix(trimmed, "SUMMARY:")) - content = strings.TrimSpace(strings.TrimPrefix(content, "RESUMEN:")) - if content != "" { - summaryLines = append(summaryLines, content) - } - } else if strings.HasPrefix(trimmed, "HIGHLIGHTS:") { - inHighlights = true - inSummary, inQuickStart, inBreakingChanges, inExample, inComparison, inLinks = false, false, false, false, false, false - } else if strings.HasPrefix(trimmed, "QUICK_START:") { - inQuickStart = true - inHighlights, inSummary, inBreakingChanges, inExample, inComparison, inLinks = false, false, false, false, false, false - } else if strings.HasPrefix(trimmed, "EXAMPLES:") { - inExample = true - inHighlights, inSummary, inQuickStart, inBreakingChanges, inComparison, inLinks = false, false, false, false, false, false - } else if strings.HasPrefix(trimmed, "BREAKING_CHANGES:") { - inBreakingChanges = true - inHighlights, inSummary, inQuickStart, inExample, inComparison, inLinks = false, false, false, false, false, false - } else if strings.HasPrefix(trimmed, "COMPARISONS:") { - inComparison = true - inHighlights, inSummary, inQuickStart, inBreakingChanges, inExample, inLinks = false, false, false, false, false, false - } else if strings.HasPrefix(trimmed, "LINKS:") { - inLinks = true - inHighlights, inSummary, inQuickStart, inBreakingChanges, inExample, inComparison = false, false, false, false, false, false - } else if inSummary && trimmed != "" { - summaryLines = append(summaryLines, trimmed) - } else if inHighlights && strings.HasPrefix(trimmed, "-") { - highlight := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) - if highlight != "" { - notes.Highlights = append(notes.Highlights, highlight) - } - } else if inQuickStart && trimmed != "" { - quickStartLines = append(quickStartLines, line) - } else if inBreakingChanges && strings.HasPrefix(trimmed, "-") { - bc := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) - if bc != "" && !strings.EqualFold(bc, "ninguno") && !strings.EqualFold(bc, "none") { - notes.BreakingChanges = append(notes.BreakingChanges, bc) - } - } else if inExample { - if strings.HasPrefix(trimmed, "EXAMPLE_") { - if currentExample != nil { - notes.Examples = append(notes.Examples, *currentExample) - } - currentExample = &models.CodeExample{} - } else if currentExample != nil { - if strings.HasPrefix(trimmed, "TITLE:") { - currentExample.Title = strings.TrimSpace(strings.TrimPrefix(trimmed, "TITLE:")) - } else if strings.HasPrefix(trimmed, "DESCRIPTION:") { - currentExample.Description = strings.TrimSpace(strings.TrimPrefix(trimmed, "DESCRIPTION:")) - } else if strings.HasPrefix(trimmed, "LANGUAGE:") { - currentExample.Language = strings.TrimSpace(strings.TrimPrefix(trimmed, "LANGUAGE:")) - } else if strings.HasPrefix(trimmed, "CODE:") { - codeContent := strings.TrimSpace(strings.TrimPrefix(trimmed, "CODE:")) - if strings.HasPrefix(codeContent, "```") { - codeContent = "" - } - currentExample.Code = codeContent - } else if trimmed != "" && !strings.HasPrefix(trimmed, "EXAMPLE_") && !strings.HasPrefix(trimmed, "BREAKING_") && !strings.HasPrefix(trimmed, "COMPARISONS:") && !strings.HasPrefix(trimmed, "LINKS:") { - if trimmed == "```" || strings.HasPrefix(trimmed, "```") { - } else if currentExample.Code == "" { - currentExample.Code = line - } else { - currentExample.Code += "\n" + line - } - } - } - } else if inComparison { - if strings.HasPrefix(trimmed, "COMPARISON_") { - if currentComparison != nil { - notes.Comparisons = append(notes.Comparisons, *currentComparison) - } - currentComparison = &models.Comparison{} - } else if currentComparison != nil { - if strings.HasPrefix(trimmed, "FEATURE:") { - currentComparison.Feature = strings.TrimSpace(strings.TrimPrefix(trimmed, "FEATURE:")) - } else if strings.HasPrefix(trimmed, "BEFORE:") { - currentComparison.Before = strings.TrimSpace(strings.TrimPrefix(trimmed, "BEFORE:")) - } else if strings.HasPrefix(trimmed, "AFTER:") { - currentComparison.After = strings.TrimSpace(strings.TrimPrefix(trimmed, "AFTER:")) - } - } - } else if inLinks { - parts := strings.SplitN(trimmed, ":", 2) - if len(parts) == 2 { - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - if key != "" && value != "" { - notes.Links[key] = value - } - } - } - } - - if currentExample != nil { - notes.Examples = append(notes.Examples, *currentExample) - } - if currentComparison != nil { - notes.Comparisons = append(notes.Comparisons, *currentComparison) - } +func (g *ReleaseNotesGenerator) parseJSONResponse(content string, release *models.Release) (*models.ReleaseNotes, error) { + content = strings.TrimSpace(content) + content = strings.TrimPrefix(content, "```json") + content = strings.TrimPrefix(content, "```") + content = strings.TrimSuffix(content, "```") + content = strings.TrimSpace(content) - if len(quickStartLines) > 0 { - notes.QuickStart = strings.Join(quickStartLines, "\n") + var jsonNotes ReleaseNotesJSON + if err := json.Unmarshal([]byte(content), &jsonNotes); err != nil { + return nil, fmt.Errorf("error al parsear JSON de release notes: %w", err) } - if len(summaryLines) > 0 { - notes.Summary = strings.Join(summaryLines, " ") + notes := &models.ReleaseNotes{ + Title: jsonNotes.Title, + Summary: jsonNotes.Summary, + Highlights: jsonNotes.Highlights, + BreakingChanges: jsonNotes.BreakingChanges, + Recommended: release.VersionBump, + Links: make(map[string]string), } - if notes.Title == "" { - notes.Title = fmt.Sprintf("Version %s", release.Version) + if jsonNotes.Contributors != "" && jsonNotes.Contributors != "N/A" { + notes.Links["Contributors"] = jsonNotes.Contributors } return notes, nil diff --git a/internal/infrastructure/ai/gemini/release_generator_examples_test.go b/internal/infrastructure/ai/gemini/release_generator_examples_test.go deleted file mode 100644 index cdb5546..0000000 --- a/internal/infrastructure/ai/gemini/release_generator_examples_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package gemini - -import ( - "testing" - - "github.com/Tomas-vilte/MateCommit/internal/domain/models" - "github.com/stretchr/testify/assert" -) - -func TestParseResponse_MultilineCodeExamples(t *testing.T) { - generator := &ReleaseNotesGenerator{} - release := &models.Release{Version: "v1.0.0", VersionBump: "minor"} - - t.Run("parses multiline code examples with backticks", func(t *testing.T) { - // Arrange - Simula el formato que genera la IA - content := `TITLE: Release v1.0.0 -SUMMARY: Test release with examples -HIGHLIGHTS: -- Feature 1 - -EXAMPLES: -EXAMPLE_1: -TITLE: Basic usage -DESCRIPTION: How to use the command -LANGUAGE: bash -CODE: -` + "```bash\nmatecommit\n```" + ` - -EXAMPLE_2: -TITLE: With flags -DESCRIPTION: Using flags -LANGUAGE: bash -CODE: -` + "```bash\nmatecommit --help\n```" + ` - -BREAKING_CHANGES: -- None` - - // Act - notes, err := generator.parseResponse(content, release) - - // Assert - assert.NoError(t, err) - assert.Len(t, notes.Examples, 2) - - // Verificar primer ejemplo - assert.Equal(t, "Basic usage", notes.Examples[0].Title) - assert.Equal(t, "How to use the command", notes.Examples[0].Description) - assert.Equal(t, "bash", notes.Examples[0].Language) - assert.Contains(t, notes.Examples[0].Code, "matecommit") - assert.NotContains(t, notes.Examples[0].Code, "```", "Code should not contain backticks") - - // Verificar segundo ejemplo - assert.Equal(t, "With flags", notes.Examples[1].Title) - assert.Equal(t, "Using flags", notes.Examples[1].Description) - assert.Equal(t, "bash", notes.Examples[1].Language) - assert.Contains(t, notes.Examples[1].Code, "matecommit --help") - assert.NotContains(t, notes.Examples[1].Code, "```", "Code should not contain backticks") - }) -} diff --git a/internal/infrastructure/ai/gemini/release_generator_parse_test.go b/internal/infrastructure/ai/gemini/release_generator_parse_test.go deleted file mode 100644 index 9163f65..0000000 --- a/internal/infrastructure/ai/gemini/release_generator_parse_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package gemini - -import ( - "testing" - - "github.com/Tomas-vilte/MateCommit/internal/domain/models" - "github.com/stretchr/testify/assert" -) - -func TestParseResponse_MultiLineSummary(t *testing.T) { - response := `TÍTULO: v1.3.0: Gestión de Releases, Gemini 2.5 y Configuración Intuitiva - -RESUMEN: En esta versión v1.3.0, me enfoqué en llevar la gestión de releases a un nuevo nivel, implementando comandos que automatizan gran parte del proceso. Además, potencié nuestras capacidades de IA actualizando a los modelos Gemini 2.5 y mejoré significativamente la configuración, haciéndola más accesible y robusta para todos ustedes. - -HIGHLIGHTS: -- ¡Implementé un nuevo set de comandos para la **gestión automatizada de releases**! -- Actualicé nuestros modelos de IA a la potente versión **Gemini 2.5** -- Introduje el asistente de configuración config init - -QUICK_START: -Para instalar o actualizar a la v1.3.0: -` + "```bash\ngo install example.com/cli-tool@latest\n```" + ` - -BREAKING_CHANGES: -- Ninguno - -LINKS: -N/A` - - release := &models.Release{Version: "v1.3.0"} - gen := &ReleaseNotesGenerator{} - - notes, err := gen.parseResponse(response, release) - - assert.NoError(t, err) - assert.NotNil(t, notes) - - assert.Equal(t, "v1.3.0: Gestión de Releases, Gemini 2.5 y Configuración Intuitiva", notes.Title) - - assert.Contains(t, notes.Summary, "En esta versión v1.3.0") - assert.Contains(t, notes.Summary, "implementando comandos") - assert.Contains(t, notes.Summary, "Gemini 2.5") - assert.NotEmpty(t, notes.Summary) - - assert.Len(t, notes.Highlights, 3) - assert.Contains(t, notes.Highlights[0], "gestión automatizada de releases") - - assert.NotEmpty(t, notes.QuickStart) - assert.Contains(t, notes.QuickStart, "go install") - - assert.Empty(t, notes.BreakingChanges) -} diff --git a/internal/infrastructure/ai/gemini/release_generator_test.go b/internal/infrastructure/ai/gemini/release_generator_test.go index 1b6078c..9217b53 100644 --- a/internal/infrastructure/ai/gemini/release_generator_test.go +++ b/internal/infrastructure/ai/gemini/release_generator_test.go @@ -76,7 +76,6 @@ func TestBuildPrompt(t *testing.T) { assert.Contains(t, prompt, "New version: v1.0.0") assert.Contains(t, prompt, "Bump type: major") - // Check for sections assert.Contains(t, prompt, "BREAKING CHANGES:") assert.Contains(t, prompt, "- breaking: Breaking change") assert.Contains(t, prompt, "NEW FEATURES:") @@ -118,20 +117,22 @@ func TestBuildPrompt(t *testing.T) { }) } -func TestParseResponse(t *testing.T) { +func TestParseJSONResponse(t *testing.T) { generator := &ReleaseNotesGenerator{} release := &models.Release{Version: "v1.0.0", VersionBump: "minor"} - t.Run("parses English response correctly", func(t *testing.T) { + t.Run("parses JSON response correctly", func(t *testing.T) { // Arrange - content := `TITLE: Release v1.0.0 -SUMMARY: This is a summary. -HIGHLIGHTS: -- Highlight 1 -- Highlight 2` + content := `{ + "title": "Release v1.0.0", + "summary": "This is a summary.", + "highlights": ["Highlight 1", "Highlight 2"], + "breaking_changes": [], + "contributors": "" + }` // Act - notes, err := generator.parseResponse(content, release) + notes, err := generator.parseJSONResponse(content, release) // Assert assert.NoError(t, err) @@ -141,53 +142,57 @@ HIGHLIGHTS: assert.Equal(t, models.VersionBump("minor"), notes.Recommended) }) - t.Run("parses Spanish response correctly", func(t *testing.T) { + t.Run("parses JSON with breaking changes", func(t *testing.T) { // Arrange - content := `TÍTULO: Lanzamiento v1.0.0 -RESUMEN: Este es un resumen. -HIGHLIGHTS: -- Destacado 1 -- Destacado 2` + content := `{ + "title": "Release v2.0.0", + "summary": "Major release with breaking changes.", + "highlights": ["New API", "Better performance"], + "breaking_changes": ["Removed old API", "Changed config format"], + "contributors": "https://github.com/test/repo/graphs/contributors" + }` // Act - notes, err := generator.parseResponse(content, release) + notes, err := generator.parseJSONResponse(content, release) // Assert assert.NoError(t, err) - assert.Equal(t, "Lanzamiento v1.0.0", notes.Title) - assert.Equal(t, "Este es un resumen.", notes.Summary) - assert.Equal(t, []string{"Destacado 1", "Destacado 2"}, notes.Highlights) + assert.Equal(t, "Release v2.0.0", notes.Title) + assert.Equal(t, "Major release with breaking changes.", notes.Summary) + assert.Equal(t, []string{"New API", "Better performance"}, notes.Highlights) + assert.Equal(t, []string{"Removed old API", "Changed config format"}, notes.BreakingChanges) + assert.Equal(t, "https://github.com/test/repo/graphs/contributors", notes.Links["Contributors"]) }) - t.Run("handles missing title by using default", func(t *testing.T) { + t.Run("handles invalid JSON", func(t *testing.T) { // Arrange - content := `SUMMARY: Just a summary.` + content := `invalid json` // Act - notes, err := generator.parseResponse(content, release) + notes, err := generator.parseJSONResponse(content, release) // Assert - assert.NoError(t, err) - assert.Equal(t, "Version v1.0.0", notes.Title) // Default behavior - assert.Equal(t, "Just a summary.", notes.Summary) + assert.Error(t, err) + assert.Nil(t, notes) + assert.Contains(t, err.Error(), "parsear JSON") }) - t.Run("handles extra whitespace", func(t *testing.T) { + t.Run("handles JSON with code fences", func(t *testing.T) { // Arrange - content := ` -TITLE: Release v1.0.0 -SUMMARY: Summary with spaces. -HIGHLIGHTS: -- Highlight 1 -` + content := "```json\n" + `{ + "title": "Test", + "summary": "Summary", + "highlights": [], + "breaking_changes": [], + "contributors": "" + }` + "\n```" // Act - notes, err := generator.parseResponse(content, release) + notes, err := generator.parseJSONResponse(content, release) // Assert assert.NoError(t, err) - assert.Equal(t, "Release v1.0.0", notes.Title) - assert.Equal(t, "Summary with spaces.", notes.Summary) - assert.Equal(t, []string{"Highlight 1"}, notes.Highlights) + assert.Equal(t, "Test", notes.Title) + assert.Equal(t, "Summary", notes.Summary) }) } diff --git a/internal/infrastructure/ai/prompts.go b/internal/infrastructure/ai/prompts.go index 559ab3f..2e75cbd 100644 --- a/internal/infrastructure/ai/prompts.go +++ b/internal/infrastructure/ai/prompts.go @@ -30,41 +30,49 @@ const ( // Templates para Pull Requests const ( - prPromptTemplateEN = `Hey, could you whip up a summary for this PR with: - - ## PR Title - A short title (max 80 chars). Example: "fix: Image loading error" - - ## Key Changes - - The 3 main changes - - Purpose of each one - - Technical impact if applicable - - ## Suggested Tags - Comma-separated. Options: feature, fix, refactor, docs, infra, test. Example: fix,infra - - PR Content: - %s - - Thanks a bunch, you rock!` - - prPromptTemplateES = `Che, armame un resumen de este PR con: - - ## Título del PR - Un título corto (máx 40 caracteres). Ej: "fix: Error al cargar imágenes" - - ## Cambios clave - - Los 3 cambios principales - - El propósito de cada uno - - Impacto técnico si aplica - - ## Etiquetas sugeridas - Separadas por coma. Opciones: feature, fix, refactor, docs, infra, test. Ej: fix,infra - - Contenido del PR: - %s - - ¡Gracias máquina!` + prPromptTemplateEN = `# Task + Generate a comprehensive Pull Request summary. + + # PR Content + %s + + # Instructions + 1. Create concise title (max 80 chars) + 2. Identify 3-5 key changes with purpose and impact + 3. Suggest relevant labels from: feature, fix, refactor, docs, infra, test, breaking-change + + # Output Format + Respond with ONLY valid JSON: + { + "title": "PR title here", + "body": "Detailed markdown body with:\n- Overview\n- Key changes\n- Technical impact", + "labels": ["label1", "label2"] + } + + Generate the summary now.` + + prPromptTemplateES = `# Tarea + Genera un resumen completo del Pull Request. + + # Contenido del PR + %s + + # Instrucciones + 1. Crea un título conciso (máx 80 caracteres) + 2. Identifica 3-5 cambios clave con propósito e impacto + 3. Sugiere etiquetas relevantes de: feature, fix, refactor, docs, infra, test, breaking-change + + # Formato de Salida + IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. + + Responde SOLO con JSON válido: + { + "title": "título del PR", + "body": "cuerpo detallado en markdown con:\n- Resumen\n- Cambios clave\n- Impacto técnico", + "labels": ["etiqueta1", "etiqueta2"] + } + + Genera el resumen ahora.` ) // Templates para Commits con ticket @@ -102,7 +110,7 @@ const ( "impact": "technical impact" }, "requirements": { - "status": "Fully Met | Partially Met | Not Met", + "status": "full_met | partially_met | not_met", "missing": ["criterion 1", "criterion 2"], "suggestions": ["improvement 1", "improvement 2"] } @@ -133,6 +141,9 @@ const ( 4. Incluye referencia al issue si se proporciona # Formato de Salida + IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. + EXCEPTO el campo "status" que debe ser uno de los valores permitidos exactos. + Responde SOLO con un array JSON válido. Cada sugerencia debe tener: { "title": "mensaje del commit", @@ -144,7 +155,7 @@ const ( "impact": "impacto técnico" }, "requirements": { - "status": "Completamente Cumplido | Parcialmente Cumplido | No Cumplido", + "status": "full_met | partially_met | not_met", "missing": ["criterio 1", "criterio 2"], "suggestions": ["mejora 1", "mejora 2"] } @@ -175,6 +186,8 @@ const ( 5. Incluye referencia al issue si se proporciona # Formato de Salida + IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. + Responde SOLO con un array JSON válido. Cada sugerencia debe tener: { "title": "mensaje del commit", @@ -228,185 +241,71 @@ const ( Generate %d suggestions now.` ) -// Templates para Releases +// Templates para Releases - MARKDOWN + JSON const ( - releasePromptTemplateES = ` - Sos un desarrollador escribiendo las release notes de tu proyecto en primera persona. - Usá un tono técnico pero cercano, explicando qué hiciste en esta versión. - - Repositorio: %s/%s - Versión anterior: %s - Nueva versión: %s - Tipo de bump: %s - - Cambios en este release: - - %s - - IMPORTANTE - CONTEXTO ADICIONAL: - El listado anterior incluye no solo commits, sino también: - - Issues cerrados: Problemas reportados por usuarios que fueron resueltos - - Pull Requests mergeados: Contribuciones de la comunidad o del equipo - - Contributors: Personas que participaron en este release - - Estadísticas de archivos: Magnitud de los cambios - - Actualizaciones de dependencias: Librerías actualizadas - - Usá esta información para: - 1. Dar crédito a contributors mencionándolos por username (@usuario) - 2. Referenciar issues/PRs específicos cuando sean relevantes (#123) - 3. Mencionar áreas del código más afectadas según las estadísticas - 4. Destacar contribuciones de la comunidad si hay nuevos contributors - 5. Mencionar upgrades importantes de dependencias si afectan al usuario - - REGLAS DE ESTILO: - - Primera persona: "Implementé", "Mejoré", "Arreglé", "Agregué" - - Voseo natural: "podés", "tenés", "querés" (en vez de "puedes", "tienes", "quieres") - - Expresiones naturales: "mucho más simple", "ahora funciona mejor", "sin vueltas" - - Tono profesional pero directo, como si le explicaras a un colega - - Sé técnico y preciso, pero accesible - - NO uses emojis en el contenido de las release notes - - Dá crédito: "Gracias a @usuario por reportar/contribuir" - - REGLAS CRÍTICAS - PREVENCIÓN DE ALUCINACIONES: - 1. Basate EXCLUSIVAMENTE en los commits, issues, PRs listados arriba - 2. Si la sección de cambios está vacía o solo tiene cambios menores, escribí un resumen breve y honesto - 3. NO inventes features, comandos, flags, o funcionalidades que no aparezcan explícitamente - 4. Solo mencioná issues/PRs que estén en el listado - 5. Solo mencioná contributors que estén en el listado - 6. Si no hay suficiente información, sé honesto y simple - 7. Para EXAMPLES, solo mostrá comandos que realmente existan - 8. Si los cambios son principalmente internos, decilo claramente - - VALIDACIÓN DE CONTENIDO: - Antes de escribir cada sección, preguntate: "¿Este detalle específico está en la información que me pasaron?" - Si la respuesta es NO, no lo incluyas. - - Formato de respuesta (IMPORTANTE: Incluí TODAS las secciones): - - TÍTULO: - - RESUMEN: <2-3 oraciones en primera persona contando los cambios más importantes. Mencioná contributors clave si corresponde> - - HIGHLIGHTS: - - - - - - - (Si hay nuevos contributors o muchos issues cerrados, podés incluirlo como highlight) - - QUICK_START: - - IMPORTANTE: Este proyecto es un CLI de Go. Usá "go install github.com/%s/%s@" para instalación. - - EXAMPLES: - EXAMPLE_1: - TITLE: - DESCRIPTION: - LANGUAGE: bash - CODE: - - EXAMPLE_2: - TITLE: - DESCRIPTION: - LANGUAGE: bash - CODE: - - BREAKING_CHANGES: - - - - COMPARISONS: - COMPARISON_1: - FEATURE: - BEFORE: - AFTER: - - CONTRIBUTORS: - - (Si hay contributors listados arriba, incluí esta sección. Si no, poné "N/A") - - LINKS: - - Closed Issues: - - Merged PRs: - ` - - releasePromptTemplateEN = ` - You are a developer writing release notes for your project in first person. - Write in a friendly, casual tone explaining what you built in this version. - - Repository: %s/%s - Previous version: %s - New version: %s - Bump type: %s - - Changes in this release: + releasePromptTemplateES = `# Tarea +Genera release notes en primera persona con tono técnico pero cercano (voseo argentino). + +# Información del Release +- Repositorio: %s/%s +- Versión anterior: %s +- Nueva versión: %s +- Tipo de bump: %s + +# Cambios +%s + +# Instrucciones +1. Basate EXCLUSIVAMENTE en los cambios listados arriba +2. Primera persona con voseo: "Implementé", "Mejoré", "Arreglé" +3. NO inventes features, comandos o funcionalidades +4. Si hay contributors, mencioná con @username +5. Referencias a issues/PRs cuando corresponda + +# Formato de Salida +IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. + +Responde SOLO con JSON válido: +{ + "title": "título conciso (max 60 chars)", + "summary": "2-3 oraciones en primera persona", + "highlights": ["highlight 1", "highlight 2", "highlight 3"], + "breaking_changes": ["cambio 1" o "Ninguno"], + "contributors": "Gracias a @user1, @user2" o "N/A" +} - %s +Genera las release notes ahora.` + + releasePromptTemplateEN = `# Task +Generate release notes in first person with friendly, technical tone. + +# Release Information +- Repository: %s/%s +- Previous version: %s +- New version: %s +- Bump type: %s + +# Changes +%s + +# Instructions +1. Base EVERYTHING on the changes listed above +2. First person: "I added", "I implemented", "I improved", "I fixed" +3. DO NOT invent features, commands, or functionality +4. Credit contributors with @username when applicable +5. Reference issues/PRs when relevant + +# Output Format +Respond with ONLY valid JSON: +{ + "title": "concise title (max 60 chars)", + "summary": "2-3 sentences in first person", + "highlights": ["highlight 1", "highlight 2", "highlight 3"], + "breaking_changes": ["change 1" or "None"], + "contributors": "Thanks to @user1, @user2" or "N/A" +} - STYLE RULES: - - First person: "I added", "I implemented", "I improved", "I fixed" - - Professional but accessible tone (you can use expressions like "now", "simpler", "much better") - - Explain what you did and why it's useful - - Be technical and precise, but approachable - - DO NOT use emojis in the release notes content - - CRITICAL RULES - PREVENTING HALLUCINATIONS: - 1. Base everything EXCLUSIVELY on the commits listed above in "Changes in this release" - 2. If the changes section is empty or only has minor changes (e.g., version bump), write a brief and honest summary - 3. DO NOT invent features, commands, flags, or functionality not explicitly present in the commits - 4. DO NOT mention "validators", "linters", "new options" or other features unless they appear in the commits - 5. If you don't have enough info for a specific example, use generic examples of basic project usage - 6. For EXAMPLES, only show commands that actually exist according to the commits. If there are no significant changes, show existing basic usage - 7. For COMPARISONS, only include comparisons if there are concrete changes to compare. If not, use "N/A" or a generic version comparison - 8. If changes are primarily internal or maintenance-related, state that clearly instead of inventing user-visible features - - CONTENT VALIDATION: - Before writing each section, ask yourself: "Is this specific detail in the commits I was given?" - If the answer is NO, don't include it. - - Response format (IMPORTANT: Include ALL sections): - - TITLE: - - SUMMARY: <2-3 sentences in first person highlighting the most important changes. If there are no significant changes, be honest about it> - - HIGHLIGHTS: - - - - - - - (If there aren't enough real highlights, focus on maintenance, stability, or preparation for future features) - - QUICK_START: - - IMPORTANT: This project is a Go CLI. Use "go install github.com/%s/%s@" for installation. - Do not invent flags or commands that don't exist. - - EXAMPLES: - EXAMPLE_1: - TITLE: - DESCRIPTION: - LANGUAGE: bash - CODE: - - EXAMPLE_2: - TITLE: - DESCRIPTION: - LANGUAGE: bash - CODE: - (Only include examples of functionality that actually exists. If there are no new features, show existing basic usage) - - BREAKING_CHANGES: - - - (Only list breaking changes if they are explicitly mentioned in the commits) - - COMPARISONS: - COMPARISON_1: - FEATURE: - BEFORE: - AFTER: - (If there are no concrete comparisons based on commits, use "N/A" or a generic version comparison) - - LINKS: - (Only include links if they are relevant to this specific release, such as closed issues or related PRs. If there are no relevant links, put "N/A") - ` +Generate the release notes now.` ) // GetPRPromptTemplate devuelve el template adecuado según el idioma @@ -542,3 +441,63 @@ func FormatIssuesForPrompt(issues []models.Issue, locale string) string { return result.String() } + +// Technical Analysis instructions +const ( + technicalAnalysisES = `Proporciona análisis técnico detallado incluyendo: mejores prácticas aplicadas, impacto en rendimiento/mantenibilidad, y consideraciones de seguridad si aplican.` + technicalAnalysisEN = `Provide detailed technical analysis including: best practices applied, performance/maintainability impact, and security considerations if applicable.` +) + +func GetTechnicalAnalysisInstruction(locale string) string { + if locale == "es" { + return technicalAnalysisES + } + return technicalAnalysisEN +} + +// No Issue Reference instructions +const ( + noIssueReferenceES = `No incluyas referencias de issues en el título.` + noIssueReferenceEN = `Do not include issue references in the title.` +) + +func GetNoIssueReferenceInstruction(locale string) string { + if locale == "es" { + return noIssueReferenceES + } + return noIssueReferenceEN +} + +// Release Note Headers +var ( + releaseHeadersES = map[string]string{ + "breaking": "CAMBIOS QUE ROMPEN:", + "features": "NUEVAS CARACTERÍSTICAS:", + "fixes": "CORRECCIONES DE BUGS:", + "improvements": "MEJORAS:", + "closed_issues": "ISSUES CERRADOS:", + "merged_prs": "PULL REQUESTS MERGEADOS:", + "contributors": "CONTRIBUIDORES", + "file_stats": "ESTADÍSTICAS DE ARCHIVOS:", + "deps": "ACTUALIZACIONES DE DEPENDENCIAS:", + } + + releaseHeadersEN = map[string]string{ + "breaking": "BREAKING CHANGES:", + "features": "NEW FEATURES:", + "fixes": "BUG FIXES:", + "improvements": "IMPROVEMENTS:", + "closed_issues": "CLOSED ISSUES:", + "merged_prs": "MERGED PULL REQUESTS:", + "contributors": "CONTRIBUTORS", + "file_stats": "FILE STATISTICS:", + "deps": "DEPENDENCY UPDATES:", + } +) + +func GetReleaseNotesSectionHeaders(locale string) map[string]string { + if locale == "es" { + return releaseHeadersES + } + return releaseHeadersEN +} diff --git a/internal/infrastructure/vcs/github/github_service.go b/internal/infrastructure/vcs/github/github_service.go index a582fff..081d156 100644 --- a/internal/infrastructure/vcs/github/github_service.go +++ b/internal/infrastructure/vcs/github/github_service.go @@ -504,6 +504,9 @@ func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models url = *issue.HTMLURL } + // Extract acceptance criteria from body markdown + criteria := extractAcceptanceCriteria(description) + return &models.Issue{ ID: int(issue.GetID()), Number: issue.GetNumber(), @@ -513,9 +516,28 @@ func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models Labels: labels, Author: author, URL: url, + Criteria: criteria, }, nil } +func extractAcceptanceCriteria(body string) []string { + var criteria []string + lines := strings.Split(body, "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "- [ ]") || strings.HasPrefix(trimmed, "- [x]") { + criterion := strings.TrimSpace(trimmed[5:]) + if criterion != "" { + criteria = append(criteria, criterion) + } + } + } + + return criteria +} + func (ghc *GitHubClient) GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) { opts := &github.RepositoryContentGetOptions{ Ref: tag, diff --git a/internal/services/commit_service.go b/internal/services/commit_service.go index 19f45c5..4261e13 100644 --- a/internal/services/commit_service.go +++ b/internal/services/commit_service.go @@ -153,6 +153,15 @@ func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int) (m } fmt.Println(msg) commitInfo.IssueInfo = issueInfo + + if len(issueInfo.Criteria) > 0 && commitInfo.TicketInfo == nil { + commitInfo.TicketInfo = &models.TicketInfo{ + TicketID: fmt.Sprintf("#%d", issueInfo.Number), + TicketTitle: issueInfo.Title, + TitleDesc: issueInfo.Description, + Criteria: issueInfo.Criteria, + } + } } } } From 461f3e1682d57f299151fd6f70952d917443adc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Sun, 14 Dec 2025 02:21:11 -0300 Subject: [PATCH 3/3] fix: ensure PR summarizer uses template for JSON format instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem PR summarizer was passing raw PR content directly to Gemini without wrapping it in the prompt template, causing the AI to echo back the PR metadata as JSON instead of generating a proper summary. This resulted in error: "422 Validation Failed - missing_field: title" ## Solution - Modified GeneratePRSummary to call generatePRPrompt() to wrap PR content in the template with JSON format instructions - Added validation to ensure title is not empty before returning - Added detailed error message showing AI response when title is missing ## Impact - PR summarization now works correctly - AI generates proper JSON with title, body, and labels - Better error messages for debugging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../ai/gemini/pull_requests_summarizer_service.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go index e2fa73b..04abbe2 100644 --- a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go @@ -52,9 +52,12 @@ func NewGeminiPRSummarizer(ctx context.Context, cfg *config.Config, trans *i18n. }, nil } -func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prompt string) (models.PRSummary, error) { +func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent string) (models.PRSummary, error) { modelName := string(gps.config.AIConfig.Models[config.AIGemini]) + // Envolver el contenido del PR en el template con instrucciones de formato JSON + prompt := gps.generatePRPrompt(prContent) + genConfig := &genai.GenerateContentConfig{ Temperature: float32Ptr(0.3), MaxOutputTokens: int32(10000), @@ -82,6 +85,16 @@ func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prompt str return models.PRSummary{}, fmt.Errorf("error al parsear JSON de PR: %w", err) } + // Validar que el título no esté vacío + if strings.TrimSpace(jsonSummary.Title) == "" { + respLen := len(responseText) + preview := responseText + if respLen > 500 { + preview = responseText[:500] + "..." + } + return models.PRSummary{}, fmt.Errorf("la IA no generó un título para el PR. Respuesta (longitud: %d): %s", respLen, preview) + } + return models.PRSummary{ Title: jsonSummary.Title, Body: jsonSummary.Body,