Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/ai/gemini/issue_content_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions internal/ai/gemini/issue_content_generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
55 changes: 44 additions & 11 deletions internal/ai/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
}
34 changes: 28 additions & 6 deletions internal/ai/prompts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}

Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/config/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/issues/from_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()})
Expand Down
10 changes: 5 additions & 5 deletions internal/commands/issues/from_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions internal/commands/issues/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 5 additions & 6 deletions internal/commands/issues/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand All @@ -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"})
Expand All @@ -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)

Expand All @@ -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}}
Expand Down
12 changes: 6 additions & 6 deletions internal/commands/issues/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
24 changes: 24 additions & 0 deletions internal/models/issue_form.go
Original file line number Diff line number Diff line change
@@ -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"`
}
6 changes: 3 additions & 3 deletions internal/models/issue_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down
Loading
Loading