From 4ecdc2e682fac4567b4dab8716a6167dba7030e7 Mon Sep 17 00:00:00 2001 From: Thomas Vilte Date: Wed, 29 Jul 2026 18:46:13 -0300 Subject: [PATCH] feat(labels): filter out unavailable issue labels --- internal/services/issue_generator_service.go | 27 +++++++++++++ .../services/issue_generator_service_test.go | 38 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/internal/services/issue_generator_service.go b/internal/services/issue_generator_service.go index b0804d0..6c5602b 100644 --- a/internal/services/issue_generator_service.go +++ b/internal/services/issue_generator_service.go @@ -148,6 +148,7 @@ func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint strin logger.Debug(ctx, "labels inferred", "total_labels", len(result.Labels)) } + result.Labels = filterToAvailableLabels(result.Labels, availableLabels) logger.Info(ctx, "issue generated from diff successfully", "title", result.Title) @@ -155,6 +156,29 @@ func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint strin return result, nil } +// filterToAvailableLabels keeps only labels that actually exist in the +// repo, dropping anything a template hardcodes or a heuristic guesses that +// isn't real (e.g. an issue template's own YAML "labels:" field can list a +// label that was never created on GitHub). If availableLabels is empty +// (fetch failed, or no VCS client), there's nothing reliable to validate +// against, so labels are kept as-is. +func filterToAvailableLabels(labels, availableLabels []string) []string { + if len(availableLabels) == 0 { + return labels + } + allowed := make(map[string]bool, len(availableLabels)) + for _, l := range availableLabels { + allowed[strings.ToLower(l)] = true + } + filtered := make([]string, 0, len(labels)) + for _, l := range labels { + if allowed[strings.ToLower(l)] { + filtered = append(filtered, l) + } + } + return filtered +} + func (s *IssueGeneratorService) fetchAvailableLabels(ctx context.Context) ([]string, error) { if s.vcsClient == nil { return nil, nil @@ -225,6 +249,8 @@ func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, des if skipLabels { result.Labels = []string{} + } else { + result.Labels = filterToAvailableLabels(result.Labels, availableLabels) } logger.Info(ctx, "issue generated from description successfully", @@ -328,6 +354,7 @@ func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int logger.Debug(ctx, "labels inferred from PR", "total_labels", len(result.Labels)) } + result.Labels = filterToAvailableLabels(result.Labels, availableLabels) logger.Info(ctx, "issue generated from PR successfully", "pr_number", prNumber, diff --git a/internal/services/issue_generator_service_test.go b/internal/services/issue_generator_service_test.go index 4ebd1d0..488884c 100644 --- a/internal/services/issue_generator_service_test.go +++ b/internal/services/issue_generator_service_test.go @@ -509,6 +509,44 @@ func TestIssueGeneratorService_GenerateWithTemplate(t *testing.T) { mockAI.AssertExpectations(t) }) + t.Run("Success - drops template labels that don't exist in the repo", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + mockTemplate := new(MockIssueTemplateService) + mockVCS := new(testutil.MockVCSClient) + service := NewIssueGeneratorService(nil, mockAI, WithIssueTemplateService(mockTemplate), WithIssueVCSClient(mockVCS), WithIssueConfig(cfg)) + + // The template's own YAML hardcodes "optimization", which was never + // created as a real label on the repo — only "performance" is real. + template := &models.IssueTemplate{ + Name: "performance", + Title: "[PERF] ", + Labels: []string{"performance", "optimization"}, + } + generated := &models.IssueGenerationResult{ + Title: "Slow release preview", + Description: "The release preview command is slow", + Labels: []string{"performance", "refactor"}, + } + merged := &models.IssueGenerationResult{ + Title: "[PERF] Slow release preview", + Description: "The release preview command is slow\n---\n## Performance", + Labels: []string{"performance", "optimization", "refactor"}, + } + + mockTemplate.On("GetTemplateByName", ctx, "performance").Return(template, nil) + mockVCS.On("GetRepoLabels", ctx).Return([]string{"performance", "refactor", "release"}, nil) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(generated, nil) + mockTemplate.On("MergeWithGeneratedContent", template, mock.Anything).Return(merged) + + result, err := service.GenerateWithTemplate(ctx, "performance", "", false, "Slow release preview", false) + + assert.NoError(t, err) + assert.ElementsMatch(t, []string{"performance", "refactor"}, result.Labels) + assert.NotContains(t, result.Labels, "optimization") + mockTemplate.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + t.Run("Error - Template not found", func(t *testing.T) { mockAI := new(MockIssueContentGenerator) mockTemplate := new(MockIssueTemplateService)