diff --git a/.github/ISSUE_TEMPLATE/custom.yml b/.github/ISSUE_TEMPLATE/custom.yml index 7a40197..6a3f8fb 100644 --- a/.github/ISSUE_TEMPLATE/custom.yml +++ b/.github/ISSUE_TEMPLATE/custom.yml @@ -1,21 +1,21 @@ body: - attributes: - value: Describí el tema que querés reportar o discutir. + value: Describe the topic you want to report or discuss. type: markdown - attributes: - description: Contame de qué se trata - label: Descripción - placeholder: Quisiera proponer/reportar/discutir... + description: Tell me what it's about + label: Description + placeholder: I would like to propose/report/discuss... id: description type: textarea validations: required: true - attributes: - description: Cualquier detalle extra que sea relevante - label: Información adicional + description: Any additional details that are relevant + label: Additional information id: additional type: textarea -description: Crea una issue personalizada +description: Create a custom issue labels: [] -name: Issue Personalizada +name: Custom Issue title: '[ISSUE] ' diff --git a/.github/ISSUE_TEMPLATE/dependency.yml b/.github/ISSUE_TEMPLATE/dependency.yml new file mode 100644 index 0000000..6d2f03f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dependency.yml @@ -0,0 +1,17 @@ +body: + - attributes: + label: Package Name + id: package + type: input + validations: + required: true + - attributes: + description: Security fix, new features, etc. + label: Reason for update + id: reason + type: textarea +description: Update a project dependency +labels: + - dependencies +name: Dependency Update +title: '[DEPENDENCY] ' diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..24f6735 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,18 @@ +body: + - attributes: + description: What needs to be documented or improved? + label: Description + id: description + type: textarea + validations: + required: true + - attributes: + description: Where should this check go? + label: Relevant files/sections + id: location + type: textarea +description: Improvements or additions to documentation +labels: + - documentation +name: Documentation +title: '[DOCS] ' diff --git a/.github/ISSUE_TEMPLATE/performance.yml b/.github/ISSUE_TEMPLATE/performance.yml new file mode 100644 index 0000000..846cb45 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/performance.yml @@ -0,0 +1,29 @@ +body: + - attributes: + value: Thanks for helping us make things faster! Please describe the performance issue in detail. + type: markdown + - attributes: + description: What is slow or inefficient? + label: Description + placeholder: The dashboard takes 5 seconds to load... + id: description + type: textarea + validations: + required: true + - attributes: + description: e.g., Response time, CPU usage, Memory + label: Metric (optional) + placeholder: 500ms -> 2s + id: metric + type: input + - attributes: + description: How can we observe this? + label: Steps to reproduce + id: repro + type: textarea +description: Report a performance issue or inefficiency +labels: + - performance + - optimization +name: Performance Issue +title: '[PERF] ' diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..d15eab8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,13 @@ +body: + - attributes: + description: What would you like to know? + label: Question + id: question + type: textarea + validations: + required: true +description: Ask a question about the project +labels: + - question +name: Question +title: '[QUESTION] ' diff --git a/.github/ISSUE_TEMPLATE/security.yml b/.github/ISSUE_TEMPLATE/security.yml new file mode 100644 index 0000000..8c4e393 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security.yml @@ -0,0 +1,22 @@ +body: + - attributes: + value: '**IMPORTANT:** Please do not disclose security vulnerabilities publicly until they have been addressed.' + type: markdown + - attributes: + description: Describe the security issue. + label: Vulnerability Description + id: description + type: textarea + validations: + required: true + - attributes: + description: What is the potential impact of this vulnerability? + label: Impact + id: impact + type: textarea +description: Report a security vulnerability +labels: + - security + - critical +name: Security Vulnerability +title: '[SECURITY] ' diff --git a/.github/ISSUE_TEMPLATE/tech_debt.yml b/.github/ISSUE_TEMPLATE/tech_debt.yml new file mode 100644 index 0000000..dc9f0e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/tech_debt.yml @@ -0,0 +1,19 @@ +body: + - attributes: + description: What code needs refactoring? + label: Description + id: description + type: textarea + validations: + required: true + - attributes: + description: Why should we do this? (e.g. readability, maintainability) + label: Reason + id: reason + type: textarea +description: Propose a refactoring or technical improvement +labels: + - refactor + - tech-debt +name: Tech Debt / Refactor +title: '[REFACTOR] ' diff --git a/cmd/main.go b/cmd/main.go index d5c2d19..ffc835a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -134,14 +134,15 @@ func initializeApp() (*cli.Command, error) { services.WithConfig(cfgApp), ) + templateService := services.NewIssueTemplateService( + services.WithTemplateConfig(cfgApp), + ) + prService := services.NewPRService( services.WithPRVCSClient(vcsClient), services.WithPRAIProvider(prAI), services.WithPRConfig(cfgApp), - ) - - templateService := services.NewIssueTemplateService( - services.WithTemplateConfig(cfgApp), + services.WithPRTemplateService(templateService), ) issueService := services.NewIssueGeneratorService( diff --git a/internal/ai/cost_wrapper_test.go b/internal/ai/cost_wrapper_test.go index c79d862..efa5788 100644 --- a/internal/ai/cost_wrapper_test.go +++ b/internal/ai/cost_wrapper_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" + "github.com/stretchr/testify/mock" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/services/cost" - "github.com/stretchr/testify/mock" ) type mockProvider struct { diff --git a/internal/ai/gemini/commit_summarizer_service_test.go b/internal/ai/gemini/commit_summarizer_service_test.go index 36d6074..74708ed 100644 --- a/internal/ai/gemini/commit_summarizer_service_test.go +++ b/internal/ai/gemini/commit_summarizer_service_test.go @@ -6,9 +6,9 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/models" - "github.com/stretchr/testify/assert" "google.golang.org/genai" ) @@ -207,7 +207,7 @@ 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, "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") diff --git a/internal/ai/gemini/issue_content_generator.go b/internal/ai/gemini/issue_content_generator.go index 5118269..925f39c 100644 --- a/internal/ai/gemini/issue_content_generator.go +++ b/internal/ai/gemini/issue_content_generator.go @@ -67,7 +67,8 @@ 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, "application/json") + // Don't force JSON mode - let the model return structured text + genConfig := GetGenerateConfig(mName, "") resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) if err != nil { @@ -102,18 +103,39 @@ func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context, var responseText string if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok { + log.Debug("formatResponse received GenerateContentResponse", + "candidates_count", len(geminiResp.Candidates)) responseText = formatResponse(geminiResp) + if len(responseText) > 0 { + preview := responseText + if len(responseText) > 100 { + preview = responseText[:100] + } + log.Debug("formatResponse result", + "response_length", len(responseText), + "response_preview", preview) + } else { + log.Debug("formatResponse result empty") + } } else if str, ok := resp.(string); ok { responseText = str + log.Debug("received string response", "length", len(str)) + } else if respMap, ok := resp.(map[string]interface{}); ok { + log.Debug("received map response from cache, extracting text") + responseText = extractTextFromMap(respMap) + log.Debug("extracted text from map", "length", len(responseText)) + } else { + log.Warn("unexpected response type", "type", fmt.Sprintf("%T", resp)) } if responseText == "" { - log.Error("empty response from gemini AI") + log.Error("empty response from gemini AI after format") return nil, domainErrors.NewAppError(domainErrors.TypeAI, "empty response from AI", nil) } log.Debug("gemini response received", - "response_length", len(responseText)) + "response_length", len(responseText), + "response_text", responseText) result, err := s.parseIssueResponse(responseText) if err != nil { @@ -131,8 +153,79 @@ func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context, return result, nil } +// extractTextFromMap extracts text from a cached response that was deserialized from JSON. +// The map structure mirrors genai.GenerateContentResponse but as map[string]interface{}. +// JSON tags use lowercase keys: "candidates", "content", "parts", "text", "thought" +func extractTextFromMap(respMap map[string]interface{}) string { + var result strings.Builder + + // Navigate: respMap["candidates"] -> []interface{} of candidates + candidates, ok := respMap["candidates"] + if !ok { + return "" + } + + candidatesList, ok := candidates.([]interface{}) + if !ok { + return "" + } + + for _, cand := range candidatesList { + candMap, ok := cand.(map[string]interface{}) + if !ok { + continue + } + + // Navigate: candMap["content"] -> map with parts + content, ok := candMap["content"] + if !ok { + continue + } + + contentMap, ok := content.(map[string]interface{}) + if !ok { + continue + } + + // Navigate: contentMap["parts"] -> []interface{} of parts + parts, ok := contentMap["parts"] + if !ok { + continue + } + + partsList, ok := parts.([]interface{}) + if !ok { + continue + } + + for _, part := range partsList { + partMap, ok := part.(map[string]interface{}) + if !ok { + continue + } + + // Check if this is a thinking part (skip it) + if thought, ok := partMap["thought"].(bool); ok && thought { + continue + } + + // Extract text from part + if text, ok := partMap["text"].(string); ok && text != "" { + result.WriteString(text) + } + } + } + + return result.String() +} + // buildIssuePrompt builds the prompt to generate issue content. func (s *GeminiIssueContentGenerator) buildIssuePrompt(request models.IssueGenerationRequest) string { + if request.Description != "" && request.Diff == "" && request.Hint == "" && + request.Template == nil && len(request.ChangedFiles) == 0 { + return request.Description + } + var sb strings.Builder if request.Description != "" { @@ -159,6 +252,14 @@ func (s *GeminiIssueContentGenerator) buildIssuePrompt(request models.IssueGener sb.WriteString(fmt.Sprintf("User Hint: %s\n\n", request.Hint)) } + if request.Template != nil { + lang := request.Language + if lang == "" { + lang = "en" + } + sb.WriteString(ai.FormatTemplateForPrompt(request.Template, lang, "issue")) + } + templateStr := ai.GetIssuePromptTemplate(request.Language) data := ai.PromptData{ IssueInfo: sb.String(), @@ -169,17 +270,63 @@ func (s *GeminiIssueContentGenerator) buildIssuePrompt(request models.IssueGener return "" } + if request.Template != nil { + rendered += ` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +🚨 FINAL REMINDER - CRITICAL OUTPUT REQUIREMENT 🚨 + +YOU MUST OUTPUT **ONLY** VALID JSON. + +The template structure above should be used to FILL the "description" field with markdown content. + +BUT your actual response MUST be a JSON object like this: +{ + "title": "string here", + "description": "markdown content following the template structure", + "labels": ["array", "of", "strings"] +} + +❌ DO NOT output prose like "Here is a high-quality GitHub issue..." +❌ DO NOT output markdown text directly +❌ DO NOT output explanations + +✅ ONLY output the JSON object +✅ Use the template to structure the markdown in the "description" field +✅ Return valid parseable JSON + +BEGIN YOUR JSON OUTPUT NOW:` + + logger.Debug(context.Background(), "full prompt with template and final JSON reminder", + "prompt_length", len(rendered), + "prompt", rendered) + } + return rendered } // parseIssueResponse parses the Gemini JSON response. func (s *GeminiIssueContentGenerator) parseIssueResponse(content string) (*models.IssueGenerationResult, error) { if content == "" { + logger.Error(context.Background(), "received empty response from Gemini AI", nil) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "empty response from AI", nil) } + if len(content) > 0 { + preview := content + if len(content) > 200 { + preview = content[:200] + } + 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) + var jsonResult struct { Title string `json:"title"` Description string `json:"description"` @@ -187,6 +334,9 @@ func (s *GeminiIssueContentGenerator) parseIssueResponse(content string) (*model } if err := json.Unmarshal([]byte(content), &jsonResult); err != nil { + logger.Warn(context.Background(), "failed to unmarshal JSON, using fallback", + "error", err.Error(), + "content", content) return &models.IssueGenerationResult{ Title: "Generated Issue", Description: content, @@ -194,6 +344,11 @@ func (s *GeminiIssueContentGenerator) parseIssueResponse(content string) (*model }, nil } + logger.Debug(context.Background(), "successfully parsed JSON", + "title", jsonResult.Title, + "description_length", len(jsonResult.Description), + "labels_count", len(jsonResult.Labels)) + result := &models.IssueGenerationResult{ Title: strings.TrimSpace(jsonResult.Title), Description: strings.TrimSpace(jsonResult.Description), diff --git a/internal/ai/gemini/issue_content_generator_test.go b/internal/ai/gemini/issue_content_generator_test.go index 333d7de..6530d7d 100644 --- a/internal/ai/gemini/issue_content_generator_test.go +++ b/internal/ai/gemini/issue_content_generator_test.go @@ -5,9 +5,9 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/models" - "github.com/stretchr/testify/assert" "google.golang.org/genai" ) @@ -59,7 +59,7 @@ func TestBuildIssuePrompt(t *testing.T) { Description: "user description", Language: "en", }, - contains: []string{"Global Description: user description"}, + contains: []string{"user description"}, }, { name: "full request", @@ -83,6 +83,136 @@ func TestBuildIssuePrompt(t *testing.T) { } } +func TestBuildIssuePrompt_WithTemplate(t *testing.T) { + cfg := &config.Config{} + gen := &GeminiIssueContentGenerator{ + config: cfg, + } + + t.Run("adds final JSON reminder when template is present", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "Bug Report", + Title: "Bug: {{title}}", + BodyContent: "## Description\n{{description}}", + } + + request := models.IssueGenerationRequest{ + Diff: "test diff", + Template: template, + Language: "en", + } + + prompt := gen.buildIssuePrompt(request) + + // Should contain the template + assert.Contains(t, prompt, "Bug Report") + + // Should contain the final JSON reminder + assert.Contains(t, prompt, "🚨 FINAL REMINDER - CRITICAL OUTPUT REQUIREMENT 🚨") + assert.Contains(t, prompt, "YOU MUST OUTPUT **ONLY** VALID JSON") + assert.Contains(t, prompt, "BEGIN YOUR JSON OUTPUT NOW:") + + // Should contain instructions about using template in description field + assert.Contains(t, prompt, "The template structure above should be used to FILL the \"description\" field") + + // Should contain prohibitions + assert.Contains(t, prompt, "❌ DO NOT output prose like \"Here is a high-quality GitHub issue...\"") + assert.Contains(t, prompt, "❌ DO NOT output markdown text directly") + + // Verify the reminder is at the end + lastIndex := len(prompt) - 500 + if lastIndex < 0 { + lastIndex = 0 + } + finalSection := prompt[lastIndex:] + assert.Contains(t, finalSection, "BEGIN YOUR JSON OUTPUT NOW:") + }) + + t.Run("does NOT add final reminder when no template", func(t *testing.T) { + request := models.IssueGenerationRequest{ + Diff: "test diff", + Template: nil, + Language: "en", + } + + prompt := gen.buildIssuePrompt(request) + + // Should NOT contain the final JSON reminder + assert.NotContains(t, prompt, "🚨 FINAL REMINDER - CRITICAL OUTPUT REQUIREMENT 🚨") + assert.NotContains(t, prompt, "BEGIN YOUR JSON OUTPUT NOW:") + }) + + t.Run("includes template in Spanish", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "Reporte de Bug", + Title: "Bug: {{title}}", + BodyContent: "## Descripción\n{{description}}", + } + + request := models.IssueGenerationRequest{ + Description: "descripción del problema", + Template: template, + Language: "es", + } + + prompt := gen.buildIssuePrompt(request) + + // Should contain the template + assert.Contains(t, prompt, "Reporte de Bug") + + // Should still contain the final JSON reminder (in English for consistency) + assert.Contains(t, prompt, "🚨 FINAL REMINDER - CRITICAL OUTPUT REQUIREMENT 🚨") + assert.Contains(t, prompt, "BEGIN YOUR JSON OUTPUT NOW:") + }) + + t.Run("handles template with all fields", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "Feature Request", + Title: "Feature: {{title}}", + BodyContent: "## Problem\n{{problem}}\n## Solution\n{{solution}}", + Labels: []string{"enhancement", "feature"}, + } + + request := models.IssueGenerationRequest{ + Diff: "test diff", + Template: template, + Language: "en", + ChangedFiles: []string{"main.go", "test.go"}, + } + + prompt := gen.buildIssuePrompt(request) + + // Should contain template information + assert.Contains(t, prompt, "Feature Request") + + // Should contain changed files + assert.Contains(t, prompt, "main.go") + assert.Contains(t, prompt, "test.go") + + // Should contain the final reminder + assert.Contains(t, prompt, "🚨 FINAL REMINDER - CRITICAL OUTPUT REQUIREMENT 🚨") + assert.Contains(t, prompt, "BEGIN YOUR JSON OUTPUT NOW:") + }) + + t.Run("reminder contains complete JSON structure example", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "Test Template", + } + + request := models.IssueGenerationRequest{ + Template: template, + Language: "en", + } + + prompt := gen.buildIssuePrompt(request) + + // Should show the expected JSON structure + assert.Contains(t, prompt, `"title": "string here"`) + assert.Contains(t, prompt, `"description": "markdown content following the template structure"`) + assert.Contains(t, prompt, `"labels": ["array", "of", "strings"]`) + }) +} + func TestParseIssueResponse(t *testing.T) { gen := &GeminiIssueContentGenerator{} @@ -170,6 +300,108 @@ func TestCleanLabels(t *testing.T) { } } +func TestExtractTextFromMap(t *testing.T) { + t.Run("extracts text from valid map structure", func(t *testing.T) { + respMap := map[string]interface{}{ + "candidates": []interface{}{ + map[string]interface{}{ + "content": map[string]interface{}{ + "parts": []interface{}{ + map[string]interface{}{ + "text": "This is the response", + "thought": false, + }, + }, + }, + }, + }, + } + + result := extractTextFromMap(respMap) + assert.Equal(t, "This is the response", result) + }) + + t.Run("skips thinking parts", func(t *testing.T) { + respMap := map[string]interface{}{ + "candidates": []interface{}{ + map[string]interface{}{ + "content": map[string]interface{}{ + "parts": []interface{}{ + map[string]interface{}{ + "text": "This is thinking", + "thought": true, + }, + map[string]interface{}{ + "text": "This is the actual response", + "thought": false, + }, + }, + }, + }, + }, + } + + result := extractTextFromMap(respMap) + assert.Equal(t, "This is the actual response", result) + }) + + t.Run("handles multiple candidates and parts", func(t *testing.T) { + respMap := map[string]interface{}{ + "candidates": []interface{}{ + map[string]interface{}{ + "content": map[string]interface{}{ + "parts": []interface{}{ + map[string]interface{}{ + "text": "Part 1", + }, + map[string]interface{}{ + "text": " Part 2", + }, + }, + }, + }, + }, + } + + result := extractTextFromMap(respMap) + assert.Equal(t, "Part 1 Part 2", result) + }) + + t.Run("returns empty string for missing candidates key", func(t *testing.T) { + respMap := map[string]interface{}{} + result := extractTextFromMap(respMap) + assert.Equal(t, "", result) + }) + + t.Run("returns empty string for invalid structure", func(t *testing.T) { + respMap := map[string]interface{}{ + "candidates": "not an array", + } + result := extractTextFromMap(respMap) + assert.Equal(t, "", result) + }) + + t.Run("skips malformed candidates gracefully", func(t *testing.T) { + respMap := map[string]interface{}{ + "candidates": []interface{}{ + "invalid candidate", + map[string]interface{}{ + "content": map[string]interface{}{ + "parts": []interface{}{ + map[string]interface{}{ + "text": "Valid text", + }, + }, + }, + }, + }, + } + + result := extractTextFromMap(respMap) + assert.Equal(t, "Valid text", result) + }) +} + func TestGenerateIssueContent_HappyPath(t *testing.T) { // Setup temp home tmpHome, err := os.MkdirTemp("", "mate-commit-test-issue-*") diff --git a/internal/ai/gemini/pull_requests_summarizer_service_test.go b/internal/ai/gemini/pull_requests_summarizer_service_test.go index 09c8e79..3f04bd4 100644 --- a/internal/ai/gemini/pull_requests_summarizer_service_test.go +++ b/internal/ai/gemini/pull_requests_summarizer_service_test.go @@ -5,9 +5,9 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/models" - "github.com/stretchr/testify/assert" "google.golang.org/genai" ) diff --git a/internal/ai/gemini/release_generator_test.go b/internal/ai/gemini/release_generator_test.go index e7b0818..3cb081b 100644 --- a/internal/ai/gemini/release_generator_test.go +++ b/internal/ai/gemini/release_generator_test.go @@ -6,9 +6,9 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/models" - "github.com/stretchr/testify/assert" "google.golang.org/genai" ) diff --git a/internal/ai/prompts.go b/internal/ai/prompts.go index 68657fd..4f5476e 100644 --- a/internal/ai/prompts.go +++ b/internal/ai/prompts.go @@ -76,22 +76,61 @@ const ( # 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 ` + "```json" + `). + 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). - # Output Format - Respond with ONLY valid JSON (no markdown): + # 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": "PR title", - "body": "Detailed markdown body with:\n- Overview\n- Key changes\n- Technical impact", - "labels": ["label1", "label2"] + "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.` + Generate the summary now. Return ONLY the JSON object, nothing else.` prPromptTemplateES = `# Tarea Actuá como un Desarrollador Senior y genera un resumen del Pull Request. @@ -109,17 +148,55 @@ const ( 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 + # 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) - Responde SOLO con JSON válido (sin markdown): + ## 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": "título del PR", - "body": "cuerpo detallado en markdown con:\n- Resumen (qué hice y por qué)\n- Cambios clave\n- Impacto técnico", - "labels": ["etiqueta1", "etiqueta2"] + "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.` + Genera el resumen ahora. Devuelve SOLO el objeto JSON, nada más.` ) const ( @@ -150,29 +227,110 @@ const ( - 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. - # Output Format - Respond with ONLY valid JSON array (no markdown). + # 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": "type(scope): short message (#N)", - "desc": "detailed technical explanation in first person", - "files": ["file1.go", "file2.go"], + "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": "brief summary", - "purpose": "main goal", - "impact": "technical impact" + "overview": "Added null check for token", + "purpose": "Prevent panic when token is null", + "impact": "Improves error handling" }, "requirements": { - "status": "full_met | partially_met | not_met", - "missing": ["missing test", "missing doc"], - "completed_indices": [0, 2], - "suggestions": ["improvement 1", "improvement 2"] + "status": "full_met", + "missing": [], + "completed_indices": [0, 1], + "suggestions": [] } } ] - Generate {{.Count}} suggestions now.` + Generate {{.Count}} suggestions now. Return ONLY the JSON array, nothing else.` promptTemplateWithTicketES = `# Tarea Actuá como un especialista en Git y genera {{.Count}} sugerencias de commits. @@ -199,31 +357,111 @@ const ( - 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 + # 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. - EXCEPTO el campo "status" que debe ser uno de los valores permitidos exactos. JSON crudo, sin markdown. + + ## 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 + } + } - Responde SOLO con este array JSON: + ## 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": "tipo(scope): mensaje corto (#N)", - "desc": "explicación técnica detallada y natural", - "files": ["archivo_modificado.go"], + "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": "qué cambiaste", - "purpose": "para qué lo cambiaste", - "impact": "qué mejora esto" + "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 | partially_met | not_met", - "missing": ["falta test", "falta doc"], - "completed_indices": [0], - "suggestions": ["agregar test de integración"] + "status": "full_met", + "missing": [], + "completed_indices": [0, 1], + "suggestions": [] } } ] - Generate {{.Count}} suggestions now.` + Genera {{.Count}} sugerencias ahora. Devuelve SOLO el array JSON, nada más.` ) const ( @@ -252,26 +490,80 @@ const ( - ❌ "se corrigió el error" (Voz pasiva, muy robótico) - ✅ "fix(cli): corrijo panic al no tener config" (Bien) - # Formato de Salida - IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. JSON crudo, sin markdown. + # 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 + } + } - Responde SOLO con un array JSON válido: + ## 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": "tipo(scope): mensaje", - "desc": "explicación clara en primera persona", - "files": ["archivo1.go", "archivo2.go"], + "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": "resumen breve", - "purpose": "objetivo principal", - "impact": "impacto técnico" + "overview": "Agregué validación de configuración", + "purpose": "Prevenir panic cuando falta config", + "impact": "Mejora la robustez del CLI" } } ] {{.TechnicalInfo}} - Generate {{.Count}} suggestions now.` + Genera {{.Count}} sugerencias ahora. Devuelve SOLO el array JSON, nada más.` promptTemplateWithoutTicketEN = `# Task Act as a Git Specialist and generate {{.Count}} commit message suggestions based on code changes. @@ -298,25 +590,79 @@ const ( - ❌ "error was fixed" (Passive voice) - ✅ "fix(cli): handle panic when config is missing" (Perfect) - # Output Format - Respond with ONLY valid JSON array (no markdown). + # 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": "type(scope): message", - "desc": "detailed explanation in first person", - "files": ["file1.go", "file2.go"], + "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": "brief summary", - "purpose": "main goal", - "impact": "technical impact" + "overview": "Added configuration validation", + "purpose": "Prevent panic when config is missing", + "impact": "Improves CLI robustness" } } ] {{.TechnicalInfo}} - Generate {{.Count}} suggestions now.` + Generate {{.Count}} suggestions now. Return ONLY the JSON array, nothing else.` ) const ( @@ -382,32 +728,60 @@ Cada release debe contar una historia: - **Highlights:** Agrupar por tema (UX, Automatización, Performance, etc.) - Cada highlight debe responder: "¿Qué ganó el usuario con esto?" -## 5. FORMATO DE SALIDA -IMPORTANTE: TODO en español. JSON válido sin markdown. +## 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): { - "title": "Título conciso y descriptivo (ej: 'Mejoras de UX y Automatización')", - "summary": "2-3 oraciones explicando el foco del release en primera persona plural. Debe dar contexto de por qué estos cambios importan.", - "highlights": [ - "Highlight 1: Agrupación de features relacionadas con explicación de valor", - "Highlight 2: Otra mejora importante", - "Highlight 3: Correcciones relevantes" - ], - "breaking_changes": ["Descripción clara del breaking change y cómo migrar" (o [] si no hay)], - "contributors": "Gracias a @user1, @user2" o "N/A" + "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 } -# Ejemplo de Calidad Esperada - -**Input (commits crudos):** -- feat: add spinners to long operations -- feat: add colors to output -- feat: improve visual feedback -- feat(mock): implement GetIssue in MockVCSClient -- fix: correct spinner formatting -- chore: update dependencies - -**Output esperado:** +## 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.", @@ -482,32 +856,60 @@ Each release should tell a story: - **Highlights:** Group by theme (UX, Automation, Performance, etc.) - Each highlight should answer: "What did the user gain from this?" -## 5. OUTPUT FORMAT -IMPORTANT: Everything in English. Valid JSON without markdown. +## 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): { - "title": "Concise and descriptive title (e.g., 'UX Improvements and Automation')", - "summary": "2-3 sentences explaining the release focus in first person plural. Should provide context on why these changes matter.", - "highlights": [ - "Highlight 1: Grouping of related features with value explanation", - "Highlight 2: Another important improvement", - "Highlight 3: Relevant fixes" - ], - "breaking_changes": ["Clear description of breaking change and how to migrate" (or [] if none)], - "contributors": "Thanks to @user1, @user2" or "N/A" + "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 } -# Expected Quality Example - -**Input (raw commits):** -- feat: add spinners to long operations -- feat: add colors to output -- feat: improve visual feedback -- feat(mock): implement GetIssue in MockVCSClient -- fix: correct spinner formatting -- chore: update dependencies - -**Expected output:** +## 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.", @@ -566,6 +968,139 @@ func GetIssueReferenceInstructions(lang string) string { } } +const ( + templateInstructionsES = `## Template del Proyecto + +El proyecto tiene un template específico. DEBES seguir su estructura y formato al generar el contenido. + +IMPORTANTE: Generá el contenido siguiendo la estructura y formato mostrado en el template arriba. Completá cada sección basándote en los cambios de código y el contexto proporcionado. + +⚠️ CRÍTICO: A pesar del template arriba, tu respuesta DEBE SER JSON válido siguiendo el schema exacto definido en este prompt. El contenido del template debe incorporarse en el campo "description" como texto markdown, pero la respuesta general DEBE ser un objeto JSON con los campos "title", "description" y "labels". NO generes markdown o prosa - SOLO genera JSON válido.` + + templateInstructionsEN = `## Project Template + +The project has a specific template. You MUST follow its structure and format when generating the content. + +IMPORTANT: Generate the content following the structure and format shown in the template above. Fill in each section based on the code changes and context provided. + +⚠️ CRITICAL: Despite the template above, your response MUST STILL be valid JSON following the exact schema defined in this prompt. The template content should be incorporated into the "description" field as markdown text, but the overall response MUST be a JSON object with "title", "description", and "labels" fields. Do NOT output markdown or prose - ONLY output valid JSON.` + + prTemplateInstructionsES = `## Template de PR del Proyecto + +El proyecto tiene un template específico de PR. DEBES seguir su estructura y formato al generar la descripción del PR. + +IMPORTANTE: Generá la descripción del PR siguiendo la estructura y formato mostrado en el template arriba. Completá cada sección basándote en los cambios de código y el contexto proporcionado.` + + prTemplateInstructionsEN = `## Project PR Template + +The project has a specific PR template. You MUST follow its structure and format when generating the PR description. + +IMPORTANT: Generate the PR description following the structure and format shown in the template above. Fill in each section based on the code changes and context provided.` +) + +// GetTemplateInstructions returns template instructions based on the language +func GetTemplateInstructions(lang string) string { + switch lang { + case "es": + return templateInstructionsES + default: + return templateInstructionsEN + } +} + +// GetPRTemplateInstructions returns PR template instructions based on the language +func GetPRTemplateInstructions(lang string) string { + switch lang { + case "es": + return prTemplateInstructionsES + default: + return prTemplateInstructionsEN + } +} + +// FormatTemplateForPrompt formats a template for inclusion in an AI prompt. +// It handles both Issue and PR templates with proper language support. +func FormatTemplateForPrompt(template *models.IssueTemplate, lang string, templateType string) string { + if template == nil { + return "" + } + + if lang == "" { + lang = "en" + } + + var sb strings.Builder + isIssue := templateType == "issue" + + if lang == "es" { + if isIssue { + sb.WriteString("## Template de Issue del Proyecto\n\n") + sb.WriteString("El proyecto tiene un template específico de issue. DEBES seguir su estructura y formato al generar el contenido del issue.\n\n") + } else { + sb.WriteString("## Template de PR del Proyecto\n\n") + sb.WriteString("El proyecto tiene un template específico de PR. DEBES seguir su estructura y formato al generar la descripción del PR.\n\n") + } + } else { + if isIssue { + sb.WriteString("## Project Issue Template\n\n") + sb.WriteString("The project has a specific issue template. You MUST follow its structure and format when generating the issue content.\n\n") + } else { + sb.WriteString("## Project PR Template\n\n") + sb.WriteString("The project has a specific PR template. You MUST follow its structure and format when generating the PR description.\n\n") + } + } + + if template.Name != "" { + if lang == "es" { + sb.WriteString(fmt.Sprintf("Nombre del Template: %s\n", template.Name)) + } else { + sb.WriteString(fmt.Sprintf("Template Name: %s\n", template.Name)) + } + } + + if template.GetAbout() != "" { + if lang == "es" { + sb.WriteString(fmt.Sprintf("Descripción del Template: %s\n", template.GetAbout())) + } else { + sb.WriteString(fmt.Sprintf("Template Description: %s\n", template.GetAbout())) + } + } + + if template.BodyContent != "" { + if lang == "es" { + sb.WriteString("\nEstructura del Template:\n```markdown\n") + } else { + sb.WriteString("\nTemplate Structure:\n```markdown\n") + } + sb.WriteString(template.BodyContent) + sb.WriteString("\n```\n\n") + if isIssue { + sb.WriteString(GetTemplateInstructions(lang)) + } else { + sb.WriteString(GetPRTemplateInstructions(lang)) + } + sb.WriteString("\n\n") + } else if template.Body != nil { + if lang == "es" { + if isIssue { + sb.WriteString("\nTipo de Template: GitHub Issue Form (YAML)\n") + } else { + sb.WriteString("\nTipo de Template: GitHub PR Template (YAML/Markdown)\n") + } + sb.WriteString("El template define campos específicos. Generá contenido que coincida con la estructura esperada.\n\n") + } else { + if isIssue { + sb.WriteString("\nTemplate Type: GitHub Issue Form (YAML)\n") + } else { + sb.WriteString("\nTemplate Type: GitHub PR Template (YAML/Markdown)\n") + } + sb.WriteString("The template defines specific fields. Generate content that matches the expected structure.\n\n") + } + } + + return sb.String() +} + const ( prIssueContextInstructionsES = ` **IMPORTANTE - Contexto de Issues/Tickets:** @@ -715,7 +1250,56 @@ func GetReleaseNotesSectionHeaders(locale string) map[string]string { } const ( - issuePromptTemplateEN = `# Task + issuePromptTemplateEN = `# 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. + ⚠️ DO NOT RETURN AN ARRAY. You MUST return a JSON OBJECT with exactly these fields: title, description, labels. + + ## JSON Schema (MANDATORY): + { + "type": "object", + "required": ["title", "description", "labels"], + "properties": { + "title": { + "type": "string", + "description": "Concise and descriptive title" + }, + "description": { + "type": "string", + "description": "Markdown body following the structure: Context, Technical Details, Impact" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of label strings (feature, fix, refactor, docs, test, infra)" + } + }, + "additionalProperties": false + } + + ## Type Rules (STRICT): + - "title": MUST be string (never number, never null, never empty) + - "description": 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 return an array like [] + ❌ 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: implement user authentication", + "description": "### Context\nWe need user authentication to secure the application.\n\n### Technical Details\n- Added auth models\n- Implemented JWT tokens\n\n### Impact\nUsers can now securely access the application.", + "labels": ["feature", "auth"] + } + + # Task Act as a Senior Tech Lead and generate a high-quality GitHub issue based on the provided inputs. # Inputs @@ -735,17 +1319,59 @@ const ( - ### Technical Details (Architectural changes, new models, etc.) - ### Impact (Benefits) - # Output Format - Respond with ONLY valid JSON (no markdown): + Generate the issue now. Return ONLY the JSON object (NOT an array), nothing else.` + + issuePromptTemplateES = `# 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. + ⚠️ NO DEVUELVAS UN ARRAY. DEBES devolver un OBJETO JSON con exactamente estos campos: title, description, labels. + IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. + + ## Schema JSON (OBLIGATORIO): { - "title": "Concise and descriptive title", - "description": "Markdown body following the structure above", - "labels": ["label1", "label2"] + "type": "object", + "required": ["title", "description", "labels"], + "properties": { + "title": { + "type": "string", + "description": "Título descriptivo y con gancho" + }, + "description": { + "type": "string", + "description": "Cuerpo en markdown siguiendo la estructura: Contexto, Detalles Técnicos, Impacto" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array de etiquetas como strings (feature, fix, refactor, docs, test, infra)" + } + }, + "additionalProperties": false } - Generate the issue now.` + ## Reglas de Tipos (ESTRICTAS): + - "title": DEBE ser string (nunca número, nunca null, nunca vacío) + - "description": 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 devuelvas un array como [] + ❌ 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 - issuePromptTemplateES = `# Tarea + ## Ejemplo Válido: + { + "title": "feat: implementar autenticación de usuarios", + "description": "### Contexto\nNecesitamos autenticación de usuarios para asegurar la aplicación.\n\n### Detalles Técnicos\n- Agregué modelos de auth\n- Implementé tokens JWT\n\n### Impacto\nLos usuarios ahora pueden acceder de forma segura a la aplicación.", + "labels": ["feature", "auth"] + } + + # Tarea Actuá como un Tech Lead y generá un issue de GitHub profesional basado en los inputs. # Entradas (Inputs) @@ -757,7 +1383,7 @@ const ( 3. **Categorización Precisa:** Elegí siempre al menos una categoría principal: 'feature', 'fix', o 'refactor'. Solo usá 'fix' si ves una corrección de un bug. Usá 'refactor' para mejoras de código sin cambios lógicos. Usá 'feature' para funcionalidades nuevas. 4. **Cero Emojis:** No uses emojis ni en el título ni en el cuerpo del issue. Mantené un estilo sobrio y técnico. 5. **Etiquetado Equilibrado:** Buscá entre 2 y 4 etiquetas relevantes. Asegurate de incluir la categoría principal más cualquier etiqueta de tipo de archivo como 'test', 'docs', o 'infra' si corresponde. - 6. **Formato:** JSON crudo. No incluyas bloques de markdown (como ` + "```json" + `). + 6. **Formato:** JSON crudo. No incluyas bloques de markdown (como ` + "" + `). # Estructura de la Descripción El campo "description" tiene que ser Markdown y seguir esta estructura estricta: @@ -765,17 +1391,7 @@ const ( - ### Detalles Técnicos (Lista de cambios importantes, modelos nuevos, refactors) - ### Impacto (¿Qué gana el usuario o el desarrollador con esto?) - # Formato de Salida - IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. - - Responde SOLO con JSON válido (sin markdown): - { - "title": "título descriptivo y con gancho", - "description": "Cuerpo en markdown siguiendo la estructura pedida", - "labels": ["etiqueta1", "etiqueta2"] - } - - Generá el issue ahora.` + Generá el issue ahora. Devuelve SOLO el objeto JSON (NO un array), nada más.` ) // GetIssuePromptTemplate returns the appropriate issue generation template based on language diff --git a/internal/ai/prompts_test.go b/internal/ai/prompts_test.go index c3de9b9..0c95a1a 100644 --- a/internal/ai/prompts_test.go +++ b/internal/ai/prompts_test.go @@ -1,173 +1,703 @@ package ai import ( + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/models" ) -func TestGetPRPromptTemplate(t *testing.T) { - t.Run("returns English template for 'en'", func(t *testing.T) { - // Arrange - lang := "en" +func TestRenderPrompt(t *testing.T) { + t.Run("Success - Render commit prompt with all fields", func(t *testing.T) { + data := PromptData{ + Count: 3, + Files: "main.go, utils.go", + Diff: "diff --git a/main.go...", + Ticket: "JIRA-123", + History: "feat: previous commit", + Instructions: "Follow conventional commits", + IssueNumber: 42, + } + + result, err := RenderPrompt("test", promptTemplateWithTicketEN, data) + + require.NoError(t, err) + assert.Contains(t, result, "3 commit message suggestions") + assert.Contains(t, result, "main.go, utils.go") + assert.Contains(t, result, "JIRA-123") + assert.Contains(t, result, "feat: previous commit") + assert.Contains(t, result, "Follow conventional commits") + }) - // Act - result := GetPRPromptTemplate(lang) + t.Run("Success - Render PR prompt", func(t *testing.T) { + data := PromptData{ + PRContent: "## Changes\n- Added authentication\n- Fixed bug in login", + } - // Assert - assert.Equal(t, prPromptTemplateEN, result) + result, err := RenderPrompt("pr_test", prPromptTemplateEN, data) + + require.NoError(t, err) + assert.Contains(t, result, "Added authentication") + assert.Contains(t, result, "Fixed bug in login") + assert.Contains(t, result, "Pull Request summary") }) - t.Run("returns Spanish template for 'es'", func(t *testing.T) { - // Arrange - lang := "es" + t.Run("Success - Render release notes prompt", func(t *testing.T) { + data := PromptData{ + RepoOwner: "thomas-vilte", + RepoName: "matecommit", + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + ReleaseDate: "2024-01-15", + Changelog: "feat: add new feature\nfix: resolve bug", + } + + result, err := RenderPrompt("release_test", releasePromptTemplateEN, data) + + require.NoError(t, err) + assert.Contains(t, result, "thomas-vilte/matecommit") + assert.Contains(t, result, "v1.0.0") + assert.Contains(t, result, "v1.1.0") + assert.Contains(t, result, "2024-01-15") + assert.Contains(t, result, "feat: add new feature") + }) - // Act - result := GetPRPromptTemplate(lang) + t.Run("Error - Invalid template syntax", func(t *testing.T) { + invalidTemplate := "Hello {{.Name" + data := PromptData{} - // Assert - assert.Equal(t, prPromptTemplateES, result) + result, err := RenderPrompt("invalid", invalidTemplate, data) + + assert.Error(t, err) + assert.Empty(t, result) + assert.Contains(t, err.Error(), "error parsing template") }) - t.Run("defaults to English for unknown language", func(t *testing.T) { - // Arrange - lang := "fr" + t.Run("Error - Missing field in data", func(t *testing.T) { + template := "Count: {{.Count}}, Missing: {{.NonExistent}}" + data := PromptData{Count: 5} - // Act - result := GetPRPromptTemplate(lang) + result, err := RenderPrompt("missing_field", template, data) - // Assert - assert.Equal(t, prPromptTemplateEN, result) + assert.Error(t, err) + assert.Empty(t, result) + assert.Contains(t, err.Error(), "error executing template") }) - t.Run("defaults to English for empty language", func(t *testing.T) { - // Arrange - lang := "" + t.Run("Success - Empty optional fields", func(t *testing.T) { + data := PromptData{ + Count: 1, + Files: "", + Diff: "", + } - // Act - result := GetPRPromptTemplate(lang) + result, err := RenderPrompt("empty_fields", promptTemplateWithoutTicketEN, data) - // Assert - assert.Equal(t, prPromptTemplateEN, result) + require.NoError(t, err) + assert.Contains(t, result, "1 commit message suggestions") + }) +} + +func TestGetPRPromptTemplate(t *testing.T) { + t.Run("English template contains key instructions", func(t *testing.T) { + result := GetPRPromptTemplate("en") + + 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) { + result := GetPRPromptTemplate("es") + + 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) { + result := GetPRPromptTemplate("fr") + expected := GetPRPromptTemplate("en") + + assert.Equal(t, expected, result) }) } func TestGetCommitPromptTemplate(t *testing.T) { - t.Run("returns English template with ticket", func(t *testing.T) { - // Arrange - lang := "en" + t.Run("English with ticket contains ticket validation", func(t *testing.T) { + result := GetCommitPromptTemplate("en", true) + + 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) { + result := GetCommitPromptTemplate("en", false) + + 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) { + result := GetCommitPromptTemplate("es", true) + + 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") + }) + + 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\"") + }) +} + +func TestGetReleasePromptTemplate(t *testing.T) { + t.Run("English template has noise filtering instructions", func(t *testing.T) { + result := GetReleasePromptTemplate("en") + + 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") + }) - // Act - result := GetCommitPromptTemplate(lang, true) + t.Run("Spanish template has Spanish instructions", func(t *testing.T) { + result := GetReleasePromptTemplate("es") + + assert.Contains(t, result, "FILTRADO DE RUIDO TÉCNICO") + 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") + }) +} - // Assert - assert.Equal(t, promptTemplateWithTicketEN, result) +func TestGetIssuePromptTemplate(t *testing.T) { + t.Run("English template has proper structure", func(t *testing.T) { + result := GetIssuePromptTemplate("en") + + assert.Contains(t, result, "Senior Tech Lead") + assert.Contains(t, result, "Context (Motivation)") + 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("returns English template without ticket", func(t *testing.T) { - // Arrange - lang := "en" + t.Run("Spanish template is in Spanish", func(t *testing.T) { + result := GetIssuePromptTemplate("es") + + assert.Contains(t, result, "Tech Lead") + assert.Contains(t, result, "Contexto") + 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") + }) +} - // Act - result := GetCommitPromptTemplate(lang, false) +func TestGetIssueReferenceInstructions(t *testing.T) { + t.Run("English instructions contain examples", func(t *testing.T) { + result := GetIssueReferenceInstructions("en") - // Assert - assert.Equal(t, promptTemplateWithoutTicketEN, result) + assert.Contains(t, result, "feat: add dark mode support") + assert.Contains(t, result, "fix: resolve authentication error") + assert.Contains(t, result, "MUST include the reference") + assert.Contains(t, result, "{{.IssueNumber}}") }) - t.Run("returns Spanish template with ticket", func(t *testing.T) { - // Arrange - lang := "es" + t.Run("Spanish instructions are in Spanish", func(t *testing.T) { + result := GetIssueReferenceInstructions("es") + + assert.Contains(t, result, "DEBES incluir la referencia") + assert.Contains(t, result, "{{.IssueNumber}}") + assert.Contains(t, result, "feat:") + assert.Contains(t, result, "fix:") + }) - // Act - result := GetCommitPromptTemplate(lang, true) + t.Run("Can be rendered with issue number", func(t *testing.T) { + instructions := GetIssueReferenceInstructions("en") + data := struct{ IssueNumber int }{IssueNumber: 42} - // Assert - assert.Equal(t, promptTemplateWithTicketES, result) + result, err := RenderPrompt("issue_ref", instructions, data) + + require.NoError(t, err) + assert.Contains(t, result, "#42") + assert.NotContains(t, result, "{{.IssueNumber}}") + }) +} + +func TestFormatTemplateForPrompt(t *testing.T) { + t.Run("Formats issue template with body content in English", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "bug_report", + BodyContent: "## Bug Description\n\nDescribe the bug...", + } + + result := FormatTemplateForPrompt(template, "en", "issue") + + assert.Contains(t, result, "Project Issue Template") + assert.Contains(t, result, "Template Name: bug_report") + assert.Contains(t, result, "Template Structure:") + assert.Contains(t, result, "## Bug Description") + assert.Contains(t, result, "```markdown") + assert.Contains(t, result, "MUST follow its structure") }) - t.Run("returns Spanish template without ticket", func(t *testing.T) { - // Arrange - lang := "es" + t.Run("Formats PR template in Spanish", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "pull_request", + BodyContent: "## Cambios\n\nDescribe los cambios...", + } - // Act - result := GetCommitPromptTemplate(lang, false) + result := FormatTemplateForPrompt(template, "es", "pr") - // Assert - assert.Equal(t, promptTemplateWithoutTicketES, result) + assert.Contains(t, result, "Template de PR del Proyecto") + assert.Contains(t, result, "Nombre del Template: pull_request") + assert.Contains(t, result, "Estructura del Template:") + assert.Contains(t, result, "## Cambios") + assert.Contains(t, result, "DEBES seguir su estructura") }) - t.Run("defaults to English with ticket for unknown language", func(t *testing.T) { - // Arrange - lang := "fr" + t.Run("Handles template with About field", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "feature_request", + About: "Template for feature requests", + BodyContent: "## Feature Description", + } - // Act - result := GetCommitPromptTemplate(lang, true) + result := FormatTemplateForPrompt(template, "en", "issue") - // Assert - assert.Equal(t, promptTemplateWithTicketEN, result) + assert.Contains(t, result, "Template Description: Template for feature requests") + assert.Contains(t, result, "## Feature Description") }) - t.Run("defaults to English without ticket for unknown language", func(t *testing.T) { - // Arrange - lang := "fr" + t.Run("Returns empty string for nil template", func(t *testing.T) { + result := FormatTemplateForPrompt(nil, "en", "issue") + + assert.Empty(t, result) + }) - // Act - result := GetCommitPromptTemplate(lang, false) + t.Run("Defaults to English for empty language", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "test", + BodyContent: "Test content", + } - // Assert - assert.Equal(t, promptTemplateWithoutTicketEN, result) + result := FormatTemplateForPrompt(template, "", "issue") + + assert.Contains(t, result, "Project Issue Template") + assert.NotContains(t, result, "Template de Issue") + }) + + t.Run("Handles YAML template without body content", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "yaml_template", + About: "YAML based template", + Body: map[string]interface{}{"type": "markdown"}, + } + + result := FormatTemplateForPrompt(template, "en", "issue") + + assert.Contains(t, result, "GitHub Issue Form (YAML)") + assert.Contains(t, result, "defines specific fields") }) } -func TestGetReleasePromptTemplate(t *testing.T) { - t.Run("returns English template for 'en'", func(t *testing.T) { - lang := "en" - result := GetReleasePromptTemplate(lang) - assert.Equal(t, releasePromptTemplateEN, result) +func TestFormatIssuesForPrompt(t *testing.T) { + t.Run("Formats multiple issues in English", func(t *testing.T) { + issues := []models.Issue{ + { + Number: 42, + Title: "Add authentication", + Description: "We need OAuth2 support for third-party integrations", + }, + { + Number: 43, + Title: "Fix memory leak", + Description: "Application crashes after 24 hours of runtime", + }, + } + + result := FormatIssuesForPrompt(issues, "en") + + assert.Contains(t, result, "Issue #42: Add authentication") + assert.Contains(t, result, "Description: We need OAuth2 support") + assert.Contains(t, result, "Issue #43: Fix memory leak") + assert.Contains(t, result, "Description: Application crashes") + }) + + t.Run("Formats issues in Spanish", func(t *testing.T) { + issues := []models.Issue{ + { + Number: 10, + Title: "Agregar modo oscuro", + Description: "Los usuarios quieren modo oscuro", + }, + } + + result := FormatIssuesForPrompt(issues, "es") + + assert.Contains(t, result, "Issue #10: Agregar modo oscuro") + assert.Contains(t, result, "Descripción: Los usuarios quieren modo oscuro") + }) + + t.Run("Truncates long descriptions", func(t *testing.T) { + longDesc := strings.Repeat("a", 250) + issues := []models.Issue{ + { + Number: 1, + Title: "Test", + Description: longDesc, + }, + } + + result := FormatIssuesForPrompt(issues, "en") + + assert.Contains(t, result, "Issue #1: Test") + assert.Contains(t, result, "...") + assert.Less(t, len(result), len(longDesc)+100) }) - t.Run("returns Spanish template for 'es'", func(t *testing.T) { - lang := "es" - result := GetReleasePromptTemplate(lang) - assert.Equal(t, releasePromptTemplateES, result) + t.Run("Handles issues without description", func(t *testing.T) { + issues := []models.Issue{ + { + Number: 5, + Title: "No description issue", + Description: "", + }, + } + + result := FormatIssuesForPrompt(issues, "en") + + assert.Contains(t, result, "Issue #5: No description issue") + assert.NotContains(t, result, "Description:") }) - t.Run("defaults to English for unknown language", func(t *testing.T) { - lang := "fr" - result := GetReleasePromptTemplate(lang) - assert.Equal(t, releasePromptTemplateEN, result) + t.Run("Returns empty string for empty issues list", func(t *testing.T) { + result := FormatIssuesForPrompt([]models.Issue{}, "en") + + assert.Empty(t, result) }) - t.Run("defaults to English for empty language", func(t *testing.T) { - lang := "" - result := GetReleasePromptTemplate(lang) - assert.Equal(t, releasePromptTemplateEN, result) + t.Run("Returns empty string for nil issues list", func(t *testing.T) { + result := FormatIssuesForPrompt(nil, "en") + + assert.Empty(t, result) }) } -func TestGetIssuePromptTemplate(t *testing.T) { - t.Run("returns English template for 'en'", func(t *testing.T) { - lang := "en" - result := GetIssuePromptTemplate(lang) - assert.Equal(t, issuePromptTemplateEN, result) +func TestGetPRIssueContextInstructions(t *testing.T) { + t.Run("English instructions contain closing keywords", func(t *testing.T) { + result := GetPRIssueContextInstructions("en") + + assert.Contains(t, result, "Fixes #N") + assert.Contains(t, result, "Closes #N") + assert.Contains(t, result, "Relates to #N") + assert.Contains(t, result, "MUST include at the BEGINNING") + assert.Contains(t, result, "{{.RelatedIssues}}") + }) + + t.Run("Spanish instructions are in Spanish", func(t *testing.T) { + result := GetPRIssueContextInstructions("es") + + assert.Contains(t, result, "Fixes #N") + assert.Contains(t, result, "Closes #N") + assert.Contains(t, result, "DEBES incluir AL INICIO") + assert.Contains(t, result, "{{.RelatedIssues}}") + }) + + t.Run("Can be rendered with related issues", func(t *testing.T) { + instructions := GetPRIssueContextInstructions("en") + data := struct{ RelatedIssues string }{ + RelatedIssues: "- Issue #42: Add feature\n- Issue #43: Fix bug", + } + + result, err := RenderPrompt("pr_issues", instructions, data) + + require.NoError(t, err) + assert.Contains(t, result, "Issue #42: Add feature") + assert.Contains(t, result, "Issue #43: Fix bug") + }) +} + +func TestGetTemplateInstructions(t *testing.T) { + t.Run("English instructions are clear", func(t *testing.T) { + result := GetTemplateInstructions("en") + + assert.Contains(t, result, "Project Template") + assert.Contains(t, result, "MUST follow") + assert.Contains(t, result, "structure and format") + }) + + t.Run("Spanish instructions are in Spanish", func(t *testing.T) { + result := GetTemplateInstructions("es") + + assert.Contains(t, result, "Template del Proyecto") + assert.Contains(t, result, "DEBES seguir") + assert.Contains(t, result, "estructura y formato") + }) +} + +func TestGetPRTemplateInstructions(t *testing.T) { + t.Run("English PR instructions", func(t *testing.T) { + result := GetPRTemplateInstructions("en") + + assert.Contains(t, result, "PR template") + assert.Contains(t, result, "MUST follow") + }) + + t.Run("Spanish PR instructions", func(t *testing.T) { + result := GetPRTemplateInstructions("es") + + assert.Contains(t, result, "Template de PR") + assert.Contains(t, result, "DEBES seguir") }) +} + +func TestGetTechnicalAnalysisInstruction(t *testing.T) { + t.Run("English includes key analysis points", func(t *testing.T) { + result := GetTechnicalAnalysisInstruction("en") + + assert.Contains(t, result, "best practices") + assert.Contains(t, result, "performance") + assert.Contains(t, result, "maintainability") + assert.Contains(t, result, "security") + }) + + t.Run("Spanish is in Spanish", func(t *testing.T) { + result := GetTechnicalAnalysisInstruction("es") - t.Run("returns Spanish template for 'es'", func(t *testing.T) { - lang := "es" - result := GetIssuePromptTemplate(lang) - assert.Equal(t, issuePromptTemplateES, result) + assert.Contains(t, result, "buenas prácticas") + assert.Contains(t, result, "rendimiento") + assert.Contains(t, result, "mantenibilidad") + assert.Contains(t, result, "seguridad") }) +} + +func TestGetNoIssueReferenceInstruction(t *testing.T) { + t.Run("English instruction is clear", func(t *testing.T) { + result := GetNoIssueReferenceInstruction("en") + + assert.Contains(t, result, "Do not include issue references") + }) + + t.Run("Spanish instruction", func(t *testing.T) { + result := GetNoIssueReferenceInstruction("es") + + assert.Contains(t, result, "No incluyas referencias de issues") + }) +} + +func TestGetReleaseNotesSectionHeaders(t *testing.T) { + t.Run("English headers are complete", func(t *testing.T) { + headers := GetReleaseNotesSectionHeaders("en") + + assert.Equal(t, "BREAKING CHANGES:", headers["breaking"]) + assert.Equal(t, "NEW FEATURES:", headers["features"]) + assert.Equal(t, "BUG FIXES:", headers["fixes"]) + assert.Equal(t, "IMPROVEMENTS:", headers["improvements"]) + assert.Equal(t, "CONTRIBUTORS", headers["contributors"]) + assert.Len(t, headers, 9) + }) + + t.Run("Spanish headers are in Spanish", func(t *testing.T) { + headers := GetReleaseNotesSectionHeaders("es") + + assert.Equal(t, "CAMBIOS QUE ROMPEN:", headers["breaking"]) + assert.Equal(t, "NUEVAS CARACTERÍSTICAS:", headers["features"]) + assert.Equal(t, "CORRECCIONES DE BUGS:", headers["fixes"]) + assert.Equal(t, "MEJORAS:", headers["improvements"]) + assert.Equal(t, "CONTRIBUIDORES", headers["contributors"]) + assert.Len(t, headers, 9) + }) + + t.Run("Unknown language defaults to English", func(t *testing.T) { + headers := GetReleaseNotesSectionHeaders("fr") + englishHeaders := GetReleaseNotesSectionHeaders("en") + + assert.Equal(t, englishHeaders, headers) + }) +} - t.Run("defaults to English for unknown language", func(t *testing.T) { - lang := "fr" - result := GetIssuePromptTemplate(lang) - assert.Equal(t, issuePromptTemplateEN, result) +func TestPromptRenderingWorkflow(t *testing.T) { + t.Run("Complete commit prompt with issue reference", func(t *testing.T) { + template := GetCommitPromptTemplate("en", true) + + issueInstructions := GetIssueReferenceInstructions("en") + issueData := struct{ IssueNumber int }{IssueNumber: 42} + renderedInstructions, err := RenderPrompt("issue_inst", issueInstructions, issueData) + require.NoError(t, err) + + data := PromptData{ + Count: 3, + Files: "auth.go, middleware.go", + Diff: "diff --git a/auth.go...", + Ticket: "AUTH-42", + History: "feat: add login endpoint", + Instructions: renderedInstructions, + IssueNumber: 42, + } + + result, err := RenderPrompt("full_commit", template, data) + + require.NoError(t, err) + assert.Contains(t, result, "3 commit message suggestions") + assert.Contains(t, result, "auth.go, middleware.go") + 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("defaults to English for empty language", func(t *testing.T) { - lang := "" - result := GetIssuePromptTemplate(lang) - assert.Equal(t, issuePromptTemplateEN, result) + t.Run("Complete PR prompt with issues and template", func(t *testing.T) { + issues := []models.Issue{ + {Number: 10, Title: "Add feature", Description: "Feature description"}, + {Number: 11, Title: "Fix bug", Description: "Bug description"}, + } + formattedIssues := FormatIssuesForPrompt(issues, "en") + + prTemplate := &models.IssueTemplate{ + Name: "pr_template", + BodyContent: "## Changes\n## Testing", + } + formattedTemplate := FormatTemplateForPrompt(prTemplate, "en", "pr") + + issueContext := GetPRIssueContextInstructions("en") + + prContent := formattedTemplate + "\n\n" + issueContext + "\n\n" + formattedIssues + + template := GetPRPromptTemplate("en") + data := PromptData{PRContent: prContent} + result, err := RenderPrompt("full_pr", template, data) + + require.NoError(t, err) + assert.Contains(t, result, "Issue #10: Add feature") + 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/cache/cache.go b/internal/commands/cache/cache.go index 85078f5..db17086 100644 --- a/internal/commands/cache/cache.go +++ b/internal/commands/cache/cache.go @@ -5,10 +5,10 @@ import ( "fmt" "time" + "github.com/fatih/color" + "github.com/thomas-vilte/matecommit/internal/cache" "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/i18n" - "github.com/thomas-vilte/matecommit/internal/cache" - "github.com/fatih/color" "github.com/urfave/cli/v3" ) diff --git a/internal/commands/config/config_test.go b/internal/commands/config/config_test.go index f4c9f69..3dcd35f 100644 --- a/internal/commands/config/config_test.go +++ b/internal/commands/config/config_test.go @@ -5,9 +5,9 @@ import ( "path/filepath" "testing" + "github.com/stretchr/testify/assert" "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/i18n" - "github.com/stretchr/testify/assert" ) func setupConfigTest(t *testing.T) (*config.Config, *i18n.Translations, string, func()) { diff --git a/internal/commands/config/init_test.go b/internal/commands/config/init_test.go index 15cb642..2cb7ccf 100644 --- a/internal/commands/config/init_test.go +++ b/internal/commands/config/init_test.go @@ -10,10 +10,10 @@ import ( "sync" "testing" - "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/config" + "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/urfave/cli/v3" ) diff --git a/internal/commands/config/show_test.go b/internal/commands/config/show_test.go index dede9c4..f0c1b5b 100644 --- a/internal/commands/config/show_test.go +++ b/internal/commands/config/show_test.go @@ -7,8 +7,8 @@ import ( "os" "testing" - "github.com/thomas-vilte/matecommit/internal/config" "github.com/stretchr/testify/assert" + "github.com/thomas-vilte/matecommit/internal/config" "github.com/urfave/cli/v3" ) diff --git a/internal/commands/handler/suggestions_test.go b/internal/commands/handler/suggestions_test.go index 6f756aa..dc1913a 100644 --- a/internal/commands/handler/suggestions_test.go +++ b/internal/commands/handler/suggestions_test.go @@ -8,10 +8,10 @@ import ( "os" "testing" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" ) type mockGitService struct { diff --git a/internal/commands/issues/issues.go b/internal/commands/issues/issues.go index e7cf922..930e32c 100644 --- a/internal/commands/issues/issues.go +++ b/internal/commands/issues/issues.go @@ -20,9 +20,9 @@ import ( // IssueGeneratorService is a minimal interface for testing purposes type IssueGeneratorService interface { - GenerateFromDiff(ctx context.Context, hint string, skipLabels bool) (*models.IssueGenerationResult, error) - GenerateFromDescription(ctx context.Context, description string, skipLabels bool) (*models.IssueGenerationResult, error) - GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool) (*models.IssueGenerationResult, error) + GenerateFromDiff(ctx context.Context, hint string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) + GenerateFromDescription(ctx context.Context, description string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) + GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) GenerateWithTemplate(ctx context.Context, templateName string, hint string, fromDiff bool, description string, skipLabels bool) (*models.IssueGenerationResult, error) CreateIssue(ctx context.Context, result *models.IssueGenerationResult, assignees []string) (*models.Issue, error) GetAuthenticatedUser(ctx context.Context) (string, error) @@ -126,6 +126,11 @@ func (f *IssuesCommandFactory) createGenerateFlags(t *i18n.Translations) []cli.F Aliases: []string{"c"}, Usage: t.GetMessage("issue.flag_checkout", 0, nil), }, + &cli.BoolFlag{ + Name: "auto-template", + Usage: t.GetMessage("issue.auto_template_flag", 0, nil), + Value: true, + }, } } @@ -143,6 +148,7 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c assignMe := command.Bool("assign-me") checkoutBranch := command.Bool("checkout") templateName := command.String("template") + autoTemplate := command.Bool("auto-template") log.Info("executing issue generate command", "from_diff", fromDiff, @@ -206,11 +212,11 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c if templateName != "" { result, err = issueService.GenerateWithTemplate(ctx, templateName, hint, fromDiff, description, noLabels) } else if fromDiff { - result, err = issueService.GenerateFromDiff(ctx, hint, noLabels) + result, err = issueService.GenerateFromDiff(ctx, hint, noLabels, autoTemplate) } else if fromPR > 0 { - result, err = issueService.GenerateFromPR(ctx, fromPR, hint, noLabels) + result, err = issueService.GenerateFromPR(ctx, fromPR, hint, noLabels, autoTemplate) } else { - result, err = issueService.GenerateFromDescription(ctx, description, noLabels) + result, err = issueService.GenerateFromDescription(ctx, description, noLabels, autoTemplate) } spinner.Stop() diff --git a/internal/commands/issues/issues_test.go b/internal/commands/issues/issues_test.go index 9f47599..b046481 100644 --- a/internal/commands/issues/issues_test.go +++ b/internal/commands/issues/issues_test.go @@ -80,7 +80,7 @@ func TestIssueGenerateAction(t *testing.T) { Labels: []string{"bug"}, } - mockGen.On("GenerateFromDiff", mock.Anything, "hint", false).Return(expectedResult, nil) + mockGen.On("GenerateFromDiff", mock.Anything, "hint", false, true).Return(expectedResult, nil) mockGen.On("CreateIssue", mock.Anything, expectedResult, []string(nil)).Return(&models.Issue{Number: 1, URL: "http://test.com"}, nil) withStdin("y\n", func() { @@ -102,7 +102,7 @@ func TestIssueGenerateAction(t *testing.T) { Description: "Dry Run Desc", } - mockGen.On("GenerateFromDescription", mock.Anything, "desc", false).Return(expectedResult, nil) + mockGen.On("GenerateFromDescription", mock.Anything, "desc", false, true).Return(expectedResult, nil) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} err := app.Run(context.Background(), []string{"test", "issue", "generate", "--description", "desc", "--dry-run"}) @@ -121,7 +121,7 @@ func TestIssueGenerateAction(t *testing.T) { Title: "Assigned Issue", } - mockGen.On("GenerateFromDiff", mock.Anything, "", false).Return(expectedResult, nil) + mockGen.On("GenerateFromDiff", mock.Anything, "", false, true).Return(expectedResult, nil) mockGen.On("GetAuthenticatedUser", mock.Anything).Return("test-user", nil) mockGen.On("CreateIssue", mock.Anything, expectedResult, []string{"test-user"}).Return(&models.Issue{Number: 1}, nil) @@ -139,7 +139,7 @@ func TestIssueGenerateAction(t *testing.T) { factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) - mockGen.On("GenerateFromDiff", mock.Anything, "", false).Return(&models.IssueGenerationResult{Title: "T"}, nil) + mockGen.On("GenerateFromDiff", mock.Anything, "", false, true).Return(&models.IssueGenerationResult{Title: "T"}, nil) withStdin("n\n", func() { app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} diff --git a/internal/commands/issues/mocks.go b/internal/commands/issues/mocks.go index 41c6ef6..c9cbd4f 100644 --- a/internal/commands/issues/mocks.go +++ b/internal/commands/issues/mocks.go @@ -11,24 +11,24 @@ type MockIssueGeneratorService struct { mock.Mock } -func (m *MockIssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { - args := m.Called(ctx, hint, skipLabels) +func (m *MockIssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, hint, skipLabels, autoTemplate) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.IssueGenerationResult), args.Error(1) } -func (m *MockIssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool) (*models.IssueGenerationResult, error) { - args := m.Called(ctx, description, skipLabels) +func (m *MockIssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, description, skipLabels, autoTemplate) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.IssueGenerationResult), args.Error(1) } -func (m *MockIssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { - args := m.Called(ctx, prNumber, hint, skipLabels) +func (m *MockIssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, prNumber, hint, skipLabels, autoTemplate) if args.Get(0) == nil { return nil, args.Error(1) } diff --git a/internal/commands/pull_requests/summarize_test.go b/internal/commands/pull_requests/summarize_test.go index aa88439..601c0fe 100644 --- a/internal/commands/pull_requests/summarize_test.go +++ b/internal/commands/pull_requests/summarize_test.go @@ -5,12 +5,12 @@ import ( "fmt" "testing" - "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/config" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" ) type MockPRService struct { diff --git a/internal/commands/release/edit_test.go b/internal/commands/release/edit_test.go index 3df59b4..a742d4a 100644 --- a/internal/commands/release/edit_test.go +++ b/internal/commands/release/edit_test.go @@ -8,11 +8,11 @@ import ( "path/filepath" "testing" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" "github.com/urfave/cli/v3" ) diff --git a/internal/commands/release/push_test.go b/internal/commands/release/push_test.go index 1734bab..95e07f3 100644 --- a/internal/commands/release/push_test.go +++ b/internal/commands/release/push_test.go @@ -5,11 +5,11 @@ import ( "errors" "testing" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" "github.com/urfave/cli/v3" ) diff --git a/internal/commands/stats/stats_test.go b/internal/commands/stats/stats_test.go index 3e5a2ea..4a208c2 100644 --- a/internal/commands/stats/stats_test.go +++ b/internal/commands/stats/stats_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "github.com/thomas-vilte/matecommit/internal/i18n" - "github.com/thomas-vilte/matecommit/internal/services/cost" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/services/cost" ) func TestNewStatsCommand(t *testing.T) { diff --git a/internal/dependency/gomod_analyzer_test.go b/internal/dependency/gomod_analyzer_test.go index af0c127..14dca96 100644 --- a/internal/dependency/gomod_analyzer_test.go +++ b/internal/dependency/gomod_analyzer_test.go @@ -5,9 +5,9 @@ import ( "errors" "testing" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/models" ) // MockVCSClient es un mock del VCSClient diff --git a/internal/dependency/package_json_analyzer_test.go b/internal/dependency/package_json_analyzer_test.go index 868b6bc..1f0f14a 100644 --- a/internal/dependency/package_json_analyzer_test.go +++ b/internal/dependency/package_json_analyzer_test.go @@ -5,9 +5,9 @@ import ( "errors" "testing" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/models" ) func TestPackageJsonAnalyzer_Name(t *testing.T) { diff --git a/internal/dependency/registry_test.go b/internal/dependency/registry_test.go index 9e1c89d..94b99df 100644 --- a/internal/dependency/registry_test.go +++ b/internal/dependency/registry_test.go @@ -5,10 +5,10 @@ import ( "errors" "testing" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/ports" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/models" + "github.com/thomas-vilte/matecommit/internal/ports" ) // MockAnalyzer es un mock de DependencyAnalyzer diff --git a/internal/github/github_service.go b/internal/github/github_service.go index a1f220e..285bcfc 100644 --- a/internal/github/github_service.go +++ b/internal/github/github_service.go @@ -214,6 +214,11 @@ func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData } } + prLabels := make([]string, len(pr.Labels)) + for i, label := range pr.Labels { + prLabels[i] = label.GetName() + } + diff, resp, err := ghc.prService.GetRaw(ctx, ghc.owner, ghc.repo, prNumber, github.RawOptions{Type: github.Diff}) if err != nil { // If 406 error (diff too large), use fallback commit by commit @@ -236,6 +241,7 @@ func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData Diff: diff, BranchName: pr.GetHead().GetRef(), Description: pr.GetBody(), + Labels: prLabels, } log.Debug("github PR fetched successfully", diff --git a/internal/github/github_service_test.go b/internal/github/github_service_test.go index ba979df..13cbac8 100644 --- a/internal/github/github_service_test.go +++ b/internal/github/github_service_test.go @@ -8,11 +8,11 @@ import ( "testing" "time" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/google/go-github/v80/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/models" ) func newTestClient(pr *MockPRService, issues *MockIssuesService, release *MockReleaseService, userService *MockUserService) *GitHubClient { diff --git a/internal/github/mocks.go b/internal/github/mocks.go index 4c0a9df..efad7f6 100644 --- a/internal/github/mocks.go +++ b/internal/github/mocks.go @@ -4,9 +4,9 @@ import ( "context" "os" - "github.com/thomas-vilte/matecommit/internal/builder" "github.com/google/go-github/v80/github" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/builder" ) type MockPRService struct { diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index 3cd3d6d..9ba01bf 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -168,6 +168,7 @@ error_invalid_language = "Invalid Language: {{.Language}}" [error] pr_service_creation_error = "Error creating PR service: {{.Error}}" +pr_template_creation_error = "Error creating PR template service: {{.Error}}" get_labels = "Error getting labels" create_label = "Error creating label '{{.label}}'" update_pr = "Error updating PR #{{.pr_number}}" @@ -722,6 +723,13 @@ template_list_empty = "No templates found" template_list_hint = "Run 'matecommit issue template init' to create default templates" template_list_usage_hint = "Use --template with the generate command" template_list_error = "Error listing templates" +auto_template_flag = "Automatically select template using AI if none specified" +auto_selected_template = "🤖 Auto-selected template: {{.Template}}" +# Template selection +templates_found = "📋 Found {{.Count}} template(s) in your repository" +template_none = "None (use AI only)" +template_select_prompt = "Select a template" +template_selected = "✓ Using template: {{.Template}}" [stats] @@ -791,3 +799,6 @@ reason_default = "Default model" [flags_global] debug_flag = "Enable debug logging (very verbose, includes file:line)" verbose_flag = "Enable verbose logging (show info messages)" + +[pr] +using_template = "📋 Using PR template: {{.Template}}" \ No newline at end of file diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index 7e14fb4..37c50cd 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -180,6 +180,7 @@ ai_models_label = "Modelos de IA configurados:" no_ai_models_configured = "No hay modelos de IA configurados" [error] pr_service_creation_error = "Error al crear el servicio de PR: {{.Error}}" +pr_template_creation_error = "Error al crear el servicio de template de PR: {{.Error}}" pr_summary_error = "Error al generar el resumen del PR: {{.Error}}" get_labels = "Error al obtener las etiquetas" create_label = "Error al crear la etiqueta '{{.label}}'" @@ -743,6 +744,13 @@ template_list_empty = "No se encontraron templates" template_list_hint = "Ejecutá 'matecommit issue template init' para crear templates por defecto" template_list_usage_hint = "Usá --template con el comando generate" template_list_error = "Error listando templates" +auto_template_flag = "Seleccionar automáticamente el template usando IA si no se especifica uno" +auto_selected_template = "🤖 Template auto-seleccionado: {{.Template}}" +# Template selection +templates_found = "📋 Se encontraron {{.Count}} template(s) en tu repositorio" +template_none = "Ninguno (usar solo IA)" +template_select_prompt = "Seleccioná un template" +template_selected = "✓ Usando template: {{.Template}}" [stats] usage = "Mostrar estadísticas de costo y uso" @@ -809,3 +817,6 @@ reason_default = "Modelo por defecto" [flags_global] debug_flag = "Habilitar logging de depuración (muy detallado, incluye archivo:línea)" verbose_flag = "Habilitar logging detallado (mostrar mensajes informativos)" + +[pr] +using_template = "📋 Usando template de PR: {{.Template}}" \ No newline at end of file diff --git a/internal/models/issue_generation.go b/internal/models/issue_generation.go index 053cb42..1d416a8 100644 --- a/internal/models/issue_generation.go +++ b/internal/models/issue_generation.go @@ -17,6 +17,9 @@ type IssueGenerationRequest struct { // Language is the language for content generation (e.g.: "es", "en") Language string + + // Template is the project's issue template to guide generation (optional) + Template *IssueTemplate } // IssueGenerationResult contains the result of an issue's content generation. diff --git a/internal/models/pr.go b/internal/models/pr.go index 1b00d70..9b88d81 100644 --- a/internal/models/pr.go +++ b/internal/models/pr.go @@ -37,6 +37,7 @@ type ( BranchName string RelatedIssues []Issue Description string + Labels []string } // Commit represents a commit included in the PR. diff --git a/internal/providers/ai.go b/internal/providers/ai.go index ae6d571..377e49f 100644 --- a/internal/providers/ai.go +++ b/internal/providers/ai.go @@ -4,10 +4,10 @@ import ( "context" "fmt" - "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/ai" "github.com/thomas-vilte/matecommit/internal/ai/gemini" + "github.com/thomas-vilte/matecommit/internal/config" + "github.com/thomas-vilte/matecommit/internal/ports" ) // NewCommitSummarizer creates a CommitSummarizer based on the configured provider diff --git a/internal/providers/tickets.go b/internal/providers/tickets.go index daf7dcb..e24fa73 100644 --- a/internal/providers/tickets.go +++ b/internal/providers/tickets.go @@ -6,8 +6,8 @@ import ( "net/http" "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/jira" + "github.com/thomas-vilte/matecommit/internal/ports" ) // NewTicketManager creates a TicketManager based on the configured provider diff --git a/internal/providers/vcs.go b/internal/providers/vcs.go index 6a65cc4..80a48d4 100644 --- a/internal/providers/vcs.go +++ b/internal/providers/vcs.go @@ -5,9 +5,9 @@ import ( "fmt" "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/git" "github.com/thomas-vilte/matecommit/internal/github" + "github.com/thomas-vilte/matecommit/internal/ports" ) // NewVCSClient creates a VCSClient based on the configuration and automatic detection of the remote diff --git a/internal/services/commit_service_test.go b/internal/services/commit_service_test.go index ac7a9f1..8e06c1b 100644 --- a/internal/services/commit_service_test.go +++ b/internal/services/commit_service_test.go @@ -5,11 +5,11 @@ import ( "errors" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" "github.com/thomas-vilte/matecommit/internal/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" ) func setupTest(t *testing.T) (*MockGitService, *MockAIProvider, *MockJiraService, *MockVCSClient, *config.Config) { diff --git a/internal/services/issue_generator_service.go b/internal/services/issue_generator_service.go index edd010b..0499c2d 100644 --- a/internal/services/issue_generator_service.go +++ b/internal/services/issue_generator_service.go @@ -23,6 +23,7 @@ type issueGitService interface { type issueTemplateService interface { GetTemplateByName(ctx context.Context, name string) (*models.IssueTemplate, error) MergeWithGeneratedContent(template *models.IssueTemplate, generated *models.IssueGenerationResult) *models.IssueGenerationResult + ListTemplates(ctx context.Context) ([]models.TemplateMetadata, error) } type IssueGeneratorService struct { @@ -70,7 +71,7 @@ func NewIssueGeneratorService( // GenerateFromDiff generates issue content based on the current git diff. // It analyzes local changes (staged and unstaged) to create an appropriate title, description, and labels. -func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { +func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) { logger.Info(ctx, "generating issue from diff", "has_hint", hint != "", "skip_labels", skipLabels) @@ -101,14 +102,21 @@ func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint strin "diff_size", len(diff), "files_count", len(changedFiles)) + var template *models.IssueTemplate + if autoTemplate { + template, _ = s.SelectTemplateWithAI(ctx, "", hint, changedFiles, nil) + } + request := models.IssueGenerationRequest{ Diff: diff, ChangedFiles: changedFiles, Hint: hint, Language: s.config.Language, + Template: template, } - logger.Debug(ctx, "calling AI for issue generation from diff") + logger.Debug(ctx, "calling AI for issue generation from diff", + "has_template", template != nil) result, err := s.ai.GenerateIssueContent(ctx, request) if err != nil { @@ -116,6 +124,12 @@ func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint strin return nil, domainErrors.NewAppError(domainErrors.TypeAI, "failed to generate issue content", err) } + if template != nil { + result = s.templateService.MergeWithGeneratedContent(template, result) + logger.Debug(ctx, "merged template with generated content", + "template_name", template.Name) + } + if !skipLabels { smartLabels := s.inferSmartLabels(diff, changedFiles) result.Labels = s.mergeLabels(result.Labels, smartLabels) @@ -131,7 +145,7 @@ func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint strin // GenerateFromDescription generates issue content based on a manual description. // Useful when the user wants to create an issue without having local changes. -func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool) (*models.IssueGenerationResult, error) { +func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) { logger.Info(ctx, "generating issue from description", "description_length", len(description), "skip_labels", skipLabels) @@ -146,14 +160,25 @@ func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, des return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, "description is required", nil) } + var template *models.IssueTemplate + if autoTemplate { + var err error + template, err = s.SelectTemplateWithAI(ctx, "", description, nil, nil) + if err != nil { + logger.Warn(ctx, "failed to auto-select template via AI", err) + } + } + request := models.IssueGenerationRequest{ Description: description, + Template: template, } if s.config != nil { request.Language = s.config.Language } - logger.Debug(ctx, "calling AI for issue generation from description") + logger.Debug(ctx, "calling AI for issue generation from description", + "has_template", template != nil) result, err := s.ai.GenerateIssueContent(ctx, request) if err != nil { @@ -161,6 +186,12 @@ func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, des return nil, domainErrors.NewAppError(domainErrors.TypeAI, "failed to generate issue content", err) } + if template != nil { + result = s.templateService.MergeWithGeneratedContent(template, result) + logger.Debug(ctx, "merged template with generated content", + "template_name", template.Name) + } + if skipLabels { result.Labels = []string{} } @@ -171,7 +202,7 @@ func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, des return result, nil } -func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { +func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) { logger.Info(ctx, "generating issue from PR", "pr_number", prNumber, "has_hint", hint != "", @@ -217,15 +248,22 @@ func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int changedFiles := s.extractFilesFromDiff(prData.Diff) + var template *models.IssueTemplate + if autoTemplate { + template, _ = s.SelectTemplateWithAI(ctx, prData.Title, prData.Description, changedFiles, prData.Labels) + } + request := models.IssueGenerationRequest{ Description: contextBuilder.String(), Diff: prData.Diff, ChangedFiles: changedFiles, Hint: hint, Language: s.config.Language, + Template: template, } - logger.Debug(ctx, "calling AI for issue generation from PR") + logger.Debug(ctx, "calling AI for issue generation from PR", + "has_template", template != nil) result, err := s.ai.GenerateIssueContent(ctx, request) if err != nil { @@ -234,6 +272,12 @@ func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int return nil, domainErrors.NewAppError(domainErrors.TypeAI, "failed to generate issue content", err) } + if template != nil { + result = s.templateService.MergeWithGeneratedContent(template, result) + logger.Debug(ctx, "merged template with generated content", + "template_name", template.Name) + } + result.Description = fmt.Sprintf("%s\n\n---\n*Related PR: #%d*", result.Description, prNumber) if !skipLabels { @@ -258,9 +302,9 @@ func (s *IssueGeneratorService) GenerateWithTemplate(ctx context.Context, templa var baseResult *models.IssueGenerationResult if fromDiff { - baseResult, err = s.GenerateFromDiff(ctx, hint, skipLabels) + baseResult, err = s.GenerateFromDiff(ctx, hint, skipLabels, false) } else if description != "" { - baseResult, err = s.GenerateFromDescription(ctx, description, skipLabels) + baseResult, err = s.GenerateFromDescription(ctx, description, skipLabels, false) } else { return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, "no input provided", nil) } @@ -277,6 +321,18 @@ func (s *IssueGeneratorService) GenerateWithTemplate(ctx context.Context, templa return result, nil } +func (s *IssueGeneratorService) SuggestTemplates(ctx context.Context) ([]models.TemplateMetadata, error) { + if s.templateService == nil { + return []models.TemplateMetadata{}, nil + } + + templates, err := s.templateService.ListTemplates(ctx) + if err != nil { + return []models.TemplateMetadata{}, nil + } + return templates, nil +} + func (s *IssueGeneratorService) mergeAssignees(genAssignees, templateAssignees []string) []string { assigneeMap := make(map[string]bool) for _, a := range genAssignees { @@ -297,6 +353,95 @@ func (s *IssueGeneratorService) mergeAssignees(genAssignees, templateAssignees [ return result } +// SelectTemplateWithAI uses AI to analyze the context (diff/description) and select the best template +func (s *IssueGeneratorService) SelectTemplateWithAI(ctx context.Context, title, description string, changedFiles, labels []string) (*models.IssueTemplate, error) { + if s.ai == nil || s.templateService == nil { + return nil, nil + } + + templates, err := s.templateService.ListTemplates(ctx) + if err != nil || len(templates) == 0 { + return nil, nil + } + + var templateListBuilder strings.Builder + for _, t := range templates { + templateListBuilder.WriteString(fmt.Sprintf("- %s: %s\n", t.Name, t.About)) + } + + var contextBuilder strings.Builder + if title != "" { + contextBuilder.WriteString(fmt.Sprintf("Title: %s\n", title)) + } + if description != "" { + contextBuilder.WriteString(fmt.Sprintf("Description: %s\n", description)) + } + if len(changedFiles) > 0 { + contextBuilder.WriteString(fmt.Sprintf("Changed files: %s\n", strings.Join(changedFiles, ", "))) + } + if len(labels) > 0 { + contextBuilder.WriteString(fmt.Sprintf("Labels: %s\n", strings.Join(labels, ", "))) + } + + prompt := fmt.Sprintf(`You are an intelligent assistant helping to select the correct issue template for a software project. +Available templates: +%s +Context (Metadata): +%s +Based on the context, select the SINGLE most appropriate template name from the list above. +Respond ONLY with valid JSON in this exact format: +{ + "title": "Template Name", + "description": "", + "labels": [] +} +The title field must contain ONLY the template name exactly as it appears in the list (e.g., "Bug Report", "Feature Request"). +If no template fits perfectly, choose "Custom Issue" or the most generic one.`, templateListBuilder.String(), contextBuilder.String()) + + request := models.IssueGenerationRequest{ + Description: prompt, + Language: "en", + } + + result, err := s.ai.GenerateIssueContent(ctx, request) + if err != nil { + logger.Warn(ctx, "failed to auto-select template via AI", err) + return nil, nil + } + + selectedName := strings.TrimSpace(result.Title) + + logger.Info(ctx, "AI template selection response", + "selectedName", selectedName, + "templates_count", len(templates), + ) + + var bestMatch *models.TemplateMetadata + for i, t := range templates { + logger.Debug(ctx, "checking template match", + "index", i, + "template_name", t.Name, + "selected_name", selectedName, + "exact_match", strings.EqualFold(t.Name, selectedName), + "contains_match", strings.Contains(strings.ToLower(selectedName), strings.ToLower(t.Name))) + if strings.EqualFold(t.Name, selectedName) || strings.Contains(strings.ToLower(selectedName), strings.ToLower(t.Name)) { + bestMatch = &t + break + } + } + + logger.Info(ctx, "template matching result", "found_match", bestMatch != nil) + + if bestMatch != nil { + logger.Info(ctx, "AI auto-selected template", "template", bestMatch.Name) + templateName := strings.TrimSuffix(bestMatch.FilePath, ".yml") + templateName = strings.TrimSuffix(templateName, ".yaml") + templateName = strings.TrimSuffix(templateName, ".md") + return s.templateService.GetTemplateByName(ctx, templateName) + } + return nil, nil +} + func (s *IssueGeneratorService) extractFilesFromDiff(diff string) []string { files := make([]string, 0) seen := make(map[string]bool) diff --git a/internal/services/issue_generator_service_custom_test.go b/internal/services/issue_generator_service_custom_test.go new file mode 100644 index 0000000..272e2c0 --- /dev/null +++ b/internal/services/issue_generator_service_custom_test.go @@ -0,0 +1,473 @@ +package services + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/config" + "github.com/thomas-vilte/matecommit/internal/models" +) + +func TestIssueGeneratorService_SelectTemplateWithAI(t *testing.T) { + t.Run("Success - AI selects Bug Report", func(t *testing.T) { + ctx := context.Background() + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Bug Report", FilePath: "bug.yml", About: "Fix bugs"}, + {Name: "Feature Request", FilePath: "feat.yml", About: "New features"}, + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + + mockTemplateSvc.On("GetTemplateByName", ctx, "bug").Return(&models.IssueTemplate{ + Name: "Bug Report", + FilePath: "bug.yml", + }, nil) + + mockAI := new(MockIssueContentGenerator) + mockAI.On("GenerateIssueContent", ctx, mock.MatchedBy(func(req models.IssueGenerationRequest) bool { + return strings.Contains(req.Description, "Available templates") && + strings.Contains(req.Description, "Based on the context, select") + })).Return(&models.IssueGenerationResult{ + Title: "Bug Report", + }, nil) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "Fixing a panic in main.go", nil, nil) + + assert.NoError(t, err) + assert.NotNil(t, tmpl) + assert.Equal(t, "Bug Report", tmpl.Name) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Returns nil when no templates available", func(t *testing.T) { + ctx := context.Background() + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return([]models.TemplateMetadata{}, nil) + + mockAI := new(MockIssueContentGenerator) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "Some description", nil, nil) + + assert.NoError(t, err) // Returns nil,nil not error when no templates + assert.Nil(t, tmpl) + mockTemplateSvc.AssertExpectations(t) + }) + + t.Run("Returns nil when ListTemplates fails", func(t *testing.T) { + ctx := context.Background() + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return([]models.TemplateMetadata{}, assert.AnError) + + mockAI := new(MockIssueContentGenerator) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "Some description", nil, nil) + + assert.NoError(t, err) // Returns nil,nil not error + assert.Nil(t, tmpl) + mockTemplateSvc.AssertExpectations(t) + }) + + t.Run("Returns nil when no template service configured", func(t *testing.T) { + ctx := context.Background() + + mockAI := new(MockIssueContentGenerator) + + service := NewIssueGeneratorService(nil, mockAI) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "Some description", nil, nil) + + assert.NoError(t, err) // Returns nil,nil not error + assert.Nil(t, tmpl) + }) + + t.Run("Returns nil when AI returns empty title", func(t *testing.T) { + ctx := context.Background() + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Bug Report", FilePath: "bug.yml", About: "Fix bugs"}, + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + + mockAI := new(MockIssueContentGenerator) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(&models.IssueGenerationResult{ + Title: "", + }, nil) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "Some description", nil, nil) + + assert.NoError(t, err) // Returns nil,nil when no match found + assert.Nil(t, tmpl) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Returns nil when AI generation fails", func(t *testing.T) { + ctx := context.Background() + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Bug Report", FilePath: "bug.yml", About: "Fix bugs"}, + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + + mockAI := new(MockIssueContentGenerator) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(nil, assert.AnError) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "Some description", nil, nil) + + assert.NoError(t, err) // Returns nil,nil not error when AI fails + assert.Nil(t, tmpl) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Returns nil when selected template not found in list", func(t *testing.T) { + ctx := context.Background() + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Bug Report", FilePath: "bug.yml", About: "Fix bugs"}, + {Name: "Feature Request", FilePath: "feature_request.yml", About: "New features"}, + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + + // AI selects a template that doesn't match any in the list + mockAI := new(MockIssueContentGenerator) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(&models.IssueGenerationResult{ + Title: "Non Existent Template", + }, nil) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "Some description", nil, nil) + + assert.NoError(t, err) // Returns nil,nil when no match found + assert.Nil(t, tmpl) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Strips extension from FilePath", func(t *testing.T) { + ctx := context.Background() + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Security Issue", FilePath: "security.yaml", About: "Security vulnerabilities"}, + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + + // GetTemplateByName should be called with "security", not "security.yaml" + mockTemplateSvc.On("GetTemplateByName", ctx, "security").Return(&models.IssueTemplate{ + Name: "Security Issue", + FilePath: "security.yaml", + }, nil) + + mockAI := new(MockIssueContentGenerator) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(&models.IssueGenerationResult{ + Title: "Security Issue", + }, nil) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "SQL injection vulnerability", nil, nil) + + assert.NoError(t, err) + assert.NotNil(t, tmpl) + assert.Equal(t, "Security Issue", tmpl.Name) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Matches with different casing", func(t *testing.T) { + ctx := context.Background() + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Feature Request", FilePath: "feature_request.yml", About: "New features"}, + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + + mockTemplateSvc.On("GetTemplateByName", ctx, "feature_request").Return(&models.IssueTemplate{ + Name: "Feature Request", + FilePath: "feature_request.yml", + }, nil) + + mockAI := new(MockIssueContentGenerator) + // AI returns lowercase version + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(&models.IssueGenerationResult{ + Title: "feature request", + }, nil) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "", "Add dark mode", nil, nil) + + assert.NoError(t, err) + assert.NotNil(t, tmpl) + assert.Equal(t, "Feature Request", tmpl.Name) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Uses title and description in prompt", func(t *testing.T) { + ctx := context.Background() + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Bug Report", FilePath: "bug.yml", About: "Fix bugs"}, + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + + mockTemplateSvc.On("GetTemplateByName", ctx, "bug").Return(&models.IssueTemplate{ + Name: "Bug Report", + FilePath: "bug.yml", + }, nil) + + mockAI := new(MockIssueContentGenerator) + mockAI.On("GenerateIssueContent", ctx, mock.MatchedBy(func(req models.IssueGenerationRequest) bool { + return strings.Contains(req.Description, "Test Title") && + strings.Contains(req.Description, "Test Description") && + strings.Contains(req.Description, "main.go") + })).Return(&models.IssueGenerationResult{ + Title: "Bug Report", + }, nil) + + service := NewIssueGeneratorService(nil, mockAI, + WithIssueTemplateService(mockTemplateSvc)) + + tmpl, err := service.SelectTemplateWithAI(ctx, "Test Title", "Test Description", []string{"main.go"}, []string{"bug", "urgent"}) + + assert.NoError(t, err) + assert.NotNil(t, tmpl) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) +} + +func TestIssueGeneratorService_GenerateFromDiff_WithAutoTemplate(t *testing.T) { + t.Run("Success - Auto-selects template and generates issue", func(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{Language: "en"} + + diff := `diff --git a/main.go b/main.go +index 1234567..abcdefg 100644 +--- a/main.go ++++ b/main.go +@@ -10,3 +10,4 @@ func main() { ++ // Fix panic when user is nil ++ if user != nil {` + + mockGit := new(MockGitService) + mockGit.On("GetDiff", ctx).Return(diff, nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{"main.go"}, nil) + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Bug Report", FilePath: "bug.yml", About: "Fix bugs"}, + {Name: "Feature Request", FilePath: "feat.yml", About: "New features"}, + } + + bugTemplate := &models.IssueTemplate{ + Name: "Bug Report", + FilePath: "bug.yml", + Title: "Bug: {{title}}", + BodyContent: "## Description\n{{description}}", + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + mockTemplateSvc.On("GetTemplateByName", ctx, "bug").Return(bugTemplate, nil) + mockTemplateSvc.On("MergeWithGeneratedContent", bugTemplate, mock.AnythingOfType("*models.IssueGenerationResult")). + Return(&models.IssueGenerationResult{ + Title: "Fix null pointer panic in user handler", + Description: "## Description\nFixed panic when user is nil", + Labels: []string{"bug", "fix"}, + }, nil) + + mockAI := new(MockIssueContentGenerator) + // First call: template selection + mockAI.On("GenerateIssueContent", ctx, mock.MatchedBy(func(req models.IssueGenerationRequest) bool { + return strings.Contains(req.Description, "Available templates") + })).Return(&models.IssueGenerationResult{ + Title: "Bug Report", + }, nil).Once() + + // Second call: actual issue generation with template + mockAI.On("GenerateIssueContent", ctx, mock.MatchedBy(func(req models.IssueGenerationRequest) bool { + return req.Template != nil && + req.Template.Name == "Bug Report" && + strings.Contains(req.Diff, "Fix panic") + })).Return(&models.IssueGenerationResult{ + Title: "Fix null pointer panic in user handler", + Description: "## Description\nFixed panic when user is nil", + Labels: []string{"bug", "fix"}, + }, nil).Once() + + service := NewIssueGeneratorService(mockGit, mockAI, + WithIssueConfig(cfg), + WithIssueTemplateService(mockTemplateSvc)) + + result, err := service.GenerateFromDiff(ctx, "fix panic", false, true) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Fix null pointer panic in user handler", result.Title) + assert.Contains(t, result.Labels, "bug") + mockGit.AssertExpectations(t) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Auto-template disabled, no template used", func(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{Language: "en"} + + diff := `diff --git a/feature.go b/feature.go +index 1234567..abcdefg 100644 +--- a/feature.go ++++ b/feature.go +@@ -1,0 +1,5 @@ ++func NewFeature() {` + + mockGit := new(MockGitService) + mockGit.On("GetDiff", ctx).Return(diff, nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{"feature.go"}, nil) + + mockAI := new(MockIssueContentGenerator) + // Only one call: direct issue generation without template + mockAI.On("GenerateIssueContent", ctx, mock.MatchedBy(func(req models.IssueGenerationRequest) bool { + return req.Template == nil && + strings.Contains(req.Diff, "NewFeature") + })).Return(&models.IssueGenerationResult{ + Title: "Add new feature", + Description: "Implemented new feature", + Labels: []string{"feature"}, + }, nil) + + service := NewIssueGeneratorService(mockGit, mockAI, + WithIssueConfig(cfg)) + + result, err := service.GenerateFromDiff(ctx, "", false, false) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Add new feature", result.Title) + mockGit.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Template selection returns nil, falls back to no template", func(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{Language: "en"} + + diff := `diff --git a/test.go b/test.go` + + mockGit := new(MockGitService) + mockGit.On("GetDiff", ctx).Return(diff, nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{"test.go"}, nil) + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return([]models.TemplateMetadata{}, nil) + + mockAI := new(MockIssueContentGenerator) + // Only one call: direct generation because template selection failed + mockAI.On("GenerateIssueContent", ctx, mock.MatchedBy(func(req models.IssueGenerationRequest) bool { + return req.Template == nil + })).Return(&models.IssueGenerationResult{ + Title: "Test change", + Description: "Updated test", + Labels: []string{"test"}, + }, nil) + + service := NewIssueGeneratorService(mockGit, mockAI, + WithIssueConfig(cfg), + WithIssueTemplateService(mockTemplateSvc)) + + result, err := service.GenerateFromDiff(ctx, "", false, true) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Test change", result.Title) + mockGit.AssertExpectations(t) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Template selection fails, continues without template", func(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{Language: "en"} + + diff := `diff --git a/code.go b/code.go` + + mockGit := new(MockGitService) + mockGit.On("GetDiff", ctx).Return(diff, nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{"code.go"}, nil) + + mockTemplateInfo := []models.TemplateMetadata{ + {Name: "Bug Report", FilePath: "bug.yml"}, + } + + mockTemplateSvc := new(MockIssueTemplateService) + mockTemplateSvc.On("ListTemplates", ctx).Return(mockTemplateInfo, nil) + + mockAI := new(MockIssueContentGenerator) + // First call: template selection - AI fails + mockAI.On("GenerateIssueContent", ctx, mock.MatchedBy(func(req models.IssueGenerationRequest) bool { + return strings.Contains(req.Description, "Available templates") + })).Return(nil, assert.AnError).Once() + + // Second call: continues with no template + mockAI.On("GenerateIssueContent", ctx, mock.MatchedBy(func(req models.IssueGenerationRequest) bool { + return req.Template == nil + })).Return(&models.IssueGenerationResult{ + Title: "Code update", + Description: "Updated code", + Labels: []string{"refactor"}, + }, nil).Once() + + service := NewIssueGeneratorService(mockGit, mockAI, + WithIssueConfig(cfg), + WithIssueTemplateService(mockTemplateSvc)) + + result, err := service.GenerateFromDiff(ctx, "", false, true) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "Code update", result.Title) + mockGit.AssertExpectations(t) + mockTemplateSvc.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) +} diff --git a/internal/services/issue_generator_service_test.go b/internal/services/issue_generator_service_test.go index cd29b94..b65895c 100644 --- a/internal/services/issue_generator_service_test.go +++ b/internal/services/issue_generator_service_test.go @@ -2,65 +2,271 @@ package services import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/thomas-vilte/matecommit/internal/config" + domainErrors "github.com/thomas-vilte/matecommit/internal/errors" "github.com/thomas-vilte/matecommit/internal/models" ) -func TestIssueGeneratorService(t *testing.T) { +func TestIssueGeneratorService_GenerateFromDiff(t *testing.T) { ctx := context.Background() cfg := &config.Config{Language: "en"} - t.Run("GenerateFromDiff - Success", func(t *testing.T) { + t.Run("Success - Basic diff with feature", func(t *testing.T) { mockGit := new(MockGitService) mockAI := new(MockIssueContentGenerator) service := NewIssueGeneratorService(mockGit, mockAI, WithIssueConfig(cfg)) - mockGit.On("GetDiff", ctx).Return("diff content", nil) - mockGit.On("GetChangedFiles", ctx).Return([]string{"main.go"}, nil) + realDiff := `diff --git a/internal/services/user_service.go b/internal/services/user_service.go +index 1234567..abcdefg 100644 +--- a/internal/services/user_service.go ++++ b/internal/services/user_service.go +@@ -10,6 +10,12 @@ type UserService struct { + db *sql.DB + } + ++func (s *UserService) CreateUser(ctx context.Context, user *models.User) error { ++ // Implementation for creating a new user ++ return s.db.Create(user) ++} ++ + func (s *UserService) GetUser(ctx context.Context, id int) (*models.User, error) { + return s.db.FindByID(id) + }` + + mockGit.On("GetDiff", ctx).Return(realDiff, nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{"internal/services/user_service.go"}, nil) expectedRequest := models.IssueGenerationRequest{ - Diff: "diff content", - ChangedFiles: []string{"main.go"}, - Hint: "test hint", + Diff: realDiff, + ChangedFiles: []string{"internal/services/user_service.go"}, + Hint: "Add user creation functionality", Language: "en", } + expectedResult := &models.IssueGenerationResult{ + Title: "Add CreateUser method to UserService", + Description: "Implement user creation functionality in the UserService to support new user registration flow.", + Labels: []string{"feature", "enhancement"}, + } + + mockAI.On("GenerateIssueContent", ctx, expectedRequest).Return(expectedResult, nil) + + result, err := service.GenerateFromDiff(ctx, "Add user creation functionality", false, false) + + assert.NoError(t, err) + assert.Equal(t, "Add CreateUser method to UserService", result.Title) + assert.Contains(t, result.Labels, "feature") + mockGit.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Bug fix with test files", func(t *testing.T) { + mockGit := new(MockGitService) + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(mockGit, mockAI, WithIssueConfig(cfg)) + + bugFixDiff := `diff --git a/internal/auth/validator.go b/internal/auth/validator.go +index 1234567..abcdefg 100644 +--- a/internal/auth/validator.go ++++ b/internal/auth/validator.go +@@ -15,7 +15,7 @@ func ValidateToken(token string) error { + return errors.New("invalid token format") + } + +- if len(token) < 10 { ++ if len(token) < 32 { + return errors.New("token too short") + } + +diff --git a/internal/auth/validator_test.go b/internal/auth/validator_test.go +index 1234567..abcdefg 100644 +--- a/internal/auth/validator_test.go ++++ b/internal/auth/validator_test.go +@@ -20,6 +20,11 @@ func TestValidateToken(t *testing.T) { + err := ValidateToken("short") + assert.Error(t, err) + }) ++ ++ t.Run("token with correct length", func(t *testing.T) { ++ err := ValidateToken("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6") ++ assert.NoError(t, err) ++ }) + }` + + mockGit.On("GetDiff", ctx).Return(bugFixDiff, nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{ + "internal/auth/validator.go", + "internal/auth/validator_test.go", + }, nil) + + expectedResult := &models.IssueGenerationResult{ + Title: "Fix token validation length requirement", + Description: "Updated minimum token length from 10 to 32 characters to improve security. Added test coverage for the new validation logic.", + Labels: []string{"bug", "security"}, + } + + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) + + result, err := service.GenerateFromDiff(ctx, "", false, false) + + assert.NoError(t, err) + assert.Equal(t, "Fix token validation length requirement", result.Title) + assert.Contains(t, result.Labels, "test") + mockGit.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Error - No changes in diff", func(t *testing.T) { + mockGit := new(MockGitService) + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(mockGit, mockAI, WithIssueConfig(cfg)) + + mockGit.On("GetDiff", ctx).Return("", nil) + + result, err := service.GenerateFromDiff(ctx, "", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, domainErrors.ErrNoChanges, err) + mockGit.AssertExpectations(t) + }) + + t.Run("Error - Git diff fails", func(t *testing.T) { + mockGit := new(MockGitService) + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(mockGit, mockAI, WithIssueConfig(cfg)) + + gitError := errors.New("git: not a git repository") + mockGit.On("GetDiff", ctx).Return("", gitError) + + result, err := service.GenerateFromDiff(ctx, "", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + mockGit.AssertExpectations(t) + }) + + t.Run("Error - AI service not configured", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewIssueGeneratorService(mockGit, nil, WithIssueConfig(cfg)) + + result, err := service.GenerateFromDiff(ctx, "", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, domainErrors.ErrAPIKeyMissing, err) + }) + + t.Run("Error - AI generation fails", func(t *testing.T) { + mockGit := new(MockGitService) + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(mockGit, mockAI, WithIssueConfig(cfg)) + + mockGit.On("GetDiff", ctx).Return("some diff", nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{"file.go"}, nil) + + aiError := errors.New("AI service rate limit exceeded") + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(nil, aiError) + + result, err := service.GenerateFromDiff(ctx, "", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + mockGit.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Skip labels", func(t *testing.T) { + mockGit := new(MockGitService) + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(mockGit, mockAI, WithIssueConfig(cfg)) + + mockGit.On("GetDiff", ctx).Return("diff content", nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{"main.go"}, nil) + expectedResult := &models.IssueGenerationResult{ Title: "Test Issue", Description: "Test Description", Labels: []string{"feature"}, } - mockAI.On("GenerateIssueContent", ctx, expectedRequest).Return(expectedResult, nil) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromDiff(ctx, "test hint", false) + result, err := service.GenerateFromDiff(ctx, "", true, false) assert.NoError(t, err) - assert.Equal(t, "Test Issue", result.Title) - assert.Contains(t, result.Labels, "feature") + assert.NotContains(t, result.Labels, "test") mockGit.AssertExpectations(t) mockAI.AssertExpectations(t) }) +} - t.Run("GenerateFromDescription - Success", func(t *testing.T) { +func TestIssueGeneratorService_GenerateFromDescription(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{Language: "en"} + + t.Run("Success - Feature request", func(t *testing.T) { mockAI := new(MockIssueContentGenerator) service := NewIssueGeneratorService(nil, mockAI, WithIssueConfig(cfg)) + description := "We need to add OAuth2 authentication support for third-party integrations" expectedRequest := models.IssueGenerationRequest{ - Description: "manual description", + Description: description, Language: "en", } + expectedResult := &models.IssueGenerationResult{ + Title: "Add OAuth2 authentication support", + Description: "Implement OAuth2 authentication flow to enable third-party integrations with popular services like Google, GitHub, and Microsoft.", + Labels: []string{"feature", "authentication"}, + } + + mockAI.On("GenerateIssueContent", ctx, expectedRequest).Return(expectedResult, nil) + + result, err := service.GenerateFromDescription(ctx, description, false, false) + + assert.NoError(t, err) + assert.Equal(t, "Add OAuth2 authentication support", result.Title) + assert.Contains(t, result.Labels, "feature") + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Bug report", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(nil, mockAI, WithIssueConfig(cfg)) + + description := "Users are experiencing timeout errors when uploading files larger than 10MB" + expectedResult := &models.IssueGenerationResult{ + Title: "Fix file upload timeout for large files", + Description: "Users report timeout errors when uploading files larger than 10MB. Need to increase timeout limits and implement chunked upload for better reliability.", + Labels: []string{"bug", "upload"}, + } + + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) + + result, err := service.GenerateFromDescription(ctx, description, false, false) + + assert.NoError(t, err) + assert.Equal(t, "Fix file upload timeout for large files", result.Title) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Skip labels", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(nil, mockAI, WithIssueConfig(cfg)) + expectedResult := &models.IssueGenerationResult{ Title: "Manual Issue", Description: "Manual Description", + Labels: []string{"feature"}, } - mockAI.On("GenerateIssueContent", ctx, expectedRequest).Return(expectedResult, nil) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromDescription(ctx, "manual description", true) + result, err := service.GenerateFromDescription(ctx, "manual description", true, false) assert.NoError(t, err) assert.Equal(t, "Manual Issue", result.Title) @@ -68,37 +274,164 @@ func TestIssueGeneratorService(t *testing.T) { mockAI.AssertExpectations(t) }) - t.Run("GenerateFromPR - Success", func(t *testing.T) { + t.Run("Error - Empty description", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(nil, mockAI, WithIssueConfig(cfg)) + + result, err := service.GenerateFromDescription(ctx, "", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("Error - AI service not configured", func(t *testing.T) { + service := NewIssueGeneratorService(nil, nil, WithIssueConfig(cfg)) + + result, err := service.GenerateFromDescription(ctx, "some description", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, domainErrors.ErrAPIKeyMissing, err) + }) + + t.Run("Error - AI generation fails", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(nil, mockAI, WithIssueConfig(cfg)) + + aiError := errors.New("AI service unavailable") + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(nil, aiError) + + result, err := service.GenerateFromDescription(ctx, "description", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + mockAI.AssertExpectations(t) + }) +} + +func TestIssueGeneratorService_GenerateFromPR(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{Language: "en"} + + t.Run("Success - Feature PR", func(t *testing.T) { mockAI := new(MockIssueContentGenerator) mockVCS := new(MockVCSClient) service := NewIssueGeneratorService(nil, mockAI, WithIssueVCSClient(mockVCS), WithIssueConfig(cfg)) prData := models.PRData{ - Title: "PR Title", - Description: "PR Description", - Commits: []models.Commit{{Message: "commit 1"}}, - Diff: "diff --git a/file.go b/file.go\n...", + Title: "Add user profile page", + Description: "This PR implements a new user profile page with avatar upload and bio editing capabilities.", + Commits: []models.Commit{ + {Message: "feat: add profile page component"}, + {Message: "feat: implement avatar upload"}, + {Message: "test: add profile page tests"}, + }, + Diff: `diff --git a/frontend/pages/profile.tsx b/frontend/pages/profile.tsx +new file mode 100644 +index 0000000..1234567 +--- /dev/null ++++ b/frontend/pages/profile.tsx +@@ -0,0 +1,50 @@ ++import React from 'react'; ++ ++export const ProfilePage = () => { ++ return
Profile Page
; ++};`, } - mockVCS.On("GetPR", ctx, 1).Return(prData, nil) + mockVCS.On("GetPR", ctx, 42).Return(prData, nil) expectedResult := &models.IssueGenerationResult{ - Title: "PR based Issue", - Description: "PR based Description", - Labels: []string{"fix"}, + Title: "Implement user profile page", + Description: "Add comprehensive user profile page with avatar upload and bio editing features.", + Labels: []string{"feature", "frontend"}, } mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromPR(ctx, 1, "", false) + result, err := service.GenerateFromPR(ctx, 42, "", false, false) assert.NoError(t, err) - assert.Equal(t, "PR based Issue", result.Title) - assert.Contains(t, result.Description, "Related PR: #1") + assert.Equal(t, "Implement user profile page", result.Title) + assert.Contains(t, result.Description, "Related PR: #42") + assert.Contains(t, result.Labels, "feature") mockVCS.AssertExpectations(t) mockAI.AssertExpectations(t) }) - t.Run("GenerateWithTemplate - Success", func(t *testing.T) { + t.Run("Success - Bug fix PR with hint", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + mockVCS := new(MockVCSClient) + service := NewIssueGeneratorService(nil, mockAI, WithIssueVCSClient(mockVCS), WithIssueConfig(cfg)) + + prData := models.PRData{ + Title: "Fix memory leak in cache service", + Description: "Resolves memory leak caused by unbounded cache growth", + Commits: []models.Commit{ + {Message: "fix: add cache eviction policy"}, + }, + Diff: "diff --git a/cache/service.go b/cache/service.go\n...", + } + mockVCS.On("GetPR", ctx, 123).Return(prData, nil) + + expectedResult := &models.IssueGenerationResult{ + Title: "Memory leak in cache service", + Description: "Cache service experiences memory leak due to unbounded growth. Implement LRU eviction policy.", + Labels: []string{"bug", "performance"}, + } + + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) + + result, err := service.GenerateFromPR(ctx, 123, "Focus on performance impact", false, false) + + assert.NoError(t, err) + assert.Contains(t, result.Description, "Related PR: #123") + mockVCS.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Error - VCS client not configured", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(nil, mockAI, WithIssueConfig(cfg)) + + result, err := service.GenerateFromPR(ctx, 1, "", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, domainErrors.ErrConfigMissing, err) + }) + + t.Run("Error - AI service not configured", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := NewIssueGeneratorService(nil, nil, WithIssueVCSClient(mockVCS), WithIssueConfig(cfg)) + + result, err := service.GenerateFromPR(ctx, 1, "", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, domainErrors.ErrAPIKeyMissing, err) + }) + + t.Run("Error - PR not found", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + mockVCS := new(MockVCSClient) + service := NewIssueGeneratorService(nil, mockAI, WithIssueVCSClient(mockVCS), WithIssueConfig(cfg)) + + prError := errors.New("PR #999 not found") + mockVCS.On("GetPR", ctx, 999).Return(models.PRData{}, prError) + + result, err := service.GenerateFromPR(ctx, 999, "", false, false) + + assert.Error(t, err) + assert.Nil(t, result) + mockVCS.AssertExpectations(t) + }) +} + +func TestIssueGeneratorService_GenerateWithTemplate(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{Language: "en"} + + t.Run("Success - Bug report template with diff", func(t *testing.T) { mockGit := new(MockGitService) mockAI := new(MockIssueContentGenerator) mockTemplate := new(MockIssueTemplateService) @@ -108,36 +441,103 @@ func TestIssueGeneratorService(t *testing.T) { Name: "bug_report", Title: "[BUG] ", Labels: []string{"bug"}, - Assignees: []string{"tester"}, + Assignees: []string{"maintainer"}, } generated := &models.IssueGenerationResult{ - Title: "something broke", - Description: "it fails", - Labels: []string{"fix"}, + Title: "Application crashes on startup", + Description: "The application crashes when launched with invalid config", + Labels: []string{"crash"}, } merged := &models.IssueGenerationResult{ - Title: "[BUG] something broke", - Description: "it fails\n---\nTemplate body", - Labels: []string{"bug", "fix"}, - Assignees: []string{"tester"}, + Title: "[BUG] Application crashes on startup", + Description: "The application crashes when launched with invalid config\n---\n## Bug Report Template\nPlease provide details...", + Labels: []string{"bug", "crash"}, + Assignees: []string{"maintainer"}, } mockTemplate.On("GetTemplateByName", ctx, "bug_report").Return(template, nil) mockGit.On("GetDiff", ctx).Return("some changes", nil) - mockGit.On("GetChangedFiles", ctx).Return([]string{"file.go"}, nil) + mockGit.On("GetChangedFiles", ctx).Return([]string{"config/loader.go"}, nil) mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(generated, nil) mockTemplate.On("MergeWithGeneratedContent", template, mock.Anything).Return(merged) result, err := service.GenerateWithTemplate(ctx, "bug_report", "", true, "", false) assert.NoError(t, err) - assert.Equal(t, "[BUG] something broke", result.Title) + assert.Equal(t, "[BUG] Application crashes on startup", result.Title) assert.Contains(t, result.Labels, "bug") - assert.Contains(t, result.Assignees, "tester") + assert.Contains(t, result.Assignees, "maintainer") + mockTemplate.AssertExpectations(t) + mockGit.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Success - Feature request template with description", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + mockTemplate := new(MockIssueTemplateService) + service := NewIssueGeneratorService(nil, mockAI, WithIssueTemplateService(mockTemplate), WithIssueConfig(cfg)) + + template := &models.IssueTemplate{ + Name: "feature_request", + Title: "[FEATURE] ", + Labels: []string{"enhancement"}, + } + generated := &models.IssueGenerationResult{ + Title: "Add dark mode support", + Description: "Users want dark mode for better night-time viewing", + Labels: []string{"ui"}, + } + merged := &models.IssueGenerationResult{ + Title: "[FEATURE] Add dark mode support", + Description: "Users want dark mode for better night-time viewing\n---\n## Feature Request", + Labels: []string{"enhancement", "ui"}, + } + + mockTemplate.On("GetTemplateByName", ctx, "feature_request").Return(template, nil) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(generated, nil) + mockTemplate.On("MergeWithGeneratedContent", template, mock.Anything).Return(merged) + + result, err := service.GenerateWithTemplate(ctx, "feature_request", "", false, "Add dark mode", false) + + assert.NoError(t, err) + assert.Equal(t, "[FEATURE] Add dark mode support", result.Title) + assert.Contains(t, result.Labels, "enhancement") + mockTemplate.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("Error - Template not found", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + mockTemplate := new(MockIssueTemplateService) + service := NewIssueGeneratorService(nil, mockAI, WithIssueTemplateService(mockTemplate), WithIssueConfig(cfg)) + + templateError := errors.New("template not found") + mockTemplate.On("GetTemplateByName", ctx, "nonexistent").Return(nil, templateError) + + result, err := service.GenerateWithTemplate(ctx, "nonexistent", "", false, "desc", false) + + assert.Error(t, err) + assert.Nil(t, result) + mockTemplate.AssertExpectations(t) + }) + + t.Run("Error - No input provided", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + mockTemplate := new(MockIssueTemplateService) + service := NewIssueGeneratorService(nil, mockAI, WithIssueTemplateService(mockTemplate), WithIssueConfig(cfg)) + + template := &models.IssueTemplate{Name: "test"} + mockTemplate.On("GetTemplateByName", ctx, "test").Return(template, nil) + + result, err := service.GenerateWithTemplate(ctx, "test", "", false, "", false) + + assert.Error(t, err) + assert.Nil(t, result) + mockTemplate.AssertExpectations(t) }) } -func TestInferBranchName(t *testing.T) { +func TestIssueGeneratorService_InferBranchName(t *testing.T) { service := &IssueGeneratorService{} tests := []struct { @@ -147,22 +547,22 @@ func TestInferBranchName(t *testing.T) { expected string }{ { - name: "fix label has priority", + name: "fix label has highest priority", issueNumber: 123, labels: []string{"feature", "fix", "docs"}, expected: "fix/issue-123", }, { - name: "feature is default", + name: "refactor has priority over docs", issueNumber: 456, - labels: []string{"feature"}, - expected: "feature/issue-456", + labels: []string{"docs", "refactor", "test"}, + expected: "refactor/issue-456", }, { - name: "refactor has higher priority than docs", + name: "feature is default when present", issueNumber: 789, - labels: []string{"docs", "refactor"}, - expected: "refactor/issue-789", + labels: []string{"feature"}, + expected: "feature/issue-789", }, { name: "no labels defaults to feature", @@ -170,6 +570,24 @@ func TestInferBranchName(t *testing.T) { labels: []string{}, expected: "feature/issue-999", }, + { + name: "test label", + issueNumber: 111, + labels: []string{"test"}, + expected: "test/issue-111", + }, + { + name: "docs has priority over infra", + issueNumber: 222, + labels: []string{"infra", "docs"}, + expected: "docs/issue-222", + }, + { + name: "unknown labels default to feature", + issueNumber: 333, + labels: []string{"custom", "unknown"}, + expected: "feature/issue-333", + }, } for _, tt := range tests { @@ -179,3 +597,226 @@ func TestInferBranchName(t *testing.T) { }) } } + +func TestIssueGeneratorService_CreateIssue(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + result := &models.IssueGenerationResult{ + Title: "Test Issue", + Description: "Test Description", + Labels: []string{"bug"}, + } + assignees := []string{"user1", "user2"} + + expectedIssue := &models.Issue{ + Number: 42, + Title: "Test Issue", + Description: "Test Description", + Labels: []string{"bug"}, + URL: "https://github.com/owner/repo/issues/42", + } + + mockVCS.On("CreateIssue", ctx, "Test Issue", "Test Description", []string{"bug"}, assignees). + Return(expectedIssue, nil) + + issue, err := service.CreateIssue(ctx, result, assignees) + + assert.NoError(t, err) + assert.Equal(t, 42, issue.Number) + assert.Equal(t, "Test Issue", issue.Title) + mockVCS.AssertExpectations(t) + }) +} + +func TestIssueGeneratorService_GetAuthenticatedUser(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + mockVCS.On("GetAuthenticatedUser", ctx).Return("testuser", nil) + + username, err := service.GetAuthenticatedUser(ctx) + + assert.NoError(t, err) + assert.Equal(t, "testuser", username) + mockVCS.AssertExpectations(t) + }) + + t.Run("Error", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + vcsError := errors.New("authentication failed") + mockVCS.On("GetAuthenticatedUser", ctx).Return("", vcsError) + + username, err := service.GetAuthenticatedUser(ctx) + + assert.Error(t, err) + assert.Empty(t, username) + mockVCS.AssertExpectations(t) + }) +} + +func TestIssueGeneratorService_LinkIssueToPR(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + prData := models.PRData{ + Title: "Fix bug", + Description: "This fixes the bug", + } + + mockVCS.On("GetPR", ctx, 10).Return(prData, nil) + mockVCS.On("UpdatePR", ctx, 10, models.PRSummary{ + Title: "Fix bug", + Body: "This fixes the bug\n\nCloses #42", + }).Return(nil) + + err := service.LinkIssueToPR(ctx, 10, 42) + + assert.NoError(t, err) + mockVCS.AssertExpectations(t) + }) + + t.Run("Error - VCS client not configured", func(t *testing.T) { + service := &IssueGeneratorService{} + + err := service.LinkIssueToPR(ctx, 10, 42) + + assert.Error(t, err) + assert.Equal(t, domainErrors.ErrConfigMissing, err) + }) + + t.Run("Error - Get PR fails", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + prError := errors.New("PR not found") + mockVCS.On("GetPR", ctx, 10).Return(models.PRData{}, prError) + + err := service.LinkIssueToPR(ctx, 10, 42) + + assert.Error(t, err) + mockVCS.AssertExpectations(t) + }) + + t.Run("Error - Update PR fails", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + prData := models.PRData{ + Title: "Fix bug", + Description: "This fixes the bug", + } + + updateError := errors.New("update failed") + mockVCS.On("GetPR", ctx, 10).Return(prData, nil) + mockVCS.On("UpdatePR", ctx, 10, mock.Anything).Return(updateError) + + err := service.LinkIssueToPR(ctx, 10, 42) + + assert.Error(t, err) + mockVCS.AssertExpectations(t) + }) +} + +func TestIssueGeneratorService_SuggestTemplates(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + mockTemplate := new(MockIssueTemplateService) + service := &IssueGeneratorService{templateService: mockTemplate} + + expectedTemplates := []models.TemplateMetadata{ + {Name: "bug_report", FilePath: ".github/ISSUE_TEMPLATE/bug_report.md"}, + {Name: "feature_request", FilePath: ".github/ISSUE_TEMPLATE/feature_request.md"}, + } + + mockTemplate.On("ListTemplates", ctx).Return(expectedTemplates, nil) + + templates, err := service.SuggestTemplates(ctx) + + assert.NoError(t, err) + assert.Len(t, templates, 2) + assert.Equal(t, "bug_report", templates[0].Name) + mockTemplate.AssertExpectations(t) + }) + + t.Run("No template service", func(t *testing.T) { + service := &IssueGeneratorService{} + + templates, err := service.SuggestTemplates(ctx) + + assert.NoError(t, err) + assert.Empty(t, templates) + }) + + t.Run("Error - List templates fails", func(t *testing.T) { + mockTemplate := new(MockIssueTemplateService) + service := &IssueGeneratorService{templateService: mockTemplate} + + templateError := errors.New("failed to list templates") + mockTemplate.On("ListTemplates", ctx).Return([]models.TemplateMetadata{}, templateError) + + templates, err := service.SuggestTemplates(ctx) + + assert.NoError(t, err) + assert.Empty(t, templates) + mockTemplate.AssertExpectations(t) + }) +} + +func TestIssueGeneratorService_ExtractFilesFromDiff(t *testing.T) { + service := &IssueGeneratorService{} + + t.Run("Extract files from standard diff", func(t *testing.T) { + diff := `diff --git a/internal/services/user.go b/internal/services/user.go +index 1234567..abcdefg 100644 +--- a/internal/services/user.go ++++ b/internal/services/user.go +@@ -10,6 +10,12 @@ +diff --git a/internal/models/user.go b/internal/models/user.go +index 9876543..fedcba9 100644 +--- a/internal/models/user.go ++++ b/internal/models/user.go` + + files := service.extractFilesFromDiff(diff) + + assert.Len(t, files, 2) + assert.Contains(t, files, "internal/services/user.go") + assert.Contains(t, files, "internal/models/user.go") + }) + + t.Run("Exclude /dev/null", func(t *testing.T) { + diff := `diff --git a/deleted.go b/deleted.go +deleted file mode 100644 +--- a/deleted.go ++++ /dev/null` + + files := service.extractFilesFromDiff(diff) + + assert.NotContains(t, files, "/dev/null") + }) + + t.Run("No duplicates", func(t *testing.T) { + diff := `diff --git a/file.go b/file.go +--- a/file.go ++++ b/file.go +--- a/file.go ++++ b/file.go` + + files := service.extractFilesFromDiff(diff) + + assert.Len(t, files, 1) + assert.Equal(t, "file.go", files[0]) + }) +} diff --git a/internal/services/issue_template_service.go b/internal/services/issue_template_service.go index ada0591..7e75a58 100644 --- a/internal/services/issue_template_service.go +++ b/internal/services/issue_template_service.go @@ -76,12 +76,24 @@ func (s *IssueTemplateService) ListTemplates(ctx context.Context) ([]models.Temp templates := make([]models.TemplateMetadata, 0) for _, entry := range entries { - if entry.IsDir() || (!strings.HasSuffix(entry.Name(), ".yml") && !strings.HasSuffix(entry.Name(), ".yaml")) { + if entry.IsDir() { + continue + } + + if !strings.HasSuffix(entry.Name(), ".yml") && + !strings.HasSuffix(entry.Name(), ".yaml") && + !strings.HasSuffix(entry.Name(), ".md") { continue } filePath := filepath.Join(templatesDir, entry.Name()) - template, err := s.LoadTemplate(ctx, filePath) + var template *models.IssueTemplate + + if strings.HasSuffix(entry.Name(), ".md") { + template, err = s.LoadMarkdownTemplate(ctx, filePath) + } else { + template, err = s.LoadTemplate(ctx, filePath) + } if err != nil { logger.Warn(ctx, "skipping invalid template", "path", filePath, "error", err) continue @@ -158,6 +170,12 @@ func (s *IssueTemplateService) InitializeTemplates(ctx context.Context, force bo "bug_report.yml": s.buildTemplateContent("bug_report"), "feature_request.yml": s.buildTemplateContent("feature_request"), "custom.yml": s.buildTemplateContent("custom"), + "performance.yml": s.buildPerformanceTemplate(), + "documentation.yml": s.buildDocumentationTemplate(), + "security.yml": s.buildSecurityTemplate(), + "tech_debt.yml": s.buildTechDebtTemplate(), + "question.yml": s.buildQuestionTemplate(), + "dependency.yml": s.buildDependencyTemplate(), } created := 0 @@ -187,6 +205,168 @@ func (s *IssueTemplateService) InitializeTemplates(ctx context.Context, force bo return nil } +// GetPRTemplatesDir returns the directory where PR templates are stored +func (s *IssueTemplateService) GetPRTemplatesDir(ctx context.Context) (string, error) { + cwd, err := os.Getwd() + if err != nil { + logger.Error(ctx, "failed to get current working directory", err) + return "", domainErrors.NewAppError(domainErrors.TypeInternal, "failed to get current working directory", err) + } + provider := strings.ToLower(s.config.ActiveVCSProvider) + var templatesDir string + switch provider { + case "gitlab": + templatesDir = filepath.Join(cwd, ".gitlab", "merge_request_templates") + case "github": + fallthrough + default: + templatesDir = filepath.Join(cwd, ".github", "PULL_REQUEST_TEMPLATE") + } + logger.Debug(ctx, "identified PR templates directory", "provider", provider, "path", templatesDir) + return templatesDir, nil +} + +func (s *IssueTemplateService) ListPRTemplates(ctx context.Context) ([]models.TemplateMetadata, error) { + templatesDir, err := s.GetPRTemplatesDir(ctx) + if err != nil { + return nil, err + } + + templates := make([]models.TemplateMetadata, 0) + + singleTemplatePath := filepath.Join(filepath.Dir(templatesDir), "PULL_REQUEST_TEMPLATE.md") + if _, err := os.Stat(singleTemplatePath); err == nil { + template, err := s.LoadMarkdownTemplate(ctx, singleTemplatePath) + if err != nil { + logger.Warn(ctx, "failed to load single PR template", "path", singleTemplatePath, "error", err) + } else { + templates = append(templates, models.TemplateMetadata{ + Name: "Default PR Template", + About: template.GetAbout(), + FilePath: "PULL_REQUEST_TEMPLATE.md", + }) + } + } + + if _, err := os.Stat(templatesDir); os.IsNotExist(err) { + logger.Debug(ctx, "PR templates directory does not exist", "path", templatesDir) + return templates, nil + } + + entries, err := os.ReadDir(templatesDir) + if err != nil { + logger.Error(ctx, "failed to read PR templates directory", err, "path", templatesDir) + return templates, nil + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if !strings.HasSuffix(entry.Name(), ".md") && + !strings.HasSuffix(entry.Name(), ".yml") && + !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + + filePath := filepath.Join(templatesDir, entry.Name()) + var template *models.IssueTemplate + var err error + + if strings.HasSuffix(entry.Name(), ".md") { + template, err = s.LoadMarkdownTemplate(ctx, filePath) + } else { + template, err = s.LoadTemplate(ctx, filePath) + } + if err != nil { + logger.Warn(ctx, "skipping invalid PR template", "path", filePath, "error", err) + continue + } + + templates = append(templates, models.TemplateMetadata{ + Name: entry.Name(), + About: template.GetAbout(), + FilePath: entry.Name(), + }) + } + + logger.Debug(ctx, "listed PR templates", + "count", len(templates), + ) + return templates, nil +} + +// LoadMarkdownTemplate loads a Markdown template file +func (s *IssueTemplateService) LoadMarkdownTemplate(ctx context.Context, filePath string) (*models.IssueTemplate, error) { + content, err := os.ReadFile(filePath) + if err != nil { + logger.Error(ctx, "failed to read markdown template file", err, "path", filePath) + return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, fmt.Sprintf("failed to read template file: %s", filePath), err) + } + + return s.parseMarkdownTemplate(ctx, string(content), filePath) +} + +// parseMarkdownTemplate parses a Markdown template, extracting YAML frontmatter if present +func (s *IssueTemplateService) parseMarkdownTemplate(ctx context.Context, content string, filePath string) (*models.IssueTemplate, error) { + template := &models.IssueTemplate{ + FilePath: filePath, + } + + if strings.HasPrefix(content, "---\n") { + parts := strings.SplitN(content, "---\n", 3) + if len(parts) >= 3 { + frontmatter := parts[1] + if err := yaml.Unmarshal([]byte(frontmatter), template); err != nil { + logger.Warn(ctx, "failed to parse YAML frontmatter, using as plain markdown", "path", filePath, "error", err) + } + template.BodyContent = strings.TrimSpace(parts[2]) + } else { + template.BodyContent = content + } + } else { + template.BodyContent = content + } + + if template.Name == "" { + template.Name = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + } + + logger.Debug(ctx, "successfully loaded/parsed markdown template", "name", template.Name, "path", filePath) + return template, nil +} + +// GetPRTemplate loads a specific PR template by name +func (s *IssueTemplateService) GetPRTemplate(ctx context.Context, name string) (*models.IssueTemplate, error) { + templatesDir, err := s.GetPRTemplatesDir(ctx) + if err != nil { + return nil, err + } + singleTemplatePath := filepath.Join(filepath.Dir(templatesDir), "PULL_REQUEST_TEMPLATE.md") + if name == "" || name == "PULL_REQUEST_TEMPLATE.md" { + if _, err := os.Stat(singleTemplatePath); err == nil { + return s.LoadMarkdownTemplate(ctx, singleTemplatePath) + } + } + possiblePaths := []string{ + filepath.Join(templatesDir, name+".md"), + filepath.Join(templatesDir, name+".yml"), + filepath.Join(templatesDir, name+".yaml"), + filepath.Join(templatesDir, name), + } + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + if strings.HasSuffix(path, ".md") { + return s.LoadMarkdownTemplate(ctx, path) + } + return s.LoadTemplate(ctx, path) + } + } + logger.Warn(ctx, "PR template not found by name", "name", name, "searched_paths", possiblePaths) + return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, fmt.Sprintf("PR template '%s' not found", name), nil) +} + func (s *IssueTemplateService) buildTemplateContent(templateType string) string { switch templateType { case "bug_report": @@ -382,7 +562,202 @@ func (s *IssueTemplateService) buildCustomTemplate() string { return string(content) } +func (s *IssueTemplateService) buildPerformanceTemplate() string { + template := map[string]interface{}{ + "name": "Performance Issue", + "description": "Report a performance issue or inefficiency", + "title": "[PERF] ", + "labels": []string{"performance", "optimization"}, + "body": []map[string]interface{}{ + { + "type": "markdown", + "attributes": map[string]string{ + "value": "Thanks for helping us make things faster! Please describe the performance issue in detail.", + }, + }, + { + "type": "textarea", + "id": "description", + "attributes": map[string]interface{}{ + "label": "Description", + "description": "What is slow or inefficient?", + "placeholder": "The dashboard takes 5 seconds to load...", + }, + "validations": map[string]bool{"required": true}, + }, + { + "type": "input", + "id": "metric", + "attributes": map[string]interface{}{ + "label": "Metric (optional)", + "description": "e.g., Response time, CPU usage, Memory", + "placeholder": "500ms -> 2s", + }, + }, + { + "type": "textarea", + "id": "repro", + "attributes": map[string]interface{}{ + "label": "Steps to reproduce", + "description": "How can we observe this?", + }, + }, + }, + } + content, _ := yaml.Marshal(template) + return string(content) +} +func (s *IssueTemplateService) buildDocumentationTemplate() string { + template := map[string]interface{}{ + "name": "Documentation", + "description": "Improvements or additions to documentation", + "title": "[DOCS] ", + "labels": []string{"documentation"}, + "body": []map[string]interface{}{ + { + "type": "textarea", + "id": "description", + "attributes": map[string]interface{}{ + "label": "Description", + "description": "What needs to be documented or improved?", + }, + "validations": map[string]bool{"required": true}, + }, + { + "type": "textarea", + "id": "location", + "attributes": map[string]interface{}{ + "label": "Relevant files/sections", + "description": "Where should this check go?", + }, + }, + }, + } + content, _ := yaml.Marshal(template) + return string(content) +} +func (s *IssueTemplateService) buildSecurityTemplate() string { + template := map[string]interface{}{ + "name": "Security Vulnerability", + "description": "Report a security vulnerability", + "title": "[SECURITY] ", + "labels": []string{"security", "critical"}, + "body": []map[string]interface{}{ + { + "type": "markdown", + "attributes": map[string]string{ + "value": "**IMPORTANT:** Please do not disclose security vulnerabilities publicly until they have been addressed.", + }, + }, + { + "type": "textarea", + "id": "description", + "attributes": map[string]interface{}{ + "label": "Vulnerability Description", + "description": "Describe the security issue.", + }, + "validations": map[string]bool{"required": true}, + }, + { + "type": "textarea", + "id": "impact", + "attributes": map[string]interface{}{ + "label": "Impact", + "description": "What is the potential impact of this vulnerability?", + }, + }, + }, + } + content, _ := yaml.Marshal(template) + return string(content) +} +func (s *IssueTemplateService) buildTechDebtTemplate() string { + template := map[string]interface{}{ + "name": "Tech Debt / Refactor", + "description": "Propose a refactoring or technical improvement", + "title": "[REFACTOR] ", + "labels": []string{"refactor", "tech-debt"}, + "body": []map[string]interface{}{ + { + "type": "textarea", + "id": "description", + "attributes": map[string]interface{}{ + "label": "Description", + "description": "What code needs refactoring?", + }, + "validations": map[string]bool{"required": true}, + }, + { + "type": "textarea", + "id": "reason", + "attributes": map[string]interface{}{ + "label": "Reason", + "description": "Why should we do this? (e.g. readability, maintainability)", + }, + }, + }, + } + content, _ := yaml.Marshal(template) + return string(content) +} +func (s *IssueTemplateService) buildQuestionTemplate() string { + template := map[string]interface{}{ + "name": "Question", + "description": "Ask a question about the project", + "title": "[QUESTION] ", + "labels": []string{"question"}, + "body": []map[string]interface{}{ + { + "type": "textarea", + "id": "question", + "attributes": map[string]interface{}{ + "label": "Question", + "description": "What would you like to know?", + }, + "validations": map[string]bool{"required": true}, + }, + }, + } + content, _ := yaml.Marshal(template) + return string(content) +} +func (s *IssueTemplateService) buildDependencyTemplate() string { + template := map[string]interface{}{ + "name": "Dependency Update", + "description": "Update a project dependency", + "title": "[DEPENDENCY] ", + "labels": []string{"dependencies"}, + "body": []map[string]interface{}{ + { + "type": "input", + "id": "package", + "attributes": map[string]interface{}{ + "label": "Package Name", + }, + "validations": map[string]bool{"required": true}, + }, + { + "type": "textarea", + "id": "reason", + "attributes": map[string]interface{}{ + "label": "Reason for update", + "description": "Security fix, new features, etc.", + }, + }, + }, + } + content, _ := yaml.Marshal(template) + return string(content) +} + func (s *IssueTemplateService) MergeWithGeneratedContent(template *models.IssueTemplate, generated *models.IssueGenerationResult) *models.IssueGenerationResult { + ctx := context.Background() + + logger.Debug(ctx, "merging template with generated content", + "template_title", template.Title, + "generated_title", generated.Title, + "generated_description_length", len(generated.Description)) + result := &models.IssueGenerationResult{ Labels: make([]string, 0), Assignees: make([]string, 0), @@ -399,7 +774,6 @@ func (s *IssueTemplateService) MergeWithGeneratedContent(template *models.IssueT descBuilder.WriteString(generated.Description) descBuilder.WriteString("\n\n") - // Only for .md templates that have Body as a string if template.BodyContent != "" { descBuilder.WriteString("---\n\n") descBuilder.WriteString(template.BodyContent) @@ -407,6 +781,10 @@ func (s *IssueTemplateService) MergeWithGeneratedContent(template *models.IssueT result.Description = descBuilder.String() + logger.Debug(ctx, "merge result", + "result_title", result.Title, + "result_description_length", len(result.Description)) + labelMap := make(map[string]bool) for _, label := range template.Labels { if label != "" { diff --git a/internal/services/issue_template_service_test.go b/internal/services/issue_template_service_test.go index afd0678..ab2e45f 100644 --- a/internal/services/issue_template_service_test.go +++ b/internal/services/issue_template_service_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/models" + "gopkg.in/yaml.v3" ) func TestIssueTemplateService_GetTemplatesDir(t *testing.T) { @@ -145,14 +146,14 @@ func TestIssueTemplateService_FilesystemOps(t *testing.T) { t.Run("ListTemplates", func(t *testing.T) { templates, err := service.ListTemplates(context.Background()) assert.NoError(t, err) - assert.Len(t, templates, 3) + assert.Len(t, templates, 9) err = os.WriteFile(filepath.Join(tmpDir, ".github", "ISSUE_TEMPLATE", "test.txt"), []byte("..."), 0644) require.NoError(t, err) templates, err = service.ListTemplates(context.Background()) assert.NoError(t, err) - assert.Len(t, templates, 3) + assert.Len(t, templates, 9) }) t.Run("GetTemplateByName", func(t *testing.T) { @@ -283,3 +284,81 @@ func TestIssueTemplateService_MergeWithGeneratedContent_Realistic(t *testing.T) assert.Equal(t, "[BUG] Server error 500", result.Title) assert.Contains(t, result.Description, "- Go to /home") } + +func TestIssueTemplateService_PRTemplates(t *testing.T) { + tmpDir := t.TempDir() + + origCwd, _ := os.Getwd() + err := os.Chdir(tmpDir) + require.NoError(t, err) + defer func() { _ = os.Chdir(origCwd) }() + + cfg := &config.Config{ActiveVCSProvider: "github"} + service := NewIssueTemplateService(WithTemplateConfig(cfg)) + + t.Run("GetPRTemplatesDir", func(t *testing.T) { + dir, err := service.GetPRTemplatesDir(context.Background()) + assert.NoError(t, err) + assert.Contains(t, dir, ".github/PULL_REQUEST_TEMPLATE") + }) + + t.Run("ListPRTemplates - Single File", func(t *testing.T) { + _ = os.MkdirAll(filepath.Join(tmpDir, ".github"), 0755) + err := os.WriteFile(filepath.Join(tmpDir, ".github", "PULL_REQUEST_TEMPLATE.md"), []byte("## Template"), 0644) + require.NoError(t, err) + + templates, err := service.ListPRTemplates(context.Background()) + assert.NoError(t, err) + assert.Len(t, templates, 1) + assert.Equal(t, "PULL_REQUEST_TEMPLATE.md", templates[0].FilePath) + }) + + t.Run("ListPRTemplates - Directory", func(t *testing.T) { + _ = os.Remove(filepath.Join(tmpDir, ".github", "PULL_REQUEST_TEMPLATE.md")) + + targetDir := filepath.Join(tmpDir, ".github", "PULL_REQUEST_TEMPLATE") + _ = os.MkdirAll(targetDir, 0755) + + err := os.WriteFile(filepath.Join(targetDir, "custom.md"), []byte("## Custom"), 0644) + require.NoError(t, err) + + templates, err := service.ListPRTemplates(context.Background()) + assert.NoError(t, err) + assert.Len(t, templates, 1) + assert.Equal(t, "custom.md", templates[0].FilePath) + }) + + t.Run("GetPRTemplate - Single File", func(t *testing.T) { + _ = os.WriteFile(filepath.Join(tmpDir, ".github", "PULL_REQUEST_TEMPLATE.md"), []byte("## Main\nContent"), 0644) + + tmpl, err := service.GetPRTemplate(context.Background(), "") + assert.NoError(t, err) + assert.Contains(t, tmpl.BodyContent, "## Main") + + tmpl, err = service.GetPRTemplate(context.Background(), "PULL_REQUEST_TEMPLATE.md") + assert.NoError(t, err) + assert.Contains(t, tmpl.BodyContent, "Content") + }) +} + +func TestIssueTemplateService_NewTemplates(t *testing.T) { + service := &IssueTemplateService{} + + t.Run("Performance Template is valid YAML", func(t *testing.T) { + content := service.buildPerformanceTemplate() + var tmpl models.IssueTemplate + err := yaml.Unmarshal([]byte(content), &tmpl) + assert.NoError(t, err) + assert.Equal(t, "Performance Issue", tmpl.Name) + assert.Contains(t, tmpl.Labels, "performance") + }) + + t.Run("Security Template is valid YAML", func(t *testing.T) { + content := service.buildSecurityTemplate() + var tmpl models.IssueTemplate + err := yaml.Unmarshal([]byte(content), &tmpl) + assert.NoError(t, err) + assert.Equal(t, "Security Vulnerability", tmpl.Name) + assert.Contains(t, tmpl.Labels, "security") + }) +} diff --git a/internal/services/pull_request_service.go b/internal/services/pull_request_service.go index 90efa1f..306621b 100644 --- a/internal/services/pull_request_service.go +++ b/internal/services/pull_request_service.go @@ -24,10 +24,17 @@ type prAIProvider interface { GeneratePRSummary(ctx context.Context, prompt string) (models.PRSummary, error) } +// prTemplateService defines the methods needed by PRService for template management. +type prTemplateService interface { + GetPRTemplate(ctx context.Context, name string) (*models.IssueTemplate, error) + ListPRTemplates(ctx context.Context) ([]models.TemplateMetadata, error) +} + type PRService struct { - vcsClient prVCSClient - aiService prAIProvider - config *config.Config + vcsClient prVCSClient + aiService prAIProvider + templateService prTemplateService + config *config.Config } type PROption func(*PRService) @@ -50,6 +57,12 @@ func WithPRConfig(cfg *config.Config) PROption { } } +func WithPRTemplateService(ts prTemplateService) PROption { + return func(s *PRService) { + s.templateService = ts + } +} + func NewPRService(opts ...PROption) *PRService { s := &PRService{} for _, opt := range opts { @@ -115,7 +128,20 @@ func (s *PRService) SummarizePR(ctx context.Context, prNumber int, progress func log.Debug("building PR prompt", "has_issues", len(prData.RelatedIssues) > 0) - prompt := s.buildPRPrompt(prData) + var prTemplate *models.IssueTemplate + if s.templateService != nil { + templates, err := s.templateService.ListPRTemplates(ctx) + if err == nil && len(templates) > 0 { + prTemplate, _ = s.templateService.GetPRTemplate(ctx, templates[0].FilePath) + if prTemplate != nil { + log.Info("auto-detected PR template", + "template_name", prTemplate.Name, + "template_path", templates[0].FilePath) + } + } + } + + prompt := s.buildPRPrompt(prData, prTemplate) log.Debug("calling AI for PR summary generation", "pr_number", prNumber) @@ -181,12 +207,20 @@ func (s *PRService) SummarizePR(ctx context.Context, prNumber int, progress func return summary, nil } -func (s *PRService) buildPRPrompt(prData models.PRData) string { +func (s *PRService) buildPRPrompt(prData models.PRData, template *models.IssueTemplate) string { var prompt string prompt += fmt.Sprintf("PR #%d by %s\n", prData.ID, prData.Creator) prompt += fmt.Sprintf("Branch: %s\n\n", prData.BranchName) + if template != nil { + lang := s.config.Language + if lang == "" { + lang = "en" + } + prompt += ai.FormatTemplateForPrompt(template, lang, "pr") + } + commitCount := len(prData.Commits) diffLines := strings.Count(prData.Diff, "\n") filesChanged := strings.Count(prData.Diff, "diff --git") diff --git a/internal/services/pull_request_service_test.go b/internal/services/pull_request_service_test.go index 9d4f62e..f792420 100644 --- a/internal/services/pull_request_service_test.go +++ b/internal/services/pull_request_service_test.go @@ -7,14 +7,14 @@ import ( "strings" "testing" - "github.com/thomas-vilte/matecommit/internal/config" - domainErrors "github.com/thomas-vilte/matecommit/internal/errors" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/ai/gemini" - "github.com/thomas-vilte/matecommit/internal/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/ai/gemini" + "github.com/thomas-vilte/matecommit/internal/config" + domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/github" + "github.com/thomas-vilte/matecommit/internal/models" ) func TestPRService_SummarizePR_Success(t *testing.T) { @@ -298,7 +298,7 @@ func TestBuildPRPrompt(t *testing.T) { service := PRService{} // Act - prompt := service.buildPRPrompt(prData) + prompt := service.buildPRPrompt(prData, nil) // Assert expected := `PR #456 by dev123 @@ -317,6 +317,31 @@ Changes (diff completo): diff --git a/api.go b/api.go` assert.Equal(t, expected, prompt) + +} + +func TestBuildPRPrompt_WithTemplate(t *testing.T) { + // Arrange + prData := models.PRData{ + ID: 789, + Creator: "dev", + Diff: "diff content", + } + + template := &models.IssueTemplate{ + Name: "MyTemplate", + BodyContent: "## TODO\n- [ ] Check this", + } + + service := PRService{config: &config.Config{}} + + // Act + prompt := service.buildPRPrompt(prData, template) + + // Assert + assert.Contains(t, prompt, "## TODO") + assert.Contains(t, prompt, "- [ ] Check this") + assert.Contains(t, prompt, "PR #789") } type TestConfig struct { @@ -407,3 +432,120 @@ func TestPRService_SummarizePR_Integration(t *testing.T) { t.Logf("Resumen generado: %+v", summary) }) } + +type MockPRTemplateService struct { + mock.Mock +} + +func (m *MockPRTemplateService) GetPRTemplate(ctx context.Context, name string) (*models.IssueTemplate, error) { + args := m.Called(ctx, name) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueTemplate), args.Error(1) +} + +func (m *MockPRTemplateService) ListPRTemplates(ctx context.Context) ([]models.TemplateMetadata, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]models.TemplateMetadata), args.Error(1) +} + +func TestPRService_SummarizePR_WithTemplate(t *testing.T) { + ctx := context.Background() + prNumber := 123 + + mockVCS := new(MockVCSClient) + mockAI := new(MockPRSummarizer) + mockTemplate := new(MockPRTemplateService) + cfg := &config.Config{} + + prData := models.PRData{ + ID: prNumber, + Creator: "user1", + Commits: []models.Commit{{Message: "feat: something"}}, + Diff: "diff content", + } + + expectedSummary := models.PRSummary{ + Title: "Title", + Body: "Body", + } + + mockVCS.On("GetPR", ctx, prNumber).Return(prData, nil) + mockVCS.On("GetPRIssues", ctx, mock.Anything, mock.Anything, mock.Anything).Return([]models.Issue(nil), nil) + + // Template setup + mockTemplate.On("ListPRTemplates", ctx).Return([]models.TemplateMetadata{ + {Name: "Default", FilePath: "PULL_REQUEST_TEMPLATE.md"}, + }, nil) + + templateContent := &models.IssueTemplate{ + Name: "Default", + BodyContent: "## Checklist\n- [ ] Done", + } + mockTemplate.On("GetPRTemplate", ctx, "PULL_REQUEST_TEMPLATE.md").Return(templateContent, nil) + + mockAI.On("GeneratePRSummary", ctx, mock.MatchedBy(func(prompt string) bool { + return strings.Contains(prompt, "## Checklist") && strings.Contains(prompt, "- [ ] Done") + })).Return(expectedSummary, nil) + + mockVCS.On("UpdatePR", ctx, prNumber, expectedSummary).Return(nil) + + service := NewPRService( + WithPRVCSClient(mockVCS), + WithPRAIProvider(mockAI), + WithPRConfig(cfg), + WithPRTemplateService(mockTemplate), + ) + + _, err := service.SummarizePR(ctx, prNumber, func(e models.ProgressEvent) {}) + + assert.NoError(t, err) + mockVCS.AssertExpectations(t) + mockAI.AssertExpectations(t) + mockTemplate.AssertExpectations(t) +} + +func TestPRService_SummarizePR_WithTemplateError(t *testing.T) { + ctx := context.Background() + prNumber := 123 + + mockVCS := new(MockVCSClient) + mockAI := new(MockPRSummarizer) + mockTemplate := new(MockPRTemplateService) + cfg := &config.Config{} + + prData := models.PRData{ + ID: prNumber, + Creator: "user1", + Commits: []models.Commit{{Message: "feat: something"}}, + } + + expectedSummary := models.PRSummary{Title: "Title", Body: "Body"} + + mockVCS.On("GetPR", ctx, prNumber).Return(prData, nil) + mockVCS.On("GetPRIssues", ctx, mock.Anything, mock.Anything, mock.Anything).Return([]models.Issue(nil), nil) + + mockTemplate.On("ListPRTemplates", ctx).Return([]models.TemplateMetadata(nil), errors.New("io error")) + + mockAI.On("GeneratePRSummary", ctx, mock.Anything).Return(expectedSummary, nil) + + mockVCS.On("UpdatePR", ctx, prNumber, expectedSummary).Return(nil) + + service := NewPRService( + WithPRVCSClient(mockVCS), + WithPRAIProvider(mockAI), + WithPRConfig(cfg), + WithPRTemplateService(mockTemplate), + ) + + _, err := service.SummarizePR(ctx, prNumber, func(e models.ProgressEvent) {}) + + assert.NoError(t, err) + mockVCS.AssertExpectations(t) + mockAI.AssertExpectations(t) + mockTemplate.AssertExpectations(t) +} diff --git a/internal/services/release_service.go b/internal/services/release_service.go index 91b5f99..441f2d6 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -1203,22 +1203,6 @@ func (s *ReleaseService) searchVersionFileRecursive(lang string) (string, string return "", "", fmt.Errorf("version file not found") } -func (s *ReleaseService) adjustPatternForReplacement(basePattern, content, lang string) string { - patterns, ok := consolidatedVersionPatterns[lang] - if !ok { - return basePattern - } - - for _, patternInfo := range patterns { - re := regexp.MustCompile(patternInfo.detectionPattern) - if re.MatchString(content) { - return patternInfo.replacementPattern - } - } - - return basePattern -} - func (s *ReleaseService) detectProjectType() string { indicators := map[string][]string{ "go": {"go.mod", "Gopkg.toml", "glide.yaml"}, diff --git a/internal/services/release_service_test.go b/internal/services/release_service_test.go index 5f2f746..cf726bb 100644 --- a/internal/services/release_service_test.go +++ b/internal/services/release_service_test.go @@ -1403,10 +1403,14 @@ const Version = "dev" service := &ReleaseService{} cwd, _ := os.Getwd() - defer os.Chdir(cwd) - os.Chdir(dir) + defer func() { + if err := os.Chdir(cwd); err != nil { + t.Fatal(err) + } + }() + _ = os.Chdir(dir) - os.WriteFile("go.mod", []byte("module test"), 0644) + _ = os.WriteFile("go.mod", []byte("module test"), 0644) foundFile, pattern, err := service.FindVersionFile(context.Background()) assert.Error(t, err) @@ -1417,16 +1421,20 @@ const Version = "dev" t.Run("Should ignore node_modules", func(t *testing.T) { dir := t.TempDir() cwd, _ := os.Getwd() - defer os.Chdir(cwd) - os.Chdir(dir) + defer func() { + if err := os.Chdir(cwd); err != nil { + t.Fatal(err) + } + }() + _ = os.Chdir(dir) - os.WriteFile("package.json", []byte(`{"name":"test"}`), 0644) + _ = os.WriteFile("package.json", []byte(`{"name":"test"}`), 0644) nodeModules := filepath.Join(dir, "node_modules") - os.Mkdir(nodeModules, 0755) + _ = os.Mkdir(nodeModules, 0755) ignoredFile := filepath.Join(nodeModules, "package.json") - os.WriteFile(ignoredFile, []byte(`{"version": "1.0.0"}`), 0644) + _ = os.WriteFile(ignoredFile, []byte(`{"version": "1.0.0"}`), 0644) service := &ReleaseService{} foundFile, _, err := service.FindVersionFile(context.Background()) @@ -1438,15 +1446,19 @@ const Version = "dev" t.Run("Should respect max recursion depth", func(t *testing.T) { dir := t.TempDir() cwd, _ := os.Getwd() - defer os.Chdir(cwd) - os.Chdir(dir) - os.WriteFile("go.mod", []byte("module test"), 0644) + defer func() { + if err := os.Chdir(cwd); err != nil { + t.Fatal(err) + } + }() + _ = os.Chdir(dir) + _ = os.WriteFile("go.mod", []byte("module test"), 0644) deepDir := filepath.Join(dir, "1", "2", "3", "4", "5", "6") - os.MkdirAll(deepDir, 0755) + _ = os.MkdirAll(deepDir, 0755) versionFile := filepath.Join(deepDir, "version.go") - os.WriteFile(versionFile, []byte(`package main + _ = os.WriteFile(versionFile, []byte(`package main const Version = "1.0.0"`), 0644) service := &ReleaseService{} @@ -1459,15 +1471,19 @@ const Version = "1.0.0"`), 0644) t.Run("Should find file within recursion depth", func(t *testing.T) { dir := t.TempDir() cwd, _ := os.Getwd() - defer os.Chdir(cwd) - os.Chdir(dir) - os.WriteFile("go.mod", []byte("module test"), 0644) + defer func() { + if err := os.Chdir(cwd); err != nil { + t.Fatal(err) + } + }() + _ = os.Chdir(dir) + _ = os.WriteFile("go.mod", []byte("module test"), 0644) shallowDir := filepath.Join(dir, "internal") - os.MkdirAll(shallowDir, 0755) + _ = os.MkdirAll(shallowDir, 0755) versionFile := filepath.Join(shallowDir, "version.go") - os.WriteFile(versionFile, []byte(`package main + _ = os.WriteFile(versionFile, []byte(`package main const Version = "1.0.0"`), 0644) service := &ReleaseService{} diff --git a/internal/ui/token_stats.go b/internal/ui/token_stats.go index cb8a01f..6ac1567 100644 --- a/internal/ui/token_stats.go +++ b/internal/ui/token_stats.go @@ -2,9 +2,9 @@ package ui import ( "fmt" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/fatih/color" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" ) func PrintTokenUsage(usage *models.TokenUsage, t *i18n.Translations) {