Skip to content
Open
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
8 changes: 8 additions & 0 deletions internal/commands/issues/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/commands/issues/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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"})
Expand All @@ -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)

Expand All @@ -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}}
Expand All @@ -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() {
Expand Down
8 changes: 8 additions & 0 deletions internal/commands/issues/mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/i18n/locales/active.en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions internal/i18n/locales/active.es.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions internal/services/issue_generator_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
55 changes: 55 additions & 0 deletions internal/services/issue_generator_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 8 additions & 0 deletions internal/testutil/mockvcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions internal/vcs/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/vcs/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading