From 2d706d62b1587587e1eb4d1050aa41bed07b9a56 Mon Sep 17 00:00:00 2001 From: Thomas Vilte Date: Wed, 29 Jul 2026 19:08:29 -0300 Subject: [PATCH] feat(issues): warn about similar open issues --- internal/commands/issues/issues.go | 8 +++ internal/commands/issues/issues_test.go | 5 ++ internal/commands/issues/mocks_test.go | 8 +++ internal/i18n/locales/active.en.toml | 1 + internal/i18n/locales/active.es.toml | 1 + internal/services/issue_generator_service.go | 67 +++++++++++++++++++ .../services/issue_generator_service_test.go | 55 +++++++++++++++ internal/testutil/mockvcs.go | 8 +++ internal/vcs/github/client.go | 38 +++++++++++ internal/vcs/interfaces.go | 2 + 10 files changed, 193 insertions(+) diff --git a/internal/commands/issues/issues.go b/internal/commands/issues/issues.go index df40ac3..ed61d64 100644 --- a/internal/commands/issues/issues.go +++ b/internal/commands/issues/issues.go @@ -25,6 +25,7 @@ type IssueGeneratorService interface { 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) + FindSimilarOpenIssues(ctx context.Context, title string) ([]models.Issue, error) GetAuthenticatedUser(ctx context.Context) (string, error) InferBranchName(issueNumber int, labels []string) string LinkIssueToPR(ctx context.Context, prNumber int, issueNumber int) error @@ -239,6 +240,13 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c _ = showPreview(result, t, cfg) ui.PrintTokenUsage(result.Usage, t) + if similar, err := issueService.FindSimilarOpenIssues(ctx, result.Title); err == nil && len(similar) > 0 { + ui.PrintWarning(t.GetMessage("issue.similar_issues_found", 0, struct{ Count int }{len(similar)})) + for _, s := range similar { + fmt.Printf(" #%d: %s (%s)\n", s.Number, s.Title, s.URL) + } + } + if dryRun { ui.PrintInfo(t.GetMessage("issue.dry_run_complete", 0, nil)) return nil diff --git a/internal/commands/issues/issues_test.go b/internal/commands/issues/issues_test.go index 7e0fbc1..3337e51 100644 --- a/internal/commands/issues/issues_test.go +++ b/internal/commands/issues/issues_test.go @@ -81,6 +81,7 @@ func TestIssueGenerateAction(t *testing.T) { } mockGen.On("GenerateFromDiff", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(expectedResult, nil) + mockGen.On("FindSimilarOpenIssues", mock.Anything, mock.Anything).Return([]models.Issue{}, 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}} @@ -102,6 +103,7 @@ func TestIssueGenerateAction(t *testing.T) { } mockGen.On("GenerateFromDescription", mock.Anything, "desc", false, true, mock.Anything).Return(expectedResult, nil) + mockGen.On("FindSimilarOpenIssues", mock.Anything, mock.Anything).Return([]models.Issue{}, nil) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} err := app.Run(context.Background(), []string{"test", "issue", "generate", "--description", "desc", "--dry-run"}) @@ -121,6 +123,7 @@ func TestIssueGenerateAction(t *testing.T) { } mockGen.On("GenerateFromDiff", mock.Anything, "", false, true, mock.Anything).Return(expectedResult, nil) + mockGen.On("FindSimilarOpenIssues", mock.Anything, mock.Anything).Return([]models.Issue{}, 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,6 +142,7 @@ func TestIssueGenerateAction(t *testing.T) { cmd := factory.CreateCommand(trans, cfg) mockGen.On("GenerateFromDiff", mock.Anything, "", false, true, mock.Anything).Return(&models.IssueGenerationResult{Title: "T"}, nil) + mockGen.On("FindSimilarOpenIssues", mock.Anything, mock.Anything).Return([]models.Issue{}, nil) withStdin("n\n", func() { app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} @@ -157,6 +161,7 @@ func TestIssueGenerateAction(t *testing.T) { expectedResult := &models.IssueGenerationResult{Title: "Template Issue"} mockGen.On("GenerateWithTemplate", mock.Anything, "bug", "", true, "", false).Return(expectedResult, nil) + mockGen.On("FindSimilarOpenIssues", mock.Anything, mock.Anything).Return([]models.Issue{}, nil) mockGen.On("CreateIssue", mock.Anything, expectedResult, []string(nil)).Return(&models.Issue{Number: 1}, nil) withStdin("y\n", func() { diff --git a/internal/commands/issues/mocks_test.go b/internal/commands/issues/mocks_test.go index 7f53ce5..9b43909 100644 --- a/internal/commands/issues/mocks_test.go +++ b/internal/commands/issues/mocks_test.go @@ -51,6 +51,14 @@ func (m *MockIssueGeneratorService) CreateIssue(ctx context.Context, result *mod return args.Get(0).(*models.Issue), args.Error(1) } +func (m *MockIssueGeneratorService) FindSimilarOpenIssues(ctx context.Context, title string) ([]models.Issue, error) { + args := m.Called(ctx, title) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]models.Issue), args.Error(1) +} + func (m *MockIssueGeneratorService) GetAuthenticatedUser(ctx context.Context) (string, error) { args := m.Called(ctx) return args.String(0), args.Error(1) diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index feee75f..da20038 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -766,6 +766,7 @@ preview_labels_label = "Labels" confirm_prompt = "Create this issue? (Y/n)" created_successfully = "✅ Issue #{{.Number}} created successfully: {{.URL}}" cancelled = "Issue creation cancelled" +similar_issues_found = "Found {{.Count}} possibly similar open issue(s):" dry_run_complete = "Dry run complete. No issue was created." error_no_input = "You must specify either --from-diff or --description" error_conflicting_flags = "Cannot use --from-diff and --description together" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index c215c91..e40798a 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -786,6 +786,7 @@ preview_labels_label = "Labels" confirm_prompt = "¿Crear esta issue? (Y/n)" created_successfully = "✅ Issue #{{.Number}} creada exitosamente: {{.URL}}" cancelled = "Creación de issue cancelada" +similar_issues_found = "Se encontraron {{.Count}} issue(s) abiertas posiblemente similares:" dry_run_complete = "Dry run completado. No se creó ninguna issue." error_no_input = "Debés especificar --from-diff o --description" error_conflicting_flags = "No podés usar --from-diff y --description juntos" diff --git a/internal/services/issue_generator_service.go b/internal/services/issue_generator_service.go index 6c5602b..ba9a18c 100644 --- a/internal/services/issue_generator_service.go +++ b/internal/services/issue_generator_service.go @@ -529,6 +529,73 @@ func (s *IssueGeneratorService) extractFilesFromDiff(diff string) []string { // CreateIssue creates a new issue in the repository using the configured VCS client. // It returns the created issue with its assigned number and URL. +// FindSimilarOpenIssues does a lightweight, local keyword-overlap match of +// the generated title against currently open issues, so the CLI can warn +// about likely duplicates before creating a new one. No AI call involved — +// this only needs to be fast and cheap, not exhaustive. +func (s *IssueGeneratorService) FindSimilarOpenIssues(ctx context.Context, title string) ([]models.Issue, error) { + if s.vcsClient == nil { + return nil, nil + } + + openIssues, err := s.vcsClient.ListOpenIssues(ctx) + if err != nil { + return nil, err + } + + titleWords := significantWords(title) + if len(titleWords) == 0 { + return nil, nil + } + + var similar []models.Issue + for _, issue := range openIssues { + otherWords := significantWords(issue.Title) + if len(otherWords) == 0 { + continue + } + + shared := 0 + for w := range titleWords { + if otherWords[w] { + shared++ + } + } + + smaller := len(titleWords) + if len(otherWords) < smaller { + smaller = len(otherWords) + } + + if shared >= 2 && float64(shared)/float64(smaller) >= 0.5 { + similar = append(similar, issue) + } + } + + return similar, nil +} + +// significantWords tokenizes text into lowercase, punctuation-stripped +// words, dropping short/common words that carry no real matching signal. +func significantWords(text string) map[string]bool { + stopwords := map[string]bool{ + "the": true, "a": true, "an": true, "to": true, "of": true, "in": true, + "for": true, "and": true, "or": true, "is": true, "on": true, "with": true, + "el": true, "la": true, "los": true, "las": true, "de": true, "en": true, + "un": true, "una": true, "para": true, "y": true, "o": true, "que": true, + } + + words := make(map[string]bool) + for _, w := range strings.Fields(strings.ToLower(text)) { + w = strings.Trim(w, ".,!?:;()[]{}'\"") + if len(w) < 3 || stopwords[w] { + continue + } + words[w] = true + } + return words +} + func (s *IssueGeneratorService) CreateIssue(ctx context.Context, result *models.IssueGenerationResult, assignees []string) (*models.Issue, error) { return s.vcsClient.CreateIssue(ctx, result.Title, result.Description, result.Labels, assignees) } diff --git a/internal/services/issue_generator_service_test.go b/internal/services/issue_generator_service_test.go index 488884c..abbfba6 100644 --- a/internal/services/issue_generator_service_test.go +++ b/internal/services/issue_generator_service_test.go @@ -639,6 +639,61 @@ func TestIssueGeneratorService_InferBranchName(t *testing.T) { } } +func TestIssueGeneratorService_FindSimilarOpenIssues(t *testing.T) { + ctx := context.Background() + + t.Run("finds an issue with a strongly overlapping title", func(t *testing.T) { + mockVCS := new(testutil.MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + mockVCS.On("ListOpenIssues", ctx).Return([]models.Issue{ + {Number: 10, Title: "Refactor release preview to parallelize GitHub API calls"}, + {Number: 11, Title: "Add dark mode support to the CLI"}, + }, nil) + + similar, err := service.FindSimilarOpenIssues(ctx, "Parallelize GitHub API calls in release preview command") + + assert.NoError(t, err) + assert.Len(t, similar, 1) + assert.Equal(t, 10, similar[0].Number) + }) + + t.Run("ignores issues with little title overlap", func(t *testing.T) { + mockVCS := new(testutil.MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + mockVCS.On("ListOpenIssues", ctx).Return([]models.Issue{ + {Number: 20, Title: "Add dark mode support to the CLI"}, + }, nil) + + similar, err := service.FindSimilarOpenIssues(ctx, "Fix panic when config file is missing") + + assert.NoError(t, err) + assert.Empty(t, similar) + }) + + t.Run("returns nil when no VCS client is configured", func(t *testing.T) { + service := &IssueGeneratorService{} + + similar, err := service.FindSimilarOpenIssues(ctx, "Any title") + + assert.NoError(t, err) + assert.Nil(t, similar) + }) + + t.Run("propagates errors from ListOpenIssues", func(t *testing.T) { + mockVCS := new(testutil.MockVCSClient) + service := &IssueGeneratorService{vcsClient: mockVCS} + + mockVCS.On("ListOpenIssues", ctx).Return(nil, errors.New("api error")) + + similar, err := service.FindSimilarOpenIssues(ctx, "Any title") + + assert.Error(t, err) + assert.Nil(t, similar) + }) +} + func TestIssueGeneratorService_CreateIssue(t *testing.T) { ctx := context.Background() diff --git a/internal/testutil/mockvcs.go b/internal/testutil/mockvcs.go index 0b57c80..f2202eb 100644 --- a/internal/testutil/mockvcs.go +++ b/internal/testutil/mockvcs.go @@ -33,6 +33,14 @@ func (m *MockVCSClient) GetRepoLabelsWithDescriptions(ctx context.Context) ([]mo return args.Get(0).([]models.RepoLabel), args.Error(1) } +func (m *MockVCSClient) ListOpenIssues(ctx context.Context) ([]models.Issue, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]models.Issue), args.Error(1) +} + func (m *MockVCSClient) CreateLabel(ctx context.Context, name, color, description string) error { args := m.Called(ctx, name, color, description) return args.Error(0) diff --git a/internal/vcs/github/client.go b/internal/vcs/github/client.go index bc9799b..54bf1b4 100644 --- a/internal/vcs/github/client.go +++ b/internal/vcs/github/client.go @@ -514,6 +514,44 @@ func (ghc *GitHubClient) GetClosedIssuesBetweenTags(ctx context.Context, previou return allIssues, nil } +// ListOpenIssues fetches currently open issues, most recently updated +// first, capped to a single page so a duplicate check stays cheap and fast. +func (ghc *GitHubClient) ListOpenIssues(ctx context.Context) ([]models.Issue, error) { + opts := &github.IssueListByRepoOptions{ + State: "open", + Sort: "updated", + Direction: "desc", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + issues, _, err := ghc.issuesService.ListByRepo(ctx, ghc.owner, ghc.repo, opts) + if err != nil { + return nil, err + } + + result := make([]models.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.PullRequestLinks != nil { + continue + } + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + labels = append(labels, label.GetName()) + } + result = append(result, models.Issue{ + Number: issue.GetNumber(), + Title: issue.GetTitle(), + Labels: labels, + Author: issue.GetUser().GetLogin(), + URL: issue.GetHTMLURL(), + }) + } + + return result, nil +} + func (ghc *GitHubClient) GetMergedPRsBetweenTags(ctx context.Context, previousTag, _ string) ([]models.PullRequest, error) { prevRelease, _, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, previousTag) if err != nil { diff --git a/internal/vcs/interfaces.go b/internal/vcs/interfaces.go index 5ad281b..0d671ad 100644 --- a/internal/vcs/interfaces.go +++ b/internal/vcs/interfaces.go @@ -38,6 +38,8 @@ type VCSClient interface { GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) // GetIssue gets information for an issue/ticket by its number GetIssue(ctx context.Context, issueNumber int) (*models.Issue, error) + // ListOpenIssues gets currently open issues in the repository, most recently updated first + ListOpenIssues(ctx context.Context) ([]models.Issue, error) // GetFileAtTag gets the content of a file at a specific tag GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) // GetPRIssues gets issues related to a PR based on branch name, commits, and description