diff --git a/internal/ai/gemini/commit_summarizer_service.go b/internal/ai/gemini/commit_summarizer_service.go index 469c7b3..5953152 100644 --- a/internal/ai/gemini/commit_summarizer_service.go +++ b/internal/ai/gemini/commit_summarizer_service.go @@ -46,6 +46,71 @@ type ( } ) +// getCommitSuggestionSchema returns the JSON schema for commit suggestions +func getCommitSuggestionSchema() *genai.Schema { + return &genai.Schema{ + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeObject, + Required: []string{"title", "desc", "files"}, + Properties: map[string]*genai.Schema{ + "title": { + Type: genai.TypeString, + Description: "Commit title (type(scope): message)", + }, + "desc": { + Type: genai.TypeString, + Description: "Detailed explanation in first person", + }, + "files": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeString, + }, + Description: "Array of file paths as strings", + }, + "analysis": { + Type: genai.TypeObject, + Required: []string{"overview", "purpose", "impact"}, + Properties: map[string]*genai.Schema{ + "overview": {Type: genai.TypeString}, + "purpose": {Type: genai.TypeString}, + "impact": {Type: genai.TypeString}, + }, + }, + "requirements": { + Type: genai.TypeObject, + Required: []string{"status", "missing", "completed_indices", "suggestions"}, + Properties: map[string]*genai.Schema{ + "status": { + Type: genai.TypeString, + Enum: []string{"full_met", "partially_met", "not_met"}, + }, + "missing": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeString, + }, + }, + "completed_indices": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeInteger, + }, + }, + "suggestions": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeString, + }, + }, + }, + }, + }, + }, + } +} + func NewGeminiCommitSummarizer(ctx context.Context, cfg *config.Config, onConfirmation ai.ConfirmationCallback) (*GeminiCommitSummarizer, error) { providerCfg, exists := cfg.AIProviders["gemini"] if !exists || providerCfg.APIKey == "" { @@ -97,13 +162,11 @@ func NewGeminiCommitSummarizer(ctx context.Context, cfg *config.Config, onConfir func (s *GeminiCommitSummarizer) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { log := logger.FromContext(ctx) - log.Debug("calling gemini API", "model", mName, "prompt_length", len(p)) - - genConfig := GetGenerateConfig(mName, "application/json") - + schema := getCommitSuggestionSchema() + genConfig := GetGenerateConfig(mName, "application/json", schema) resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) if err != nil { log.Error("gemini API call failed", @@ -116,23 +179,24 @@ func (s *GeminiCommitSummarizer) defaultGenerate(ctx context.Context, mName stri strings.Contains(errMsg, "resource exhausted") { return nil, nil, domainErrors.ErrGeminiQuotaExceeded.WithError(err) } - if strings.Contains(errMsg, "invalid") || strings.Contains(errMsg, "unauthorized") || strings.Contains(errMsg, "api key") { return nil, nil, domainErrors.ErrGeminiAPIKeyInvalid.WithError(err) } - return nil, nil, domainErrors.ErrAIGeneration.WithError(err) } - usage := extractUsage(resp) - - log.Debug("gemini API response received", - "input_tokens", usage.InputTokens, - "output_tokens", usage.OutputTokens, - "candidates", len(resp.Candidates)) - + if usage != nil { + log.Debug("gemini API response received", + "input_tokens", usage.InputTokens, + "output_tokens", usage.OutputTokens, + "candidates", len(resp.Candidates)) + } else { + log.Debug("gemini API response received", + "candidates", len(resp.Candidates), + "usage", "nil") + } return resp, usage, nil } @@ -216,15 +280,10 @@ func (s *GeminiCommitSummarizer) parseSuggestionsJSON(responseText string) ([]mo if responseText == "" { return nil, fmt.Errorf("empty response text from AI") } - - responseText = ExtractJSON(responseText) - var jsonSuggestions []CommitSuggestionJSON if err := json.Unmarshal([]byte(responseText), &jsonSuggestions); err != nil { - // Log at default level (no context available here) return nil, fmt.Errorf("error parsing JSON: %w", err) } - suggestions := make([]models.CommitSuggestion, 0, len(jsonSuggestions)) for _, js := range jsonSuggestions { suggestion := models.CommitSuggestion{ @@ -232,7 +291,6 @@ func (s *GeminiCommitSummarizer) parseSuggestionsJSON(responseText string) ([]mo Explanation: js.Desc, Files: js.Files, } - if js.Analysis != nil { suggestion.CodeAnalysis = models.CodeAnalysis{ ChangesOverview: js.Analysis.OverView, @@ -240,7 +298,6 @@ func (s *GeminiCommitSummarizer) parseSuggestionsJSON(responseText string) ([]mo TechnicalImpact: js.Analysis.Impact, } } - if js.Requirements != nil { suggestion.RequirementsAnalysis = models.RequirementsAnalysis{ CriteriaStatus: models.CriteriaStatus(js.Requirements.Status), @@ -248,10 +305,8 @@ func (s *GeminiCommitSummarizer) parseSuggestionsJSON(responseText string) ([]mo ImprovementSuggestions: js.Requirements.Suggestions, } } - suggestions = append(suggestions, suggestion) } - return suggestions, nil } diff --git a/internal/ai/gemini/commit_summarizer_service_test.go b/internal/ai/gemini/commit_summarizer_service_test.go index 74708ed..823043f 100644 --- a/internal/ai/gemini/commit_summarizer_service_test.go +++ b/internal/ai/gemini/commit_summarizer_service_test.go @@ -207,7 +207,6 @@ func TestGeminiCommitSummarizer(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, "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") @@ -237,7 +236,6 @@ func TestGeminiCommitSummarizer(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, "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") diff --git a/internal/ai/gemini/helper.go b/internal/ai/gemini/helper.go index 4110144..ad728ca 100644 --- a/internal/ai/gemini/helper.go +++ b/internal/ai/gemini/helper.go @@ -1,11 +1,9 @@ package gemini import ( - "encoding/json" "strings" "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/regex" "google.golang.org/genai" ) @@ -22,7 +20,7 @@ func extractUsage(resp *genai.GenerateContentResponse) *models.TokenUsage { } // GetGenerateConfig returns the optimal configuration for the model, enabling Thinking Mode if compatible. -func GetGenerateConfig(modelName string, responseType string) *genai.GenerateContentConfig { +func GetGenerateConfig(modelName string, responseType string, schema *genai.Schema) *genai.GenerateContentConfig { config := &genai.GenerateContentConfig{ Temperature: float32Ptr(0.3), MaxOutputTokens: int32(10000), @@ -31,6 +29,9 @@ func GetGenerateConfig(modelName string, responseType string) *genai.GenerateCon if responseType == "application/json" { config.ResponseMIMEType = "application/json" + if schema != nil { + config.ResponseJsonSchema = schema + } } if strings.HasPrefix(modelName, "gemini-3") { @@ -43,108 +44,6 @@ func GetGenerateConfig(modelName string, responseType string) *genai.GenerateCon return config } -// ExtractJSON attempts to extract a valid JSON block from text, handling Markdown code blocks -// and possible extra text that models with "Thinking" mode might generate. -func ExtractJSON(text string) string { - text = strings.TrimSpace(text) - - matches := regex.MarkdownJSONBlock.FindAllStringSubmatch(text, -1) - var bestMarkdown string - for _, m := range matches { - if len(m) > 1 { - content := strings.TrimSpace(m[1]) - sanitized := SanitizeJSON(content) - if json.Valid([]byte(sanitized)) { - if len(sanitized) > len(bestMarkdown) { - bestMarkdown = sanitized - } - } - } - } - if bestMarkdown != "" { - return bestMarkdown - } - - var bestBlock string - for i := 0; i < len(text); { - startIdx := strings.IndexAny(text[i:], "{[") - if startIdx == -1 { - break - } - startIdx += i - - opener := text[startIdx] - var closer byte - if opener == '{' { - closer = '}' - } else { - closer = ']' - } - - count := 0 - inString := false - escaped := false - foundEnd := false - endIdx := -1 - - for j := startIdx; j < len(text); j++ { - char := text[j] - if escaped { - escaped = false - continue - } - if char == '\\' { - escaped = true - continue - } - if char == '"' { - inString = !inString - continue - } - - if !inString { - if char == opener { - count++ - } else if char == closer { - count-- - if count == 0 { - foundEnd = true - endIdx = j - break - } - } - } - } - - if foundEnd { - block := text[startIdx : endIdx+1] - sanitized := SanitizeJSON(block) - if json.Valid([]byte(sanitized)) { - if len(sanitized) > len(bestBlock) { - bestBlock = sanitized - } - } - i = endIdx + 1 - } else { - i = startIdx + 1 - } - } - - if bestBlock != "" { - return bestBlock - } - - return SanitizeJSON(text) -} - -// SanitizeJSON cleans malformed JSON that LLMs sometimes generate, -// such as unescaped newlines within String Literals. -func SanitizeJSON(s string) string { - return regex.JSONString.ReplaceAllStringFunc(s, func(m string) string { - return strings.ReplaceAll(m, "\n", "\\n") - }) -} - func float32Ptr(f float32) *float32 { return &f } diff --git a/internal/ai/gemini/helper_test.go b/internal/ai/gemini/helper_test.go index bd232dc..b0c0941 100644 --- a/internal/ai/gemini/helper_test.go +++ b/internal/ai/gemini/helper_test.go @@ -35,7 +35,7 @@ func TestExtractUsage(t *testing.T) { func TestGetGenerateConfig(t *testing.T) { t.Run("default config", func(t *testing.T) { - cfg := GetGenerateConfig("gemini-1.5-flash", "") + cfg := GetGenerateConfig("gemini-1.5-flash", "", nil) assert.NotNil(t, cfg) assert.Equal(t, float32(0.3), *cfg.Temperature) assert.Empty(t, cfg.ResponseMIMEType) @@ -43,77 +43,14 @@ func TestGetGenerateConfig(t *testing.T) { }) t.Run("json response type", func(t *testing.T) { - cfg := GetGenerateConfig("gemini-1.5-flash", "application/json") + cfg := GetGenerateConfig("gemini-1.5-flash", "application/json", nil) assert.Equal(t, "application/json", cfg.ResponseMIMEType) }) t.Run("Thinking Mode for gemini-3", func(t *testing.T) { - cfg := GetGenerateConfig("gemini-3-flash-preview", "") + cfg := GetGenerateConfig("gemini-3-flash-preview", "", nil) assert.NotNil(t, cfg.ThinkingConfig) assert.True(t, cfg.ThinkingConfig.IncludeThoughts) assert.Equal(t, genai.ThinkingLevelHigh, cfg.ThinkingConfig.ThinkingLevel) }) } - -func TestExtractJSON(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "pure JSON object", - input: `{"key": "value"}`, - expected: `{"key": "value"}`, - }, - { - name: "pure JSON array", - input: `[{"key": "value"}]`, - expected: `[{"key": "value"}]`, - }, - { - name: "markdown code block with json tag", - input: "Sure, here is the JSON:\n```json\n{\"key\": \"value\"}\n```\nHope it helps!", - expected: `{"key": "value"}`, - }, - { - name: "markdown code block without tag", - input: "```\n[1, 2, 3]\n```", - expected: `[1, 2, 3]`, - }, - { - name: "text before and after JSON object", - input: "Some thinking content... {\"title\": \"fix bug\"} more text", - expected: `{"title": "fix bug"}`, - }, - { - name: "text before and after JSON array", - input: "Reasoning: ... [{\"title\": \"feat\"}] end", - expected: `[{"title": "feat"}]`, - }, - { - name: "balanced matching with stray brackets in prose", - input: "Thoughts [about stuff]: {\"key\": \"value\"} More text [end]", - expected: `{"key": "value"}`, - }, - { - name: "unescaped newlines in JSON string", - input: `{"desc": "This is a -multi-line -description"}`, - expected: `{"desc": "This is a\nmulti-line\ndescription"}`, - }, - { - name: "empty input", - input: "", - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ExtractJSON(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/ai/gemini/issue_content_generator.go b/internal/ai/gemini/issue_content_generator.go index fa2f460..77cb508 100644 --- a/internal/ai/gemini/issue_content_generator.go +++ b/internal/ai/gemini/issue_content_generator.go @@ -73,7 +73,7 @@ func NewGeminiIssueContentGenerator(ctx context.Context, cfg *config.Config, onC } func (s *GeminiIssueContentGenerator) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { - genConfig := GetGenerateConfig(mName, "") + genConfig := GetGenerateConfig(mName, "", nil) log := logger.FromContext(ctx) resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) @@ -278,8 +278,6 @@ func (s *GeminiIssueContentGenerator) parseIssueResponse(content string) (*model logger.Debug(context.Background(), "parsing Gemini response", "content_length", len(content), "content_preview", preview) } - content = ExtractJSON(content) - logger.Debug(context.Background(), "extracted JSON content", "content_length", len(content), "content", content) diff --git a/internal/ai/gemini/pull_requests_summarizer_service.go b/internal/ai/gemini/pull_requests_summarizer_service.go index 116540d..d1a5526 100644 --- a/internal/ai/gemini/pull_requests_summarizer_service.go +++ b/internal/ai/gemini/pull_requests_summarizer_service.go @@ -29,6 +29,30 @@ type PRSummaryJSON struct { Labels []string `json:"labels"` } +func getPRSummarySchema() *genai.Schema { + return &genai.Schema{ + Type: genai.TypeObject, + Required: []string{"title", "body", "labels"}, + Properties: map[string]*genai.Schema{ + "title": { + Type: genai.TypeString, + Description: "PR title (max 80 chars)", + }, + "body": { + Type: genai.TypeString, + Description: "Detailed markdown body with overview, key changes, and technical impact", + }, + "labels": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeString, + }, + Description: "Array of label strings (feature, fix, refactor, docs, infra, test, breaking-change)", + }, + }, + } +} + func NewGeminiPRSummarizer(ctx context.Context, cfg *config.Config, onConfirmation ai.ConfirmationCallback) (*GeminiPRSummarizer, error) { providerCfg, exists := cfg.AIProviders["gemini"] if !exists || providerCfg.APIKey == "" { @@ -77,31 +101,27 @@ func NewGeminiPRSummarizer(ctx context.Context, cfg *config.Config, onConfirmati } func (gps *GeminiPRSummarizer) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { - genConfig := GetGenerateConfig(mName, "application/json") + schema := getPRSummarySchema() + genConfig := GetGenerateConfig(mName, "application/json", schema) log := logger.FromContext(ctx) - resp, err := gps.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) if err != nil { log.Error("gemini API call failed", "error", err, "model", mName) - errMsg := strings.ToLower(err.Error()) if strings.Contains(errMsg, "quota") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "resource exhausted") { return nil, nil, domainErrors.ErrGeminiQuotaExceeded.WithError(err) } - if strings.Contains(errMsg, "invalid") || strings.Contains(errMsg, "unauthorized") || strings.Contains(errMsg, "api key") { return nil, nil, domainErrors.ErrGeminiAPIKeyInvalid.WithError(err) } - return nil, nil, domainErrors.ErrAIGeneration.WithError(err) } - usage := extractUsage(resp) return resp, usage, nil } @@ -146,7 +166,6 @@ func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent WithContext("operation", "summarize PR") } - responseText = ExtractJSON(responseText) var jsonSummary PRSummaryJSON if err := json.Unmarshal([]byte(responseText), &jsonSummary); err != nil { respLen := len(responseText) diff --git a/internal/ai/gemini/release_generator.go b/internal/ai/gemini/release_generator.go index 8491a7b..a37e46d 100644 --- a/internal/ai/gemini/release_generator.go +++ b/internal/ai/gemini/release_generator.go @@ -33,6 +33,42 @@ type ReleaseNotesJSON struct { Contributors string `json:"contributors"` } +// getReleaseNotesSchema returns the JSON schema for release notes +func getReleaseNotesSchema() *genai.Schema { + return &genai.Schema{ + Type: genai.TypeObject, + Required: []string{"title", "summary", "highlights", "breaking_changes", "contributors"}, + Properties: map[string]*genai.Schema{ + "title": { + Type: genai.TypeString, + Description: "Concise and descriptive title", + }, + "summary": { + Type: genai.TypeString, + Description: "2-3 sentences explaining the release focus in first person plural", + }, + "highlights": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeString, + }, + Description: "Array of highlights as strings", + }, + "breaking_changes": { + Type: genai.TypeArray, + Items: &genai.Schema{ + Type: genai.TypeString, + }, + Description: "Array of breaking changes as strings (or [] if none)", + }, + "contributors": { + Type: genai.TypeString, + Description: "Text with contributors (e.g., 'Thanks to @user1, @user2') or 'N/A'", + }, + }, + } +} + func NewReleaseNotesGenerator(ctx context.Context, cfg *config.Config, onConfirmation ai.ConfirmationCallback, owner, repo string) (*ReleaseNotesGenerator, error) { providerCfg, exists := cfg.AIProviders["gemini"] if !exists || providerCfg.APIKey == "" { @@ -85,31 +121,27 @@ func NewReleaseNotesGenerator(ctx context.Context, cfg *config.Config, onConfirm } func (g *ReleaseNotesGenerator) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { - genConfig := GetGenerateConfig(mName, "application/json") + schema := getReleaseNotesSchema() + genConfig := GetGenerateConfig(mName, "application/json", schema) log := logger.FromContext(ctx) - resp, err := g.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) if err != nil { log.Error("gemini API call failed", "error", err, "model", mName) - errMsg := strings.ToLower(err.Error()) if strings.Contains(errMsg, "quota") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "resource exhausted") { return nil, nil, domainErrors.ErrGeminiQuotaExceeded.WithError(err) } - if strings.Contains(errMsg, "invalid") || strings.Contains(errMsg, "unauthorized") || strings.Contains(errMsg, "api key") { return nil, nil, domainErrors.ErrGeminiAPIKeyInvalid.WithError(err) } - return nil, nil, domainErrors.ErrAIGeneration.WithError(err) } - usage := extractUsage(resp) return resp, usage, nil } @@ -305,8 +337,6 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } func (g *ReleaseNotesGenerator) parseJSONResponse(content string, release *models.Release) (*models.ReleaseNotes, error) { - content = ExtractJSON(content) - var jsonNotes ReleaseNotesJSON if err := json.Unmarshal([]byte(content), &jsonNotes); err != nil { return nil, domainErrors.NewAppError(domainErrors.TypeAI, "error parsing AI JSON response", err) diff --git a/internal/ai/gemini/release_generator_test.go b/internal/ai/gemini/release_generator_test.go index c1cf2c5..18b59bf 100644 --- a/internal/ai/gemini/release_generator_test.go +++ b/internal/ai/gemini/release_generator_test.go @@ -223,25 +223,6 @@ func TestParseJSONResponse(t *testing.T) { assert.Contains(t, err.Error(), "error parsing AI JSON response") }) - t.Run("handles JSON with code fences", func(t *testing.T) { - // Arrange - content := "```json\n" + `{ - "title": "Test", - "summary": "Summary", - "highlights": [], - "breaking_changes": [], - "contributors": "" - }` + "\n```" - - // Act - notes, err := generator.parseJSONResponse(content, release) - - // Assert - assert.NoError(t, err) - assert.Equal(t, "Test", notes.Title) - assert.Equal(t, "Summary", notes.Summary) - }) - t.Run("handles N/A contributors", func(t *testing.T) { content := `{"title": "T", "summary": "S", "highlights": [], "breaking_changes": [], "contributors": "N/A"}` notes, err := generator.parseJSONResponse(content, release) diff --git a/internal/ai/prompts.go b/internal/ai/prompts.go index 4f5476e..8b5bb4f 100644 --- a/internal/ai/prompts.go +++ b/internal/ai/prompts.go @@ -69,147 +69,40 @@ func RenderPrompt(name, tmplStr string, data interface{}) (string, error) { const ( prPromptTemplateEN = `# Task Act as a Senior Tech Lead and generate a Pull Request summary. - # PR Content {{.PRContent}} - # Golden Rules (Constraints) 1. **No Hallucinations:** If it's not in the diff, DO NOT invent it. 2. **Tone:** Professional, direct, technical. Use first person ("I implemented", "I added"). - 3. **Format:** Raw JSON only. Do not wrap in markdown blocks (like ` + "" + `). - # Instructions 1. Title: Catchy but descriptive (max 80 chars). 2. Key Changes: Filter the noise. Explain the *technical impact*, not just the code change. - 3. Labels: Choose wisely (feature, fix, refactor, docs, infra, test, breaking-change). - - # STRICT OUTPUT FORMAT - ⚠️ CRITICAL: You MUST return ONLY valid JSON. No markdown blocks, no explanations, no text before/after. - ⚠️ ALL field types are STRICTLY enforced. DO NOT change types or add extra fields. - - ## JSON Schema (MANDATORY): - { - "type": "object", - "required": ["title", "body", "labels"], - "properties": { - "title": { - "type": "string", - "description": "PR title (max 80 chars)" - }, - "body": { - "type": "string", - "description": "Detailed markdown body with overview, key changes, and technical impact" - }, - "labels": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array of label strings (feature, fix, refactor, docs, infra, test, breaking-change)" - } - }, - "additionalProperties": false - } - - ## Type Rules (STRICT): - - "title": MUST be string (never number, never null, never empty) - - "body": MUST be string (never number, never null, can contain markdown) - - "labels": MUST be array of strings (never array of numbers, never null, use [] if empty) - - ## Prohibited Actions: - ❌ DO NOT add any fields not listed in the schema - ❌ DO NOT change field types (e.g., title to number) - ❌ DO NOT wrap JSON in markdown code blocks - ❌ DO NOT add explanatory text before/after JSON - ❌ DO NOT use null values for required fields - - ## Valid Example: - { - "title": "feat(auth): implement OAuth2 authentication", - "body": "## Overview\nI implemented OAuth2 authentication to improve security.\n\n## Key Changes\n- Added OAuth2 client\n- Updated login flow\n\n## Technical Impact\nImproves security and allows SSO integration.", - "labels": ["feature", "auth"] - } - - Generate the summary now. Return ONLY the JSON object, nothing else.` + 3. Labels: Choose wisely (feature, fix, refactor, docs, infra, test, breaking-change).` prPromptTemplateES = `# Tarea Actuá como un Desarrollador Senior y genera un resumen del Pull Request. - # Contenido del PR {{.PRContent}} - # Reglas de Oro (Constraints) 1. **Cero alucinaciones:** Si algo no está explícito en el diff, no lo inventes. 2. **Tono:** Profesional, cercano y directo. Usa primera persona ("Implementé", "Agregué", "Corregí"). Evita el lenguaje robótico ("Se ha realizado"). - 3. **Formato:** JSON crudo. No incluyas bloques de markdown. - # Instrucciones 1. Título: Descriptivo y conciso (máx 80 caracteres). 2. Cambios Clave: Filtrá el ruido. Explicá el *impacto* técnico y el propósito, no solo qué línea cambió. 3. Etiquetas: Elegí con criterio (feature, fix, refactor, docs, infra, test, breaking-change). - - # FORMATO DE SALIDA ESTRICTO - ⚠️ CRÍTICO: DEBES devolver SOLO JSON válido. Sin bloques de markdown, sin explicaciones, sin texto antes/después. - ⚠️ TODOS los tipos de campos están ESTRICTAMENTE definidos. NO cambies tipos ni agregues campos extra. - IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. - ## Schema JSON (OBLIGATORIO): - { - "type": "object", - "required": ["title", "body", "labels"], - "properties": { - "title": { - "type": "string", - "description": "Título del PR (máx 80 caracteres)" - }, - "body": { - "type": "string", - "description": "Cuerpo detallado en markdown con resumen, cambios clave e impacto técnico" - }, - "labels": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array de etiquetas como strings (feature, fix, refactor, docs, infra, test, breaking-change)" - } - }, - "additionalProperties": false - } - - ## Reglas de Tipos (ESTRICTAS): - - "title": DEBE ser string (nunca número, nunca null, nunca vacío) - - "body": DEBE ser string (nunca número, nunca null, puede contener markdown) - - "labels": DEBE ser array de strings (nunca array de números, nunca null, usar [] si está vacío) - - ## Acciones Prohibidas: - ❌ NO agregues campos que no estén en el schema - ❌ NO cambies tipos de campos (ej: title a número) - ❌ NO envuelvas el JSON en bloques de markdown - ❌ NO agregues texto explicativo antes/después del JSON - ❌ NO uses null para campos requeridos - - ## Ejemplo Válido: - { - "title": "feat(auth): implementar autenticación OAuth2", - "body": "## Resumen\nImplementé autenticación OAuth2 para mejorar la seguridad.\n\n## Cambios Clave\n- Agregué cliente OAuth2\n- Actualicé el flujo de login\n\n## Impacto Técnico\nMejora la seguridad y permite integración SSO.", - "labels": ["feature", "auth"] - } - - Genera el resumen ahora. Devuelve SOLO el objeto JSON, nada más.` + IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español.` ) const ( promptTemplateWithTicketEN = `# Task Act as a Git Specialist and generate {{.Count}} commit message suggestions. - # Context - Modified Files: {{.Files}} - Diff: {{.Diff}} - Ticket/Issue: {{.Ticket}} - Recent History: {{.History}} - Issue Instructions: {{.Instructions}} - # Quality Guidelines 1. **Conventional Commits:** Strictly follow ` + "`type(scope): description`" + `. - Types: feat, fix, refactor, perf, test, docs, chore, build, ci. @@ -226,111 +119,7 @@ const ( - If recent history shows something was implemented in previous commits, do NOT mark it as missing. - If you see file names or function names in the diff indicating prior implementation (e.g., "stats.go", "CountTokens"), assume it exists. - Focus on what's missing NOW in the current commit context, not in the entire project. - - # STRICT OUTPUT FORMAT - ⚠️ CRITICAL: You MUST return ONLY valid JSON. No markdown blocks, no explanations, no text before/after. - ⚠️ ALL field types are STRICTLY enforced. DO NOT change types or add extra fields. - - ## JSON Schema (MANDATORY): - { - "type": "array", - "items": { - "type": "object", - "required": ["title", "desc", "files"], - "properties": { - "title": { - "type": "string", - "description": "Commit title (type(scope): message)" - }, - "desc": { - "type": "string", - "description": "Detailed explanation in first person" - }, - "files": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array of file paths as strings" - }, - "analysis": { - "type": "object", - "required": ["overview", "purpose", "impact"], - "properties": { - "overview": {"type": "string"}, - "purpose": {"type": "string"}, - "impact": {"type": "string"} - }, - "additionalProperties": false - }, - "requirements": { - "type": "object", - "required": ["status", "missing", "completed_indices", "suggestions"], - "properties": { - "status": { - "type": "string", - "enum": ["full_met", "partially_met", "not_met"] - }, - "missing": { - "type": "array", - "items": {"type": "string"} - }, - "completed_indices": { - "type": "array", - "items": {"type": "integer"} - }, - "suggestions": { - "type": "array", - "items": {"type": "string"} - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - } - - ## Type Rules (STRICT): - - "title": MUST be string (never number, never null) - - "desc": MUST be string (never number, never null, can be empty string "") - - "files": MUST be array of strings (never array of numbers, never null) - - "analysis.overview": MUST be string - - "analysis.purpose": MUST be string - - "analysis.impact": MUST be string - - "requirements.status": MUST be one of: "full_met" | "partially_met" | "not_met" (exact strings) - - "requirements.missing": MUST be array of strings (never null, use [] if empty) - - "requirements.completed_indices": MUST be array of integers (never strings, never null, use [] if empty) - - "requirements.suggestions": MUST be array of strings (never null, use [] if empty) - - ## Prohibited Actions: - ❌ DO NOT add any fields not listed in the schema - ❌ DO NOT change field types (e.g., desc to number) - ❌ DO NOT wrap JSON in markdown code blocks - ❌ DO NOT add explanatory text before/after JSON - ❌ DO NOT use null values for required string fields (use "" instead) - - ## Valid Example: - [ - { - "title": "fix(auth): handle null token error (#42)", - "desc": "I added validation to prevent null token errors in the authentication flow", - "files": ["internal/auth/auth.go", "internal/auth/auth_test.go"], - "analysis": { - "overview": "Added null check for token", - "purpose": "Prevent panic when token is null", - "impact": "Improves error handling" - }, - "requirements": { - "status": "full_met", - "missing": [], - "completed_indices": [0, 1], - "suggestions": [] - } - } - ] - - Generate {{.Count}} suggestions now. Return ONLY the JSON array, nothing else.` + Generate {{.Count}} suggestions now.` promptTemplateWithTicketES = `# Tarea Actuá como un especialista en Git y genera {{.Count}} sugerencias de commits. @@ -341,7 +130,6 @@ const ( - Ticket/Issue: {{.Ticket}} - Historial reciente: {{.History}} - Instrucciones Issue: {{.Instructions}} - # Criterios de Calidad (Guidelines) 1. **Conventional Commits:** Respeta estrictamente ` + "`tipo(scope): descripción`" + `. - Tipos: feat, fix, refactor, perf, test, docs, chore, build, ci. @@ -349,131 +137,24 @@ const ( - ❌ MAL: "fix: arreglos varios en el login" (Muy vago) - ✅ BIEN: "fix(auth): manejo de error en token nulo (#42)" (Preciso) 3. **Scope:** Si tocaste archivos de 'ui', el scope es (ui). Si es 'api', es (api). Si son muchos, no uses scope. - 4. **Primera Persona:** La descripción ("desc") escribila como si le contaras a un colega (ej: "Optimicé la query para mejorar el tiempo de respuesta"). + 4. **Primera Persona:** La descripción (\"desc\") escribila como si le contaras a un colega (ej: \"Optimicé la query para mejorar el tiempo de respuesta\"). 5. **Validación de Requerimientos (IMPORTANTE):** - Analiza SOLO los cambios del diff actual contra los criterios del ticket. - Marca como "missing" ÚNICAMENTE requisitos que NO están visibles en el diff. - Si el historial reciente muestra que algo ya se implementó en commits anteriores, NO lo marques como faltante. - Si ves nombres de archivos o funciones en el diff que indican implementación previa (ej: "stats.go", "CountTokens"), asume que ya existe. - Enfocate en lo que falta AHORA en el contexto del commit actual, no en el proyecto completo. - - # FORMATO DE SALIDA ESTRICTO - ⚠️ CRÍTICO: DEBES devolver SOLO JSON válido. Sin bloques de markdown, sin explicaciones, sin texto antes/después. - ⚠️ TODOS los tipos de campos están ESTRICTAMENTE definidos. NO cambies tipos ni agregues campos extra. - IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. - - ## Schema JSON (OBLIGATORIO): - { - "type": "array", - "items": { - "type": "object", - "required": ["title", "desc", "files"], - "properties": { - "title": { - "type": "string", - "description": "Título del commit (tipo(scope): mensaje)" - }, - "desc": { - "type": "string", - "description": "Explicación detallada en primera persona" - }, - "files": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array de rutas de archivos como strings" - }, - "analysis": { - "type": "object", - "required": ["overview", "purpose", "impact"], - "properties": { - "overview": {"type": "string"}, - "purpose": {"type": "string"}, - "impact": {"type": "string"} - }, - "additionalProperties": false - }, - "requirements": { - "type": "object", - "required": ["status", "missing", "completed_indices", "suggestions"], - "properties": { - "status": { - "type": "string", - "enum": ["full_met", "partially_met", "not_met"] - }, - "missing": { - "type": "array", - "items": {"type": "string"} - }, - "completed_indices": { - "type": "array", - "items": {"type": "integer"} - }, - "suggestions": { - "type": "array", - "items": {"type": "string"} - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - } - - ## Reglas de Tipos (ESTRICTAS): - - "title": DEBE ser string (nunca número, nunca null) - - "desc": DEBE ser string (nunca número, nunca null, puede ser "" si está vacío) - - "files": DEBE ser array de strings (nunca array de números, nunca null) - - "analysis.overview": DEBE ser string - - "analysis.purpose": DEBE ser string - - "analysis.impact": DEBE ser string - - "requirements.status": DEBE ser uno de: "full_met" | "partially_met" | "not_met" (strings exactos) - - "requirements.missing": DEBE ser array de strings (nunca null, usar [] si está vacío) - - "requirements.completed_indices": DEBE ser array de enteros (nunca strings, nunca null, usar [] si está vacío) - - "requirements.suggestions": DEBE ser array de strings (nunca null, usar [] si está vacío) - - ## Acciones Prohibidas: - ❌ NO agregues campos que no estén en el schema - ❌ NO cambies tipos de campos (ej: desc a número) - ❌ NO envuelvas el JSON en bloques de markdown - ❌ NO agregues texto explicativo antes/después del JSON - ❌ NO uses null para campos string requeridos (usa "" en su lugar) - - ## Ejemplo Válido: - [ - { - "title": "fix(auth): manejo de error en token nulo (#42)", - "desc": "Agregué validación para prevenir errores cuando el token es nulo", - "files": ["internal/auth/auth.go", "internal/auth/auth_test.go"], - "analysis": { - "overview": "Agregué validación de token nulo", - "purpose": "Prevenir panic cuando el token es null", - "impact": "Mejora el manejo de errores" - }, - "requirements": { - "status": "full_met", - "missing": [], - "completed_indices": [0, 1], - "suggestions": [] - } - } - ] - - Genera {{.Count}} sugerencias ahora. Devuelve SOLO el array JSON, nada más.` + Genera {{.Count}} sugerencias ahora.` ) const ( promptTemplateWithoutTicketES = `# Tarea Actuá como un especialista en Git y genera {{.Count}} sugerencias de commits basadas en el código. - # Inputs - Archivos Modificados: {{.Files}} - Cambios (Diff): {{.Diff}} - Instrucciones Issues: {{.Instructions}} - Historial: {{.History}} - # Estrategia de Generación 1. **Analiza el Diff:** Identifica qué lógica cambió realmente. Ignora cambios de formato/espacios. 2. **Categoriza:** @@ -484,96 +165,20 @@ const ( 3. **Redacta:** - Título: Imperativo, max 50 chars si es posible (ej: "agrega validación", no "agregando"). - Descripción: Primera persona, tono profesional y natural. "Agregué esta validación para evitar X error". - # Ejemplos de Estilo - ❌ "update main.go" (Pésimo, no dice nada) - ❌ "se corrigió el error" (Voz pasiva, muy robótico) - ✅ "fix(cli): corrijo panic al no tener config" (Bien) - - # FORMATO DE SALIDA ESTRICTO - ⚠️ CRÍTICO: DEBES devolver SOLO JSON válido. Sin bloques de markdown, sin explicaciones, sin texto antes/después. - ⚠️ TODOS los tipos de campos están ESTRICTAMENTE definidos. NO cambies tipos ni agregues campos extra. - IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. - - ## Schema JSON (OBLIGATORIO): - { - "type": "array", - "items": { - "type": "object", - "required": ["title", "desc", "files", "analysis"], - "properties": { - "title": { - "type": "string", - "description": "Título del commit (tipo(scope): mensaje)" - }, - "desc": { - "type": "string", - "description": "Explicación detallada en primera persona" - }, - "files": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array de rutas de archivos como strings" - }, - "analysis": { - "type": "object", - "required": ["overview", "purpose", "impact"], - "properties": { - "overview": {"type": "string"}, - "purpose": {"type": "string"}, - "impact": {"type": "string"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - } - - ## Reglas de Tipos (ESTRICTAS): - - "title": DEBE ser string (nunca número, nunca null) - - "desc": DEBE ser string (nunca número, nunca null, puede ser "" si está vacío) - - "files": DEBE ser array de strings (nunca array de números, nunca null) - - "analysis.overview": DEBE ser string - - "analysis.purpose": DEBE ser string - - "analysis.impact": DEBE ser string - - ## Acciones Prohibidas: - ❌ NO agregues campos que no estén en el schema - ❌ NO cambies tipos de campos (ej: desc a número) - ❌ NO envuelvas el JSON en bloques de markdown - ❌ NO agregues texto explicativo antes/después del JSON - ❌ NO uses null para campos string requeridos (usa "" en su lugar) - - ## Ejemplo Válido: - [ - { - "title": "fix(cli): corrijo panic al no tener config", - "desc": "Agregué validación para evitar panic cuando no hay archivo de configuración", - "files": ["internal/config/config.go"], - "analysis": { - "overview": "Agregué validación de configuración", - "purpose": "Prevenir panic cuando falta config", - "impact": "Mejora la robustez del CLI" - } - } - ] - {{.TechnicalInfo}} - - Genera {{.Count}} sugerencias ahora. Devuelve SOLO el array JSON, nada más.` + Genera {{.Count}} sugerencias ahora.` promptTemplateWithoutTicketEN = `# Task Act as a Git Specialist and generate {{.Count}} commit message suggestions based on code changes. - # Inputs - Modified Files: {{.Files}} - Code Changes (Diff): {{.Diff}} - Issue Instructions: {{.Instructions}} - Recent History: {{.History}} - # Generation Strategy 1. **Analyze Diff:** Identify logic changes vs formatting. 2. **Categorize:** @@ -584,100 +189,23 @@ const ( 3. **Drafting:** - Title: Imperative mood, max 50 chars if possible (e.g., "add validation", not "adding"). - Description: First person, professional tone. "I added this validation to prevent X error". - # Style Examples - ❌ "update main.go" (Terrible, says nothing) - ❌ "error was fixed" (Passive voice) - ✅ "fix(cli): handle panic when config is missing" (Perfect) - - # STRICT OUTPUT FORMAT - ⚠️ CRITICAL: You MUST return ONLY valid JSON. No markdown blocks, no explanations, no text before/after. - ⚠️ ALL field types are STRICTLY enforced. DO NOT change types or add extra fields. - - ## JSON Schema (MANDATORY): - { - "type": "array", - "items": { - "type": "object", - "required": ["title", "desc", "files", "analysis"], - "properties": { - "title": { - "type": "string", - "description": "Commit title (type(scope): message)" - }, - "desc": { - "type": "string", - "description": "Detailed explanation in first person" - }, - "files": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array of file paths as strings" - }, - "analysis": { - "type": "object", - "required": ["overview", "purpose", "impact"], - "properties": { - "overview": {"type": "string"}, - "purpose": {"type": "string"}, - "impact": {"type": "string"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - } - - ## Type Rules (STRICT): - - "title": MUST be string (never number, never null) - - "desc": MUST be string (never number, never null, can be empty string "") - - "files": MUST be array of strings (never array of numbers, never null) - - "analysis.overview": MUST be string - - "analysis.purpose": MUST be string - - "analysis.impact": MUST be string - - ## Prohibited Actions: - ❌ DO NOT add any fields not listed in the schema - ❌ DO NOT change field types (e.g., desc to number) - ❌ DO NOT wrap JSON in markdown code blocks - ❌ DO NOT add explanatory text before/after JSON - ❌ DO NOT use null values for required string fields (use "" instead) - - ## Valid Example: - [ - { - "title": "fix(cli): handle panic when config is missing", - "desc": "I added validation to prevent panic when configuration file is missing", - "files": ["internal/config/config.go"], - "analysis": { - "overview": "Added configuration validation", - "purpose": "Prevent panic when config is missing", - "impact": "Improves CLI robustness" - } - } - ] - {{.TechnicalInfo}} - - Generate {{.Count}} suggestions now. Return ONLY the JSON array, nothing else.` + Generate {{.Count}} suggestions now.` ) const ( releasePromptTemplateES = `# Tarea Generá release notes profesionales para un CHANGELOG.md siguiendo el estándar "Keep a Changelog". - # Datos del Release - Repo: {{.RepoOwner}}/{{.RepoName}} - Versiones: {{.CurrentVersion}} -> {{.LatestVersion}} ({{.ReleaseDate}}) - # Changelog (Diff) {{.Changelog}} - # Instrucciones Críticas - ## 1. FILTRADO DE RUIDO TÉCNICO **IGNORAR completamente** estos tipos de commits (no incluirlos en ninguna sección): - Cambios en mocks o tests internos (ej: "Implementa GetIssue en MockVCSClient") @@ -685,128 +213,50 @@ Generá release notes profesionales para un CHANGELOG.md siguiendo el estándar - Updates menores de dependencias (ej: "chore: update go.mod") - Cambios de documentación interna o comentarios - Fixes de typos en código o variables internas - **SÍ INCLUIR** solo cambios que impactan al usuario final: - Nuevas features visibles - Mejoras de performance o UX - Correcciones de bugs que afectaban funcionalidad - Breaking changes - Updates importantes de dependencias (cambios de versión mayor) - ## 2. AGRUPACIÓN INTELIGENTE **AGRUPAR** commits relacionados bajo un concepto unificador: - ❌ **MAL** (lista cruda de commits): - "feat: agregar spinners" - "feat: agregar colores" - "feat: mejorar feedback visual" - ✅ **BIEN** (agrupado con valor): - "UX Renovada: Agregamos spinners, colores y feedback visual en todas las operaciones largas para que no sientas que la terminal se colgó" - **Reglas de agrupación:** - Si 3+ commits tocan el mismo módulo/feature → agrupar en un solo highlight - Priorizar el VALOR para el usuario, no los detalles técnicos - Máximo 5-7 highlights por release (no listar 15+ ítems) - ## 3. IDIOMA Y TONO **ESPAÑOL ARGENTINO PROFESIONAL:** - Tono: Conversacional pero técnico, como un email entre devs - Primera persona plural: "Agregamos", "Mejoramos", "Implementamos" - Evitar spanglish completamente (nada de "fixeamos" o "pusheamos") - Evitar jerga forzada, mantener profesionalismo - **Ejemplos de tono correcto:** - ✅ "Automatizamos la generación del CHANGELOG.md" - ✅ "Mejoramos la detección automática de issues" - ❌ "Se implementó la feature de changelog" (muy formal/pasivo) - ❌ "Agregamos un fix re-copado" (muy informal) - ## 4. ESTRUCTURA Y NARRATIVA Cada release debe contar una historia: - **Summary:** Explicar el foco principal del release (ej: "En esta versión nos enfocamos en mejorar la UX y automatizar el proceso de releases") - **Highlights:** Agrupar por tema (UX, Automatización, Performance, etc.) - Cada highlight debe responder: "¿Qué ganó el usuario con esto?" - -## 5. FORMATO DE SALIDA ESTRICTO -⚠️ CRÍTICO: DEBES devolver SOLO JSON válido. Sin bloques de markdown, sin explicaciones, sin texto antes/después. -⚠️ TODOS los tipos de campos están ESTRICTAMENTE definidos. NO cambies tipos ni agregues campos extra. - -## Schema JSON (OBLIGATORIO): -{ - "type": "object", - "required": ["title", "summary", "highlights", "breaking_changes", "contributors"], - "properties": { - "title": { - "type": "string", - "description": "Título conciso y descriptivo" - }, - "summary": { - "type": "string", - "description": "2-3 oraciones explicando el foco del release en primera persona plural" - }, - "highlights": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array de highlights como strings" - }, - "breaking_changes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array de breaking changes como strings (o [] si no hay)" - }, - "contributors": { - "type": "string", - "description": "Texto con contribuidores (ej: 'Gracias a @user1, @user2') o 'N/A'" - } - }, - "additionalProperties": false -} - -## Reglas de Tipos (ESTRICTAS): -- "title": DEBE ser string (nunca número, nunca null) -- "summary": DEBE ser string (nunca número, nunca null) -- "highlights": DEBE ser array de strings (nunca array de números, nunca null, usar [] si está vacío) -- "breaking_changes": DEBE ser array de strings (nunca array de números, nunca null, usar [] si no hay) -- "contributors": DEBE ser string (nunca número, nunca null, usar "N/A" si no hay contribuidores) - -## Acciones Prohibidas: -❌ NO agregues campos que no estén en el schema -❌ NO cambies tipos de campos (ej: highlights a objeto) -❌ NO envuelvas el JSON en bloques de markdown -❌ NO agregues texto explicativo antes/después del JSON -❌ NO uses null para campos requeridos (usa [] para arrays vacíos, "N/A" para contributors vacío) - -## Ejemplo Válido: -{ - "title": "Mejoras de Experiencia de Usuario", - "summary": "En esta versión nos enfocamos en mejorar la experiencia de usuario agregando feedback visual completo. Ya no vas a sentir que la terminal se colgó durante operaciones largas.", - "highlights": [ - "UX Renovada: Agregamos spinners, colores y feedback visual en todas las operaciones largas (#45)", - "Correcciones: Mejoramos el formato de los spinners para mejor legibilidad" - ], - "breaking_changes": [], - "contributors": "N/A" -} - Generá las release notes ahora siguiendo estas instrucciones al pie de la letra.` releasePromptTemplateEN = `# Task Generate professional release notes for a CHANGELOG.md following the "Keep a Changelog" standard. - # Release Information - Repository: {{.RepoOwner}}/{{.RepoName}} - Versions: {{.CurrentVersion}} -> {{.LatestVersion}} ({{.ReleaseDate}}) - # Changelog (Diff) {{.Changelog}} - # Critical Instructions - ## 1. TECHNICAL NOISE FILTERING **COMPLETELY IGNORE** these types of commits (do not include them in any section): - Changes to mocks or internal tests (e.g., "Implement GetIssue in MockVCSClient") @@ -814,113 +264,39 @@ Generate professional release notes for a CHANGELOG.md following the "Keep a Cha - Minor dependency updates (e.g., "chore: update go.mod") - Internal documentation or comment changes - Typo fixes in code or internal variables - **DO INCLUDE** only changes that impact the end user: - New visible features - Performance or UX improvements - Bug fixes affecting functionality - Breaking changes - Important dependency updates (major version changes) - ## 2. INTELLIGENT GROUPING **GROUP** related commits under a unifying concept: - ❌ **BAD** (raw commit list): - "feat: add spinners" - "feat: add colors" - "feat: improve visual feedback" - ✅ **GOOD** (grouped with value): - "Revamped UX: Added spinners, colors, and visual feedback across all long-running operations so you never feel like the terminal froze" - **Grouping rules:** - If 3+ commits touch the same module/feature → group into a single highlight - Prioritize USER VALUE, not technical details - Maximum 5-7 highlights per release (don't list 15+ items) - ## 3. LANGUAGE AND TONE **PROFESSIONAL ENGLISH:** - Tone: Conversational yet technical, like an email between developers - First person plural: "We added", "We improved", "We implemented" - Maintain professionalism, avoid forced slang - **Examples of correct tone:** - ✅ "We automated CHANGELOG.md generation" - ✅ "We improved automatic issue detection" - ❌ "The changelog feature was implemented" (too formal/passive) - ❌ "We added a super cool fix" (too informal) - ## 4. STRUCTURE AND NARRATIVE Each release should tell a story: - **Summary:** Explain the main focus of the release (e.g., "In this release, we focused on improving UX and automating the release process") - **Highlights:** Group by theme (UX, Automation, Performance, etc.) - Each highlight should answer: "What did the user gain from this?" - -## 5. STRICT OUTPUT FORMAT -⚠️ CRITICAL: You MUST return ONLY valid JSON. No markdown blocks, no explanations, no text before/after. -⚠️ ALL field types are STRICTLY enforced. DO NOT change types or add extra fields. - -## JSON Schema (MANDATORY): -{ - "type": "object", - "required": ["title", "summary", "highlights", "breaking_changes", "contributors"], - "properties": { - "title": { - "type": "string", - "description": "Concise and descriptive title" - }, - "summary": { - "type": "string", - "description": "2-3 sentences explaining the release focus in first person plural" - }, - "highlights": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array of highlight strings" - }, - "breaking_changes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array of breaking change strings (or [] if none)" - }, - "contributors": { - "type": "string", - "description": "Contributors text (e.g., 'Thanks to @user1, @user2') or 'N/A'" - } - }, - "additionalProperties": false -} - -## Type Rules (STRICT): -- "title": MUST be string (never number, never null) -- "summary": MUST be string (never number, never null) -- "highlights": MUST be array of strings (never array of numbers, never null, use [] if empty) -- "breaking_changes": MUST be array of strings (never array of numbers, never null, use [] if none) -- "contributors": MUST be string (never number, never null, use "N/A" if no contributors) - -## Prohibited Actions: -❌ DO NOT add any fields not listed in the schema -❌ DO NOT change field types (e.g., highlights to object) -❌ DO NOT wrap JSON in markdown code blocks -❌ DO NOT add explanatory text before/after JSON -❌ DO NOT use null values for required fields (use [] for empty arrays, "N/A" for empty contributors) - -## Valid Example: -{ - "title": "User Experience Improvements", - "summary": "In this release, we focused on improving the user experience by adding complete visual feedback. You'll no longer feel like the terminal froze during long operations.", - "highlights": [ - "Revamped UX: Added spinners, colors, and visual feedback across all long-running operations (#45)", - "Fixes: Improved spinner formatting for better readability" - ], - "breaking_changes": [], - "contributors": "N/A" -} - Generate the release notes now following these instructions to the letter.` ) @@ -936,16 +312,17 @@ func GetPRPromptTemplate(lang string) string { // GetCommitPromptTemplate returns the commit template based on language and whether there is a ticket func GetCommitPromptTemplate(lang string, hasTicket bool) string { - switch { - case lang == "es" && hasTicket: - return promptTemplateWithTicketES - case lang == "es" && !hasTicket: + if lang == "es" { + if hasTicket { + return promptTemplateWithTicketES + } return promptTemplateWithoutTicketES - case hasTicket: + } + + if hasTicket { return promptTemplateWithTicketEN - default: - return promptTemplateWithoutTicketEN } + return promptTemplateWithoutTicketEN } // GetReleasePromptTemplate returns the release template based on the language diff --git a/internal/ai/prompts_test.go b/internal/ai/prompts_test.go index 0c95a1a..2be6807 100644 --- a/internal/ai/prompts_test.go +++ b/internal/ai/prompts_test.go @@ -107,19 +107,7 @@ func TestGetPRPromptTemplate(t *testing.T) { assert.Contains(t, result, "Senior Tech Lead") assert.Contains(t, result, "No Hallucinations") assert.Contains(t, result, "first person") - assert.Contains(t, result, "valid JSON") assert.NotContains(t, result, "español") - // Verify strict JSON schema elements - assert.Contains(t, result, "STRICT OUTPUT FORMAT") - assert.Contains(t, result, "JSON Schema") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "Type Rules") - assert.Contains(t, result, "Prohibited Actions") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"body\"") - assert.Contains(t, result, "\"labels\"") - assert.Contains(t, result, "MUST be string") - assert.Contains(t, result, "MUST be array of strings") }) t.Run("Spanish template contains key instructions in Spanish", func(t *testing.T) { @@ -128,19 +116,7 @@ func TestGetPRPromptTemplate(t *testing.T) { assert.Contains(t, result, "Desarrollador Senior") assert.Contains(t, result, "Cero alucinaciones") assert.Contains(t, result, "primera persona") - assert.Contains(t, result, "JSON crudo") assert.Contains(t, result, "ESPAÑOL") - // Verify strict JSON schema elements in Spanish - assert.Contains(t, result, "FORMATO DE SALIDA ESTRICTO") - assert.Contains(t, result, "Schema JSON") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "Reglas de Tipos") - assert.Contains(t, result, "Acciones Prohibidas") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"body\"") - assert.Contains(t, result, "\"labels\"") - assert.Contains(t, result, "DEBE ser string") - assert.Contains(t, result, "DEBE ser array de strings") }) t.Run("Unknown language defaults to English", func(t *testing.T) { @@ -157,20 +133,7 @@ func TestGetCommitPromptTemplate(t *testing.T) { assert.Contains(t, result, "Ticket/Issue") assert.Contains(t, result, "Requirements Validation") - assert.Contains(t, result, "completed_indices") assert.Contains(t, result, "Conventional Commits") - // Verify strict JSON schema elements - assert.Contains(t, result, "STRICT OUTPUT FORMAT") - assert.Contains(t, result, "JSON Schema") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "Type Rules") - assert.Contains(t, result, "Prohibited Actions") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"desc\"") - assert.Contains(t, result, "\"files\"") - assert.Contains(t, result, "\"requirements\"") - assert.Contains(t, result, "completed_indices") - assert.Contains(t, result, "MUST be array of integers") }) t.Run("English without ticket does not mention requirements", func(t *testing.T) { @@ -179,15 +142,6 @@ func TestGetCommitPromptTemplate(t *testing.T) { assert.NotContains(t, result, "Requirements Validation") assert.NotContains(t, result, "completed_indices") assert.Contains(t, result, "Git Specialist") - // Verify strict JSON schema elements (but no requirements field) - assert.Contains(t, result, "STRICT OUTPUT FORMAT") - assert.Contains(t, result, "JSON Schema") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"desc\"") - assert.Contains(t, result, "\"files\"") - assert.Contains(t, result, "\"analysis\"") - assert.NotContains(t, result, "\"requirements\"") }) t.Run("Spanish with ticket contains Spanish instructions", func(t *testing.T) { @@ -195,32 +149,14 @@ func TestGetCommitPromptTemplate(t *testing.T) { assert.Contains(t, result, "Ticket/Issue") assert.Contains(t, result, "Validación de Requerimientos") - assert.Contains(t, result, "ESPAÑOL") - assert.Contains(t, result, "completed_indices") - // Verify strict JSON schema elements in Spanish - assert.Contains(t, result, "FORMATO DE SALIDA ESTRICTO") - assert.Contains(t, result, "Schema JSON") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "Reglas de Tipos") - assert.Contains(t, result, "Acciones Prohibidas") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"desc\"") - assert.Contains(t, result, "\"requirements\"") - assert.Contains(t, result, "DEBE ser array de enteros") + assert.Contains(t, result, "Conventional Commits") }) t.Run("Spanish without ticket is in Spanish", func(t *testing.T) { result := GetCommitPromptTemplate("es", false) assert.Contains(t, result, "especialista en Git") - assert.Contains(t, result, "ESPAÑOL") assert.NotContains(t, result, "Ticket/Issue:") - // Verify strict JSON schema elements in Spanish (but no requirements) - assert.Contains(t, result, "FORMATO DE SALIDA ESTRICTO") - assert.Contains(t, result, "Schema JSON") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "\"analysis\"") - assert.NotContains(t, result, "\"requirements\"") }) } @@ -231,21 +167,6 @@ func TestGetReleasePromptTemplate(t *testing.T) { assert.Contains(t, result, "TECHNICAL NOISE FILTERING") assert.Contains(t, result, "INTELLIGENT GROUPING") assert.Contains(t, result, "Keep a Changelog") - assert.Contains(t, result, "highlights") - assert.Contains(t, result, "breaking_changes") - // Verify strict JSON schema elements - assert.Contains(t, result, "STRICT OUTPUT FORMAT") - assert.Contains(t, result, "JSON Schema") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "Type Rules") - assert.Contains(t, result, "Prohibited Actions") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"summary\"") - assert.Contains(t, result, "\"highlights\"") - assert.Contains(t, result, "\"breaking_changes\"") - assert.Contains(t, result, "\"contributors\"") - assert.Contains(t, result, "MUST be string") - assert.Contains(t, result, "MUST be array of strings") }) t.Run("Spanish template has Spanish instructions", func(t *testing.T) { @@ -255,20 +176,6 @@ func TestGetReleasePromptTemplate(t *testing.T) { assert.Contains(t, result, "AGRUPACIÓN INTELIGENTE") assert.Contains(t, result, "Keep a Changelog") assert.Contains(t, result, "ESPAÑOL ARGENTINO") - assert.Contains(t, result, "highlights") - // Verify strict JSON schema elements in Spanish - assert.Contains(t, result, "FORMATO DE SALIDA ESTRICTO") - assert.Contains(t, result, "Schema JSON") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "Reglas de Tipos") - assert.Contains(t, result, "Acciones Prohibidas") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"summary\"") - assert.Contains(t, result, "\"highlights\"") - assert.Contains(t, result, "\"breaking_changes\"") - assert.Contains(t, result, "\"contributors\"") - assert.Contains(t, result, "DEBE ser string") - assert.Contains(t, result, "DEBE ser array de strings") }) } @@ -281,17 +188,6 @@ func TestGetIssuePromptTemplate(t *testing.T) { assert.Contains(t, result, "Technical Details") assert.Contains(t, result, "Impact") assert.Contains(t, result, "No Emojis") - // Verify strict JSON schema elements - assert.Contains(t, result, "STRICT OUTPUT FORMAT") - assert.Contains(t, result, "JSON Schema") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "Type Rules") - assert.Contains(t, result, "Prohibited Actions") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"description\"") - assert.Contains(t, result, "\"labels\"") - assert.Contains(t, result, "MUST be string") - assert.Contains(t, result, "MUST be array of strings") }) t.Run("Spanish template is in Spanish", func(t *testing.T) { @@ -302,17 +198,6 @@ func TestGetIssuePromptTemplate(t *testing.T) { assert.Contains(t, result, "Detalles Técnicos") assert.Contains(t, result, "Impacto") assert.Contains(t, result, "ESPAÑOL") - // Verify strict JSON schema elements in Spanish - assert.Contains(t, result, "FORMATO DE SALIDA ESTRICTO") - assert.Contains(t, result, "Schema JSON") - assert.Contains(t, result, "additionalProperties") - assert.Contains(t, result, "Reglas de Tipos") - assert.Contains(t, result, "Acciones Prohibidas") - assert.Contains(t, result, "\"title\"") - assert.Contains(t, result, "\"description\"") - assert.Contains(t, result, "\"labels\"") - assert.Contains(t, result, "DEBE ser string") - assert.Contains(t, result, "DEBE ser array de strings") }) } @@ -667,8 +552,6 @@ func TestPromptRenderingWorkflow(t *testing.T) { assert.Contains(t, result, "AUTH-42") assert.Contains(t, result, "#42") assert.Contains(t, result, "Conventional Commits") - assert.Contains(t, result, "STRICT OUTPUT FORMAT") - assert.Contains(t, result, "JSON Schema") }) t.Run("Complete PR prompt with issues and template", func(t *testing.T) { @@ -697,7 +580,5 @@ func TestPromptRenderingWorkflow(t *testing.T) { assert.Contains(t, result, "Issue #11: Fix bug") assert.Contains(t, result, "## Changes") assert.Contains(t, result, "Closes #N") - assert.Contains(t, result, "STRICT OUTPUT FORMAT") - assert.Contains(t, result, "JSON Schema") }) } diff --git a/internal/commands/config/init.go b/internal/commands/config/init.go index f953a28..b63eb8a 100644 --- a/internal/commands/config/init.go +++ b/internal/commands/config/init.go @@ -69,9 +69,16 @@ func initConfigAction(cfg *config.Config, t *i18n.Translations) cli.ActionFunc { return errors.New(t.GetMessage("config_local.not_in_repo", 0, nil)) } - localCfg, err := config.CreateDefaultConfig(localPath) + localCfg, err := config.LoadConfig(localPath) if err != nil { - return fmt.Errorf("error creating local config: %w", err) + if os.IsNotExist(err) { + localCfg, err = config.CreateDefaultConfig(localPath) + if err != nil { + return fmt.Errorf("error creating local config: %w", err) + } + } else { + return fmt.Errorf("error loading local config: %w", err) + } } reader := bufio.NewReader(os.Stdin) diff --git a/internal/config/config.go b/internal/config/config.go index 89254e1..05f8e39 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -165,37 +165,26 @@ func GetRepoConfigPath() string { return filepath.Join(repoRoot, ".matecommit", "config.json") } -// LoadConfigWithHierarchy loads config with repository-local override support -// Priority: local (.matecommit/config.json) > global (~/.config/matecommit/config.json) +// LoadConfigWithHierarchy loads config with repository-local priority +// If in a git repo, uses ONLY local config (.matecommit/config.json) +// If not in a repo, falls back to global config (~/.config/matecommit/config.json) func LoadConfigWithHierarchy(globalPath string) (*Config, error) { - globalConfig, err := LoadConfig(globalPath) - if err != nil { - return nil, err - } - + // If in a git repo, use local config ONLY localPath := GetRepoConfigPath() - if localPath == "" { - return globalConfig, nil - } - - if _, err := os.Stat(localPath); os.IsNotExist(err) { - return globalConfig, nil - } - - data, err := os.ReadFile(localPath) - if err != nil { - return globalConfig, nil - } - - var localConfig Config - if err := json.Unmarshal(data, &localConfig); err != nil { - return globalConfig, nil + if localPath != "" { + cfg, err := LoadConfig(localPath) + if err != nil { + // If local config doesn't exist, create it + if os.IsNotExist(err) { + return CreateDefaultConfig(localPath) + } + return nil, err + } + return cfg, nil } - merged := MergeConfigs(globalConfig, &localConfig) - merged.PathFile = globalConfig.PathFile - - return merged, nil + // Fallback to global only if not in a repo + return LoadConfig(globalPath) } // MergeConfigs merges local config over global config diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index e7ffa11..78a40f7 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -267,6 +267,7 @@ config_vcs_updated = "VCS provider '{{.Provider}}' configuration updated success config_set_active_vcs_usage = "Set the active VCS provider" config_set_active_vcs_provider_usage = "Name of the VCS provider to set as active" config_active_vcs_updated = "Active VCS provider set to '{{.Provider}}'" +test_plan_generated = "✅ Test plan generated successfully" [pr_service] error_get_pr = "Error getting the PR: {{.Error}}" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index dc85a9f..54b303e 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -278,6 +278,7 @@ config_vcs_updated = "Configuración del proveedor VCS '{{.Provider}}' actualiza config_set_active_vcs_usage = "Establecer el proveedor VCS activo" config_set_active_vcs_provider_usage = "Nombre del proveedor VCS a establecer como activo" config_active_vcs_updated = "Proveedor VCS activo establecido a '{{.Provider}}'" +test_plan_generated = "✅ Plan de pruebas generado exitosamente" [pr_service] error_get_pr = "Error al obtener el PR: {{.Error}}"