From 031e41efcd0b29c35f88c4d48663d1ae94233116 Mon Sep 17 00:00:00 2001 From: Thomas Vilte Date: Sun, 4 Jan 2026 22:31:06 -0300 Subject: [PATCH] feat(issue): implement structured templates and dynamic prompt injection --- internal/ai/gemini/issue_content_generator.go | 2 + .../ai/gemini/issue_content_generator_test.go | 22 + internal/ai/prompts.go | 55 +- internal/ai/prompts_test.go | 34 +- internal/commands/config/init_test.go | 2 +- internal/commands/issues/from_plan.go | 2 +- internal/commands/issues/from_plan_test.go | 10 +- internal/commands/issues/issues.go | 12 +- internal/commands/issues/issues_test.go | 11 +- internal/commands/issues/mocks.go | 12 +- internal/models/issue_form.go | 24 + internal/models/issue_template.go | 6 +- internal/services/issue_generator_service.go | 33 +- .../issue_generator_service_custom_test.go | 8 +- .../services/issue_generator_service_test.go | 36 +- internal/services/issue_template_service.go | 488 +++++++++--------- .../services/issue_template_service_test.go | 22 +- 17 files changed, 446 insertions(+), 333 deletions(-) create mode 100644 internal/models/issue_form.go diff --git a/internal/ai/gemini/issue_content_generator.go b/internal/ai/gemini/issue_content_generator.go index 90364c5..0d9784d 100644 --- a/internal/ai/gemini/issue_content_generator.go +++ b/internal/ai/gemini/issue_content_generator.go @@ -241,6 +241,8 @@ func (s *GeminiIssueContentGenerator) buildIssuePrompt(request models.IssueGener lang = "en" } sb.WriteString(ai.FormatTemplateForPrompt(request.Template, lang, "issue")) + } else { + sb.WriteString(ai.GetIssueDefaultStructure(request.Language)) } templateStr := ai.GetIssuePromptTemplate(request.Language) diff --git a/internal/ai/gemini/issue_content_generator_test.go b/internal/ai/gemini/issue_content_generator_test.go index cd28af9..7cdf35a 100644 --- a/internal/ai/gemini/issue_content_generator_test.go +++ b/internal/ai/gemini/issue_content_generator_test.go @@ -128,6 +128,28 @@ func TestBuildIssuePrompt_WithTemplate(t *testing.T) { // Verification is just that prompt exists and is relevant assert.Contains(t, prompt, "Code Changes") + // Should contain default structure because no template is provided + assert.Contains(t, prompt, "Context (Motivation)") + }) + + t.Run("does NOT include default structure when template is present", func(t *testing.T) { + template := &models.IssueTemplate{ + Name: "Bug Report", + Title: "Bug: {{title}}", + BodyContent: "## My Custom Structure\n{{description}}", + } + + request := models.IssueGenerationRequest{ + Diff: "test diff", + Template: template, + Language: "en", + } + + prompt := gen.buildIssuePrompt(request) + + assert.Contains(t, prompt, "My Custom Structure") + // Should NOT contain default structure + assert.NotContains(t, prompt, "Context (Motivation)") }) t.Run("includes template in Spanish", func(t *testing.T) { diff --git a/internal/ai/prompts.go b/internal/ai/prompts.go index f9a98af..d3b064c 100644 --- a/internal/ai/prompts.go +++ b/internal/ai/prompts.go @@ -396,22 +396,41 @@ func FormatTemplateForPrompt(template *models.IssueTemplate, lang string, templa sb.WriteString(GetPRTemplateInstructions(lang)) } sb.WriteString("\n\n") - } else if template.Body != nil { + } else if len(template.Body) > 0 { 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") + sb.WriteString("El template define campos específicos. A continuación la estructura que DEBES completar:\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") + sb.WriteString("The template defines specific fields. Below is the structure you MUST complete:\n\n") } + + for _, item := range template.Body { + if item.Type == "markdown" { + continue + } + + if item.Attributes.Label != "" { + sb.WriteString(fmt.Sprintf("### %s\n", item.Attributes.Label)) + if item.Attributes.Description != "" { + sb.WriteString(fmt.Sprintf("Context: %s\n", item.Attributes.Description)) + } + if item.Attributes.Placeholder != "" { + sb.WriteString(fmt.Sprintf("Example: %s\n", item.Attributes.Placeholder)) + } + sb.WriteString("\n") + } + } + + sb.WriteString("\n") } return sb.String() @@ -579,12 +598,6 @@ const ( 4. **No Emojis:** Do not use emojis in the title or description. Keep it purely textual and professional. 5. **Balanced Labeling:** Aim for 2-4 relevant labels. Ensure you include the primary category plus any relevant file-based labels like 'test', 'docs', or 'infra' if applicable. - # Description Structure - The 'description' field must follow this Markdown structure: - - ### Context (Motivation) - - ### Technical Details (Architectural changes, new models, etc.) - - ### Impact (Benefits) - Generate the issue now.` issuePromptTemplateES = `# Tarea @@ -600,13 +613,23 @@ const ( 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. + Generá el issue ahora. Responde en ESPAÑOL.` + + issueDefaultStructureEN = ` + # Description Structure + The 'description' field must follow this Markdown structure: + - ### Context (Motivation) + - ### Technical Details (Architectural changes, new models, etc.) + - ### Impact (Benefits) +` + + issueDefaultStructureES = ` # Estructura de la Descripción El campo "description" tiene que ser Markdown y seguir esta estructura estricta: - ### Contexto (¿Cuál es la motivación o el dolor que resuelve esto?) - ### Detalles Técnicos (Lista de cambios importantes, modelos nuevos, refactors) - ### Impacto (¿Qué gana el usuario o el desarrollador con esto?) - - Generá el issue ahora. Responde en ESPAÑOL.` +` ) // GetIssuePromptTemplate returns the appropriate issue generation template based on language @@ -618,3 +641,13 @@ func GetIssuePromptTemplate(lang string) string { return issuePromptTemplateEN } } + +// GetIssueDefaultStructure returns the default structure for issues when no template is provided +func GetIssueDefaultStructure(lang string) string { + switch lang { + case "es": + return issueDefaultStructureES + default: + return issueDefaultStructureEN + } +} diff --git a/internal/ai/prompts_test.go b/internal/ai/prompts_test.go index e23eb6a..9bef3e4 100644 --- a/internal/ai/prompts_test.go +++ b/internal/ai/prompts_test.go @@ -179,24 +179,42 @@ func TestGetReleasePromptTemplate(t *testing.T) { } func TestGetIssuePromptTemplate(t *testing.T) { - t.Run("English template has proper structure", func(t *testing.T) { + t.Run("English template DOES NOT have 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") + // Structure should be removed from the base template + assert.NotContains(t, result, "### Context (Motivation)") + assert.NotContains(t, result, "### Technical Details") + assert.NotContains(t, result, "### Impact") }) t.Run("Spanish template is in Spanish", func(t *testing.T) { result := GetIssuePromptTemplate("es") assert.Contains(t, result, "Tech Lead") + assert.Contains(t, result, "ESPAÑOL") + // Structure should be removed from the base template + assert.NotContains(t, result, "### Contexto") + assert.NotContains(t, result, "### Detalles Técnicos") + assert.NotContains(t, result, "### Impacto") + }) +} + +func TestGetIssueDefaultStructure(t *testing.T) { + t.Run("English default structure is correct", func(t *testing.T) { + result := GetIssueDefaultStructure("en") + assert.Contains(t, result, "Context (Motivation)") + assert.Contains(t, result, "Technical Details") + assert.Contains(t, result, "Impact") + }) + + t.Run("Spanish default structure is correct", func(t *testing.T) { + result := GetIssueDefaultStructure("es") assert.Contains(t, result, "Contexto") assert.Contains(t, result, "Detalles Técnicos") assert.Contains(t, result, "Impacto") - assert.Contains(t, result, "ESPAÑOL") }) } @@ -298,7 +316,11 @@ func TestFormatTemplateForPrompt(t *testing.T) { template := &models.IssueTemplate{ Name: "yaml_template", About: "YAML based template", - Body: map[string]interface{}{"type": "markdown"}, + Body: []models.IssueFormItem{ + { + Type: "markdown", + }, + }, } result := FormatTemplateForPrompt(template, "en", "issue") diff --git a/internal/commands/config/init_test.go b/internal/commands/config/init_test.go index cef85a2..770ddb7 100644 --- a/internal/commands/config/init_test.go +++ b/internal/commands/config/init_test.go @@ -67,7 +67,7 @@ func runInitCommandTest(t *testing.T, userInput string, fullMode bool) (output s Commands: []*cli.Command{cmd}, } - args := []string{"test", "init"} + args := []string{"test", "init", "--global"} if fullMode { args = append(args, "--full") } diff --git a/internal/commands/issues/from_plan.go b/internal/commands/issues/from_plan.go index 826722e..22e017f 100644 --- a/internal/commands/issues/from_plan.go +++ b/internal/commands/issues/from_plan.go @@ -80,7 +80,7 @@ func (f *IssuesCommandFactory) createFromPlanAction(t *i18n.Translations, cfg *c ui.PrintInfo(t.GetMessage("issue_from_plan.parsing_plan", 0, nil)) - result, err := issueService.GenerateFromDescription(ctx, string(content), false, false) + result, err := issueService.GenerateFromDescription(ctx, string(content), false, false, nil) if err != nil { errMsg := t.GetMessage("issue_from_plan.error_parsing_plan", 0, struct{ Error string }{err.Error()}) diff --git a/internal/commands/issues/from_plan_test.go b/internal/commands/issues/from_plan_test.go index e9e8076..959de0e 100644 --- a/internal/commands/issues/from_plan_test.go +++ b/internal/commands/issues/from_plan_test.go @@ -75,7 +75,7 @@ Add user authentication feature Labels: []string{"feature"}, } - mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false). + mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false, (*models.IssueTemplate)(nil)). Return(expectedResult, nil) mockGen.On("CreateIssue", mock.Anything, expectedResult, []string(nil)). Return(&models.Issue{Number: 42, URL: "http://github.com/test/issues/42"}, nil) @@ -106,7 +106,7 @@ Add user authentication feature Labels: []string{"bug"}, } - mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false). + mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false, (*models.IssueTemplate)(nil)). Return(expectedResult, nil) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} @@ -136,7 +136,7 @@ Add user authentication feature Labels: []string{"feature"}, } - mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false). + mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false, (*models.IssueTemplate)(nil)). Return(expectedResult, nil) mockGen.On("GetAuthenticatedUser", mock.Anything). Return("johndoe", nil) @@ -169,7 +169,7 @@ Add user authentication feature Labels: []string{"security"}, } - mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false). + mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false, (*models.IssueTemplate)(nil)). Return(expectedResult, nil) mockGen.On("CreateIssue", mock.Anything, mock.MatchedBy(func(r *models.IssueGenerationResult) bool { @@ -214,7 +214,7 @@ Agregar funcionalidad de autenticación con JWT` Labels: []string{"feature"}, } - mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false). + mockGen.On("GenerateFromDescription", mock.Anything, planContent, false, false, (*models.IssueTemplate)(nil)). Return(expectedResult, nil) mockGen.On("CreateIssue", mock.Anything, expectedResult, []string(nil)). Return(&models.Issue{Number: 1, URL: "http://test.com"}, nil) diff --git a/internal/commands/issues/issues.go b/internal/commands/issues/issues.go index 948a321..0da91d0 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, 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) + GenerateFromDiff(ctx context.Context, hint string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*models.IssueGenerationResult, error) + GenerateFromDescription(ctx context.Context, description string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*models.IssueGenerationResult, error) + GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*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) @@ -213,11 +213,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, autoTemplate) + result, err = issueService.GenerateFromDiff(ctx, hint, noLabels, autoTemplate, nil) } else if fromPR > 0 { - result, err = issueService.GenerateFromPR(ctx, fromPR, hint, noLabels, autoTemplate) + result, err = issueService.GenerateFromPR(ctx, fromPR, hint, noLabels, autoTemplate, nil) } else { - result, err = issueService.GenerateFromDescription(ctx, description, noLabels, autoTemplate) + result, err = issueService.GenerateFromDescription(ctx, description, noLabels, autoTemplate, nil) } spinner.Stop() diff --git a/internal/commands/issues/issues_test.go b/internal/commands/issues/issues_test.go index b046481..7e0fbc1 100644 --- a/internal/commands/issues/issues_test.go +++ b/internal/commands/issues/issues_test.go @@ -80,9 +80,8 @@ func TestIssueGenerateAction(t *testing.T) { Labels: []string{"bug"}, } - 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) - + mockGen.On("GenerateFromDiff", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(expectedResult, nil) + mockGen.On("CreateIssue", mock.Anything, mock.Anything, mock.Anything).Return(&models.Issue{Number: 1, URL: "http://test.com"}, nil) withStdin("y\n", func() { app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} err := app.Run(context.Background(), []string{"test", "issue", "generate", "--from-diff", "--hint", "hint"}) @@ -102,7 +101,7 @@ func TestIssueGenerateAction(t *testing.T) { Description: "Dry Run Desc", } - mockGen.On("GenerateFromDescription", mock.Anything, "desc", false, true).Return(expectedResult, nil) + mockGen.On("GenerateFromDescription", mock.Anything, "desc", false, true, mock.Anything).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 +120,7 @@ func TestIssueGenerateAction(t *testing.T) { Title: "Assigned Issue", } - mockGen.On("GenerateFromDiff", mock.Anything, "", false, true).Return(expectedResult, nil) + mockGen.On("GenerateFromDiff", mock.Anything, "", false, true, mock.Anything).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 +138,7 @@ func TestIssueGenerateAction(t *testing.T) { factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) - mockGen.On("GenerateFromDiff", mock.Anything, "", false, true).Return(&models.IssueGenerationResult{Title: "T"}, nil) + mockGen.On("GenerateFromDiff", mock.Anything, "", false, true, mock.Anything).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 c9cbd4f..7f53ce5 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, autoTemplate bool) (*models.IssueGenerationResult, error) { - args := m.Called(ctx, hint, skipLabels, autoTemplate) +func (m *MockIssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, hint, skipLabels, autoTemplate, explicitTemplate) 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, autoTemplate bool) (*models.IssueGenerationResult, error) { - args := m.Called(ctx, description, skipLabels, autoTemplate) +func (m *MockIssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, description, skipLabels, autoTemplate, explicitTemplate) 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, autoTemplate bool) (*models.IssueGenerationResult, error) { - args := m.Called(ctx, prNumber, hint, skipLabels, autoTemplate) +func (m *MockIssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, prNumber, hint, skipLabels, autoTemplate, explicitTemplate) if args.Get(0) == nil { return nil, args.Error(1) } diff --git a/internal/models/issue_form.go b/internal/models/issue_form.go new file mode 100644 index 0000000..032382b --- /dev/null +++ b/internal/models/issue_form.go @@ -0,0 +1,24 @@ +package models + +// IssueFormItem represents an item within a GitHub Issue Form (YAML). +type IssueFormItem struct { + Type string `yaml:"type"` + ID string `yaml:"id,omitempty"` + Attributes FormAttributes `yaml:"attributes,omitempty"` + Validations FormValidations `yaml:"validations,omitempty"` +} + +// FormAttributes contains the visual and behavioral attributes of the field. +type FormAttributes struct { + Label string `yaml:"label"` + Description string `yaml:"description,omitempty"` + Placeholder string `yaml:"placeholder,omitempty"` + Value string `yaml:"value,omitempty"` // For 'markdown' type elements + Options []string `yaml:"options,omitempty"` // For dropdowns + Multiple bool `yaml:"multiple,omitempty"` +} + +// FormValidations defines validation rules. +type FormValidations struct { + Required bool `yaml:"required,omitempty"` +} diff --git a/internal/models/issue_template.go b/internal/models/issue_template.go index fd93277..21805f5 100644 --- a/internal/models/issue_template.go +++ b/internal/models/issue_template.go @@ -12,9 +12,9 @@ type IssueTemplate struct { // Template content // For .md: Markdown string - // For .yml (GitHub Issue Forms): array of form fields - Body interface{} `yaml:"body,omitempty"` - BodyContent string `yaml:"-"` // For backward compatibility with .md + // For .yml (GitHub Issue Forms): strict typed list of form items + Body []IssueFormItem `yaml:"body,omitempty"` + BodyContent string `yaml:"-"` // For backward compatibility with .md // Path to the template file FilePath string `yaml:"-"` diff --git a/internal/services/issue_generator_service.go b/internal/services/issue_generator_service.go index 3723982..c92c274 100644 --- a/internal/services/issue_generator_service.go +++ b/internal/services/issue_generator_service.go @@ -72,10 +72,11 @@ 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, autoTemplate bool) (*models.IssueGenerationResult, error) { +func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*models.IssueGenerationResult, error) { logger.Info(ctx, "generating issue from diff", "has_hint", hint != "", - "skip_labels", skipLabels) + "skip_labels", skipLabels, + "has_explicit_template", explicitTemplate != nil) if s.ai == nil { logger.Error(ctx, "AI service not configured", nil) @@ -103,8 +104,8 @@ func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint strin "diff_size", len(diff), "files_count", len(changedFiles)) - var template *models.IssueTemplate - if autoTemplate { + var template = explicitTemplate + if template == nil && autoTemplate { template, _ = s.SelectTemplateWithAI(ctx, "", hint, changedFiles, nil) } @@ -163,10 +164,11 @@ func (s *IssueGeneratorService) fetchAvailableLabels(ctx context.Context) ([]str // 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, autoTemplate bool) (*models.IssueGenerationResult, error) { +func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*models.IssueGenerationResult, error) { logger.Info(ctx, "generating issue from description", "description_length", len(description), - "skip_labels", skipLabels) + "skip_labels", skipLabels, + "has_explicit_template", explicitTemplate != nil) if s.ai == nil { logger.Error(ctx, "AI service not configured", nil) @@ -178,8 +180,8 @@ 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 template = explicitTemplate + if template == nil && autoTemplate { var err error template, err = s.SelectTemplateWithAI(ctx, "", description, nil, nil) if err != nil { @@ -231,11 +233,12 @@ func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, des return result, nil } -func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool, autoTemplate bool) (*models.IssueGenerationResult, error) { +func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool, autoTemplate bool, explicitTemplate *models.IssueTemplate) (*models.IssueGenerationResult, error) { logger.Info(ctx, "generating issue from PR", "pr_number", prNumber, "has_hint", hint != "", - "skip_labels", skipLabels) + "skip_labels", skipLabels, + "has_explicit_template", explicitTemplate != nil) if s.ai == nil { logger.Error(ctx, "AI service not configured", nil) @@ -277,8 +280,8 @@ func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int changedFiles := s.extractFilesFromDiff(prData.Diff) - var template *models.IssueTemplate - if autoTemplate { + var template = explicitTemplate + if template == nil && autoTemplate { template, _ = s.SelectTemplateWithAI(ctx, prData.Title, prData.Description, changedFiles, prData.Labels) } @@ -341,9 +344,9 @@ func (s *IssueGeneratorService) GenerateWithTemplate(ctx context.Context, templa var baseResult *models.IssueGenerationResult if fromDiff { - baseResult, err = s.GenerateFromDiff(ctx, hint, skipLabels, false) + baseResult, err = s.GenerateFromDiff(ctx, hint, skipLabels, false, template) } else if description != "" { - baseResult, err = s.GenerateFromDescription(ctx, description, skipLabels, false) + baseResult, err = s.GenerateFromDescription(ctx, description, skipLabels, false, template) } else { return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, "no input provided", nil) } @@ -351,7 +354,7 @@ func (s *IssueGeneratorService) GenerateWithTemplate(ctx context.Context, templa return nil, err } - result := s.templateService.MergeWithGeneratedContent(template, baseResult) + result := baseResult if len(template.Assignees) > 0 { result.Assignees = s.mergeAssignees(result.Assignees, template.Assignees) diff --git a/internal/services/issue_generator_service_custom_test.go b/internal/services/issue_generator_service_custom_test.go index 272e2c0..e3b8e3a 100644 --- a/internal/services/issue_generator_service_custom_test.go +++ b/internal/services/issue_generator_service_custom_test.go @@ -339,7 +339,7 @@ index 1234567..abcdefg 100644 WithIssueConfig(cfg), WithIssueTemplateService(mockTemplateSvc)) - result, err := service.GenerateFromDiff(ctx, "fix panic", false, true) + result, err := service.GenerateFromDiff(ctx, "fix panic", false, true, nil) assert.NoError(t, err) assert.NotNil(t, result) @@ -379,7 +379,7 @@ index 1234567..abcdefg 100644 service := NewIssueGeneratorService(mockGit, mockAI, WithIssueConfig(cfg)) - result, err := service.GenerateFromDiff(ctx, "", false, false) + result, err := service.GenerateFromDiff(ctx, "", false, false, nil) assert.NoError(t, err) assert.NotNil(t, result) @@ -415,7 +415,7 @@ index 1234567..abcdefg 100644 WithIssueConfig(cfg), WithIssueTemplateService(mockTemplateSvc)) - result, err := service.GenerateFromDiff(ctx, "", false, true) + result, err := service.GenerateFromDiff(ctx, "", false, true, nil) assert.NoError(t, err) assert.NotNil(t, result) @@ -461,7 +461,7 @@ index 1234567..abcdefg 100644 WithIssueConfig(cfg), WithIssueTemplateService(mockTemplateSvc)) - result, err := service.GenerateFromDiff(ctx, "", false, true) + result, err := service.GenerateFromDiff(ctx, "", false, true, nil) assert.NoError(t, err) assert.NotNil(t, result) diff --git a/internal/services/issue_generator_service_test.go b/internal/services/issue_generator_service_test.go index f591378..3935bea 100644 --- a/internal/services/issue_generator_service_test.go +++ b/internal/services/issue_generator_service_test.go @@ -55,7 +55,7 @@ index 1234567..abcdefg 100644 mockAI.On("GenerateIssueContent", ctx, expectedRequest).Return(expectedResult, nil) - result, err := service.GenerateFromDiff(ctx, "Add user creation functionality", false, false) + result, err := service.GenerateFromDiff(ctx, "Add user creation functionality", false, false, nil) assert.NoError(t, err) assert.Equal(t, "Add CreateUser method to UserService", result.Title) @@ -111,7 +111,7 @@ index 1234567..abcdefg 100644 mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromDiff(ctx, "", false, false) + result, err := service.GenerateFromDiff(ctx, "", false, false, nil) assert.NoError(t, err) assert.Equal(t, "Fix token validation length requirement", result.Title) @@ -127,7 +127,7 @@ index 1234567..abcdefg 100644 mockGit.On("GetDiff", ctx).Return("", nil) - result, err := service.GenerateFromDiff(ctx, "", false, false) + result, err := service.GenerateFromDiff(ctx, "", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -143,7 +143,7 @@ index 1234567..abcdefg 100644 gitError := errors.New("git: not a git repository") mockGit.On("GetDiff", ctx).Return("", gitError) - result, err := service.GenerateFromDiff(ctx, "", false, false) + result, err := service.GenerateFromDiff(ctx, "", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -154,7 +154,7 @@ index 1234567..abcdefg 100644 mockGit := new(MockGitService) service := NewIssueGeneratorService(mockGit, nil, WithIssueConfig(cfg)) - result, err := service.GenerateFromDiff(ctx, "", false, false) + result, err := service.GenerateFromDiff(ctx, "", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -172,7 +172,7 @@ index 1234567..abcdefg 100644 aiError := errors.New("AI service rate limit exceeded") mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(nil, aiError) - result, err := service.GenerateFromDiff(ctx, "", false, false) + result, err := service.GenerateFromDiff(ctx, "", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -196,7 +196,7 @@ index 1234567..abcdefg 100644 mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromDiff(ctx, "", true, false) + result, err := service.GenerateFromDiff(ctx, "", true, false, nil) assert.NoError(t, err) assert.NotContains(t, result.Labels, "test") @@ -226,7 +226,7 @@ func TestIssueGeneratorService_GenerateFromDescription(t *testing.T) { mockAI.On("GenerateIssueContent", ctx, expectedRequest).Return(expectedResult, nil) - result, err := service.GenerateFromDescription(ctx, description, false, false) + result, err := service.GenerateFromDescription(ctx, description, false, false, nil) assert.NoError(t, err) assert.Equal(t, "Add OAuth2 authentication support", result.Title) @@ -247,7 +247,7 @@ func TestIssueGeneratorService_GenerateFromDescription(t *testing.T) { mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromDescription(ctx, description, false, false) + result, err := service.GenerateFromDescription(ctx, description, false, false, nil) assert.NoError(t, err) assert.Equal(t, "Fix file upload timeout for large files", result.Title) @@ -266,7 +266,7 @@ func TestIssueGeneratorService_GenerateFromDescription(t *testing.T) { mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromDescription(ctx, "manual description", true, false) + result, err := service.GenerateFromDescription(ctx, "manual description", true, false, nil) assert.NoError(t, err) assert.Equal(t, "Manual Issue", result.Title) @@ -278,7 +278,7 @@ func TestIssueGeneratorService_GenerateFromDescription(t *testing.T) { mockAI := new(MockIssueContentGenerator) service := NewIssueGeneratorService(nil, mockAI, WithIssueConfig(cfg)) - result, err := service.GenerateFromDescription(ctx, "", false, false) + result, err := service.GenerateFromDescription(ctx, "", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -287,7 +287,7 @@ func TestIssueGeneratorService_GenerateFromDescription(t *testing.T) { 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) + result, err := service.GenerateFromDescription(ctx, "some description", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -301,7 +301,7 @@ func TestIssueGeneratorService_GenerateFromDescription(t *testing.T) { aiError := errors.New("AI service unavailable") mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(nil, aiError) - result, err := service.GenerateFromDescription(ctx, "description", false, false) + result, err := service.GenerateFromDescription(ctx, "description", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -349,7 +349,7 @@ index 0000000..1234567 mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromPR(ctx, 42, "", false, false) + result, err := service.GenerateFromPR(ctx, 42, "", false, false, nil) assert.NoError(t, err) assert.Equal(t, "Implement user profile page", result.Title) @@ -383,7 +383,7 @@ index 0000000..1234567 mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) - result, err := service.GenerateFromPR(ctx, 123, "Focus on performance impact", false, false) + result, err := service.GenerateFromPR(ctx, 123, "Focus on performance impact", false, false, nil) assert.NoError(t, err) assert.Contains(t, result.Description, "Related PR: #123") @@ -395,7 +395,7 @@ index 0000000..1234567 mockAI := new(MockIssueContentGenerator) service := NewIssueGeneratorService(nil, mockAI, WithIssueConfig(cfg)) - result, err := service.GenerateFromPR(ctx, 1, "", false, false) + result, err := service.GenerateFromPR(ctx, 1, "", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -406,7 +406,7 @@ index 0000000..1234567 mockVCS := new(MockVCSClient) service := NewIssueGeneratorService(nil, nil, WithIssueVCSClient(mockVCS), WithIssueConfig(cfg)) - result, err := service.GenerateFromPR(ctx, 1, "", false, false) + result, err := service.GenerateFromPR(ctx, 1, "", false, false, nil) assert.Error(t, err) assert.Nil(t, result) @@ -421,7 +421,7 @@ index 0000000..1234567 prError := errors.New("PR #999 not found") mockVCS.On("GetPR", ctx, 999).Return(models.PRData{}, prError) - result, err := service.GenerateFromPR(ctx, 999, "", false, false) + result, err := service.GenerateFromPR(ctx, 999, "", false, false, nil) assert.Error(t, err) assert.Nil(t, result) diff --git a/internal/services/issue_template_service.go b/internal/services/issue_template_service.go index 7e75a58..bf77602 100644 --- a/internal/services/issue_template_service.go +++ b/internal/services/issue_template_service.go @@ -167,15 +167,16 @@ func (s *IssueTemplateService) InitializeTemplates(ctx context.Context, force bo } templates := map[string]string{ - "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(), + "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(), + "PULL_REQUEST_TEMPLATE.md": s.buildDefaultPRTemplate(), } created := 0 @@ -183,6 +184,9 @@ func (s *IssueTemplateService) InitializeTemplates(ctx context.Context, force bo for filename, content := range templates { filePath := filepath.Join(templatesDir, filename) + if filename == "PULL_REQUEST_TEMPLATE.md" && strings.HasSuffix(templatesDir, "ISSUE_TEMPLATE") { + filePath = filepath.Join(filepath.Dir(templatesDir), filename) + } if _, err := os.Stat(filePath); err == nil && !force { logger.Debug(ctx, "template already exists, skipping", "path", filePath) @@ -381,225 +385,208 @@ func (s *IssueTemplateService) buildTemplateContent(templateType string) string } func (s *IssueTemplateService) buildBugReportTemplate() string { - template := map[string]interface{}{ - "name": "Bug report", - "description": "Create a report to help us improve", - "title": "[BUG] ", - "labels": []string{"bug"}, - "body": []map[string]interface{}{ + template := models.IssueTemplate{ + Name: "Bug report", + Description: "Create a report to help us improve", + Title: "[BUG] ", + Labels: []string{"bug"}, + Body: []models.IssueFormItem{ { - "type": "markdown", - "attributes": map[string]string{ - "value": "Thank you for reporting a bug!", + Type: "markdown", + Attributes: models.FormAttributes{ + Value: "Thank you for reporting a bug!", }, }, { - "type": "textarea", - "id": "description", - "attributes": map[string]interface{}{ - "label": "Description", - "description": "A clear and concise description of what the bug is.", - "placeholder": "Enter bug description", - }, - "validations": map[string]bool{ - "required": true, - }, + Type: "textarea", + ID: "description", + Attributes: models.FormAttributes{ + Label: "Description", + Description: "A clear and concise description of what the bug is.", + Placeholder: "Enter bug description", + }, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "steps", - "attributes": map[string]interface{}{ - "label": "Steps to reproduce", - "description": "Explain how you encountered the bug.", - "placeholder": "1. \n2. \n3. ", - }, - "validations": map[string]bool{ - "required": true, - }, + Type: "textarea", + ID: "steps", + Attributes: models.FormAttributes{ + Label: "Steps to reproduce", + Description: "Explain how you encountered the bug.", + Placeholder: "1. \n2. \n3. ", + }, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "expected", - "attributes": map[string]interface{}{ - "label": "Expected behavior", - "placeholder": "What did you expect to happen?", - }, - "validations": map[string]bool{ - "required": true, + Type: "textarea", + ID: "expected", + Attributes: models.FormAttributes{ + Label: "Expected behavior", + Placeholder: "What did you expect to happen?", }, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "actual", - "attributes": map[string]interface{}{ - "label": "Actual behavior", - "placeholder": "What actually happened?", - }, - "validations": map[string]bool{ - "required": true, + Type: "textarea", + ID: "actual", + Attributes: models.FormAttributes{ + Label: "Actual behavior", + Placeholder: "What actually happened?", }, + Validations: models.FormValidations{Required: true}, }, { - "type": "input", - "id": "version", - "attributes": map[string]interface{}{ - "label": "Version", - "placeholder": "v1.0.0", + Type: "input", + ID: "version", + Attributes: models.FormAttributes{ + Label: "Version", + Placeholder: "v1.0.0", }, }, { - "type": "textarea", - "id": "additional", - "attributes": map[string]interface{}{ - "label": "Additional information", - "description": "Add any other context about the problem here.", + Type: "textarea", + ID: "additional", + Attributes: models.FormAttributes{ + Label: "Additional information", + Description: "Add any other context about the problem here.", }, }, }, } - content, _ := yaml.Marshal(template) return string(content) } func (s *IssueTemplateService) buildFeatureRequestTemplate() string { - template := map[string]interface{}{ - "name": "Feature request", - "description": "Suggest an idea for this project", - "title": "[FEATURE] ", - "labels": []string{"enhancement"}, - "body": []map[string]interface{}{ + template := models.IssueTemplate{ + Name: "Feature request", + Description: "Suggest an idea for this project", + Title: "[FEATURE] ", + Labels: []string{"enhancement"}, + Body: []models.IssueFormItem{ { - "type": "markdown", - "attributes": map[string]string{ - "value": "Thank you for suggesting a feature!", + Type: "markdown", + Attributes: models.FormAttributes{ + Value: "Thank you for suggesting a feature!", }, }, { - "type": "textarea", - "id": "problem", - "attributes": map[string]interface{}{ - "label": "Problem description", - "description": "A clear and concise description of what the problem is.", - "placeholder": "I'm always frustrated when...", - }, - "validations": map[string]bool{ - "required": true, - }, + Type: "textarea", + ID: "problem", + Attributes: models.FormAttributes{ + Label: "Problem description", + Description: "A clear and concise description of what the problem is.", + Placeholder: "I'm always frustrated when...", + }, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "solution", - "attributes": map[string]interface{}{ - "label": "Proposed solution", - "description": "A clear and concise description of what you want to happen.", - "placeholder": "I would like to see...", - }, - "validations": map[string]bool{ - "required": true, - }, + Type: "textarea", + ID: "solution", + Attributes: models.FormAttributes{ + Label: "Proposed solution", + Description: "A clear and concise description of what you want to happen.", + Placeholder: "I would like to see...", + }, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "alternatives", - "attributes": map[string]interface{}{ - "label": "Alternatives considered", - "description": "A clear and concise description of any alternative solutions.", + Type: "textarea", + ID: "alternatives", + Attributes: models.FormAttributes{ + Label: "Alternatives considered", + Description: "A clear and concise description of any alternative solutions.", }, }, { - "type": "textarea", - "id": "additional", - "attributes": map[string]interface{}{ - "label": "Additional information", - "description": "Add any other context or screenshots about the feature request here.", + Type: "textarea", + ID: "additional", + Attributes: models.FormAttributes{ + Label: "Additional information", + Description: "Add any other context or screenshots about the feature request here.", }, }, }, } - content, _ := yaml.Marshal(template) return string(content) } func (s *IssueTemplateService) buildCustomTemplate() string { - template := map[string]interface{}{ - "name": "Custom issue", - "description": "File a custom issue", - "title": "[ISSUE] ", - "labels": []string{}, - "body": []map[string]interface{}{ + template := models.IssueTemplate{ + Name: "Custom issue", + Description: "File a custom issue", + Title: "[ISSUE] ", + Labels: []string{}, + Body: []models.IssueFormItem{ { - "type": "markdown", - "attributes": map[string]string{ - "value": "Open a custom issue.", + Type: "markdown", + Attributes: models.FormAttributes{ + Value: "Open a custom issue.", }, }, { - "type": "textarea", - "id": "description", - "attributes": map[string]interface{}{ - "label": "Description", - "description": "Enter the issue description.", - "placeholder": "Describe your issue here", - }, - "validations": map[string]bool{ - "required": true, - }, + Type: "textarea", + ID: "description", + Attributes: models.FormAttributes{ + Label: "Description", + Description: "Enter the issue description.", + Placeholder: "Describe your issue here", + }, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "additional", - "attributes": map[string]interface{}{ - "label": "Additional information", - "description": "Any additional context.", + Type: "textarea", + ID: "additional", + Attributes: models.FormAttributes{ + Label: "Additional information", + Description: "Any additional context.", }, }, }, } - content, _ := yaml.Marshal(template) 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{}{ + template := models.IssueTemplate{ + Name: "Performance Issue", + Description: "Report a performance issue or inefficiency", + Title: "[PERF] ", + Labels: []string{"performance", "optimization"}, + Body: []models.IssueFormItem{ { - "type": "markdown", - "attributes": map[string]string{ - "value": "Thanks for helping us make things faster! Please describe the performance issue in detail.", + Type: "markdown", + Attributes: models.FormAttributes{ + 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: "textarea", + ID: "description", + Attributes: models.FormAttributes{ + Label: "Description", + Description: "What is slow or inefficient?", + Placeholder: "The dashboard takes 5 seconds to load...", + }, + Validations: models.FormValidations{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: "input", + ID: "metric", + Attributes: models.FormAttributes{ + 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?", + Type: "textarea", + ID: "repro", + Attributes: models.FormAttributes{ + Label: "Steps to reproduce", + Description: "How can we observe this?", }, }, }, @@ -608,27 +595,27 @@ func (s *IssueTemplateService) buildPerformanceTemplate() string { 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{}{ + template := models.IssueTemplate{ + Name: "Documentation", + Description: "Improvements or additions to documentation", + Title: "[DOCS] ", + Labels: []string{"documentation"}, + Body: []models.IssueFormItem{ { - "type": "textarea", - "id": "description", - "attributes": map[string]interface{}{ - "label": "Description", - "description": "What needs to be documented or improved?", + Type: "textarea", + ID: "description", + Attributes: models.FormAttributes{ + Label: "Description", + Description: "What needs to be documented or improved?", }, - "validations": map[string]bool{"required": true}, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "location", - "attributes": map[string]interface{}{ - "label": "Relevant files/sections", - "description": "Where should this check go?", + Type: "textarea", + ID: "location", + Attributes: models.FormAttributes{ + Label: "Relevant files/sections", + Description: "Where should this check go?", }, }, }, @@ -637,33 +624,33 @@ func (s *IssueTemplateService) buildDocumentationTemplate() string { 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{}{ + template := models.IssueTemplate{ + Name: "Security Vulnerability", + Description: "Report a security vulnerability", + Title: "[SECURITY] ", + Labels: []string{"security", "critical"}, + Body: []models.IssueFormItem{ { - "type": "markdown", - "attributes": map[string]string{ - "value": "**IMPORTANT:** Please do not disclose security vulnerabilities publicly until they have been addressed.", + Type: "markdown", + Attributes: models.FormAttributes{ + 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.", + Type: "textarea", + ID: "description", + Attributes: models.FormAttributes{ + Label: "Vulnerability Description", + Description: "Describe the security issue.", }, - "validations": map[string]bool{"required": true}, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "impact", - "attributes": map[string]interface{}{ - "label": "Impact", - "description": "What is the potential impact of this vulnerability?", + Type: "textarea", + ID: "impact", + Attributes: models.FormAttributes{ + Label: "Impact", + Description: "What is the potential impact of this vulnerability?", }, }, }, @@ -672,27 +659,27 @@ func (s *IssueTemplateService) buildSecurityTemplate() string { 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{}{ + template := models.IssueTemplate{ + Name: "Tech Debt / Refactor", + Description: "Propose a refactoring or technical improvement", + Title: "[REFACTOR] ", + Labels: []string{"refactor", "tech-debt"}, + Body: []models.IssueFormItem{ { - "type": "textarea", - "id": "description", - "attributes": map[string]interface{}{ - "label": "Description", - "description": "What code needs refactoring?", + Type: "textarea", + ID: "description", + Attributes: models.FormAttributes{ + Label: "Description", + Description: "What code needs refactoring?", }, - "validations": map[string]bool{"required": true}, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "reason", - "attributes": map[string]interface{}{ - "label": "Reason", - "description": "Why should we do this? (e.g. readability, maintainability)", + Type: "textarea", + ID: "reason", + Attributes: models.FormAttributes{ + Label: "Reason", + Description: "Why should we do this? (e.g. readability, maintainability)", }, }, }, @@ -701,20 +688,20 @@ func (s *IssueTemplateService) buildTechDebtTemplate() string { 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{}{ + template := models.IssueTemplate{ + Name: "Question", + Description: "Ask a question about the project", + Title: "[QUESTION] ", + Labels: []string{"question"}, + Body: []models.IssueFormItem{ { - "type": "textarea", - "id": "question", - "attributes": map[string]interface{}{ - "label": "Question", - "description": "What would you like to know?", + Type: "textarea", + ID: "question", + Attributes: models.FormAttributes{ + Label: "Question", + Description: "What would you like to know?", }, - "validations": map[string]bool{"required": true}, + Validations: models.FormValidations{Required: true}, }, }, } @@ -722,26 +709,26 @@ func (s *IssueTemplateService) buildQuestionTemplate() string { 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{}{ + template := models.IssueTemplate{ + Name: "Dependency Update", + Description: "Update a project dependency", + Title: "[DEPENDENCY] ", + Labels: []string{"dependencies"}, + Body: []models.IssueFormItem{ { - "type": "input", - "id": "package", - "attributes": map[string]interface{}{ - "label": "Package Name", + Type: "input", + ID: "package", + Attributes: models.FormAttributes{ + Label: "Package Name", }, - "validations": map[string]bool{"required": true}, + Validations: models.FormValidations{Required: true}, }, { - "type": "textarea", - "id": "reason", - "attributes": map[string]interface{}{ - "label": "Reason for update", - "description": "Security fix, new features, etc.", + Type: "textarea", + ID: "reason", + Attributes: models.FormAttributes{ + Label: "Reason for update", + Description: "Security fix, new features, etc.", }, }, }, @@ -819,3 +806,24 @@ func (s *IssueTemplateService) MergeWithGeneratedContent(template *models.IssueT return result } + +func (s *IssueTemplateService) buildDefaultPRTemplate() string { + return `## Description + +## Related Issues + +## Type of Change + +- [ ] 🐛 Bug fix (non-breaking change which fixes an issue) +- [ ] ✨ New feature (non-breaking change which adds functionality) +- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] 📝 Documentation update +- [ ] 🎨 Style/Refactor (non-breaking change which improves code quality) +## Checklist +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +` +} diff --git a/internal/services/issue_template_service_test.go b/internal/services/issue_template_service_test.go index ab2e45f..e7f2788 100644 --- a/internal/services/issue_template_service_test.go +++ b/internal/services/issue_template_service_test.go @@ -208,11 +208,11 @@ func TestIssueTemplateService_MergeWithGeneratedContent(t *testing.T) { template := &models.IssueTemplate{ Title: "[BUG] ", Labels: []string{"bug"}, - Body: []interface{}{ - map[string]interface{}{ - "type": "markdown", - "attributes": map[string]interface{}{ - "value": "Thanks for reporting!", + Body: []models.IssueFormItem{ + { + Type: "markdown", + Attributes: models.FormAttributes{ + Value: "Thanks for reporting!", }, }, }, @@ -265,12 +265,12 @@ func TestIssueTemplateService_MergeWithGeneratedContent_Realistic(t *testing.T) service := NewIssueTemplateService(WithTemplateConfig(cfg)) template := &models.IssueTemplate{ Title: "[BUG] ", - Body: []interface{}{ - map[string]interface{}{ - "type": "textarea", - "id": "repro", - "attributes": map[string]interface{}{ - "label": "Steps to reproduce", + Body: []models.IssueFormItem{ + { + Type: "textarea", + ID: "repro", + Attributes: models.FormAttributes{ + Label: "Steps to reproduce", }, }, },