diff --git a/internal/discord/commands.go b/internal/discord/commands.go index 29e6ab0..efdc763 100644 --- a/internal/discord/commands.go +++ b/internal/discord/commands.go @@ -48,5 +48,37 @@ func getCommands() []*discordgo.ApplicationCommand { }, }, }, + { + Name: "changelog", + Description: "View changes between two versions", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "base", + Description: "The base version (e.g. v2.6.0)", + Required: true, + Autocomplete: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "head", + Description: "The head version (e.g. v2.6.4)", + Required: true, + Autocomplete: true, + }, + }, + }, + { + Name: "repo", + Description: "Get the GitHub URL for a repository", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "name", + Description: "Repository name (optional, defaults to current repo)", + Required: false, + }, + }, + }, } } diff --git a/internal/discord/handlers.go b/internal/discord/handlers.go index 71e87dc..16f5b4d 100644 --- a/internal/discord/handlers.go +++ b/internal/discord/handlers.go @@ -12,7 +12,7 @@ import ( ) var ( - githubClient *github.Client + githubClient github.Client ) // ModalState tracks the state of multi-part modals diff --git a/internal/discord/handlers/changelog_handler.go b/internal/discord/handlers/changelog_handler.go new file mode 100644 index 0000000..130c9a1 --- /dev/null +++ b/internal/discord/handlers/changelog_handler.go @@ -0,0 +1,239 @@ +package handlers + +import ( + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/bwmarrin/discordgo" + gogithub "github.com/google/go-github/v57/github" +) + +const ( + // ReleaseCacheTTL defines how long release autocomplete data is cached + ReleaseCacheTTL = 1 * time.Hour + + // ComparisonCacheTTL defines how long changelog comparison results are cached + ComparisonCacheTTL = 1 * time.Hour +) + +var ( + releaseCache []*gogithub.RepositoryRelease + releaseCacheMutex sync.RWMutex + lastCacheUpdate time.Time + cacheDuration = ReleaseCacheTTL + + comparisonCache map[string]*CachedComparison + comparisonCacheMutex sync.RWMutex + comparisonCacheTTL = ComparisonCacheTTL +) + +type CachedComparison struct { + Message string + Timestamp time.Time +} + +func init() { + comparisonCache = make(map[string]*CachedComparison) +} + +func handleChangelog(s *discordgo.Session, i *discordgo.InteractionCreate) { + options := i.ApplicationCommandData().Options + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + var base, head string + if opt, ok := optionMap["base"]; ok { + base = opt.StringValue() + } + if opt, ok := optionMap["head"]; ok { + head = opt.StringValue() + } + + if base == "" || head == "" { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Please provide both base and head versions.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Defer response as API call might take time + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + }) + + message, err := getChangelogMessage(base, head) + if err != nil { + log.Printf("Error getting changelog: %v", err) + errMsg := fmt.Sprintf("Failed to compare versions: %s...%s", base, head) + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &errMsg, + }) + return + } + + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &message, + }) +} + +func getChangelogMessage(base, head string) (string, error) { + cacheKey := fmt.Sprintf("%s...%s", base, head) + + // First check with read lock + comparisonCacheMutex.RLock() + if cached, exists := comparisonCache[cacheKey]; exists { + if time.Since(cached.Timestamp) < comparisonCacheTTL { + comparisonCacheMutex.RUnlock() + return cached.Message, nil + } + } + comparisonCacheMutex.RUnlock() + + // Cache miss or expired - acquire write lock + comparisonCacheMutex.Lock() + defer comparisonCacheMutex.Unlock() + + // Double-check after acquiring write lock + if cached, exists := comparisonCache[cacheKey]; exists { + if time.Since(cached.Timestamp) < comparisonCacheTTL { + return cached.Message, nil + } + } + + // Fetch from GitHub + comparison, err := GithubClient.CompareCommits(GithubOwner, GithubRepo, base, head) + if err != nil { + return "", err + } + + message := formatChangelogMessage(base, head, comparison) + + // Store in cache + comparisonCache[cacheKey] = &CachedComparison{ + Message: message, + Timestamp: time.Now(), + } + + return message, nil +} + +func formatChangelogMessage(base, head string, comparison *gogithub.CommitsComparison) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## Changes from %s to %s\n", base, head)) + sb.WriteString(fmt.Sprintf("Total commits: %d\n\n", comparison.GetTotalCommits())) + + // List commits (limit to last 10 to avoid hitting message length limits) + commits := comparison.Commits + if len(commits) > 10 { + sb.WriteString(fmt.Sprintf("*Showing last 10 of %d commits*\n\n", len(commits))) + commits = commits[len(commits)-10:] + } + + for _, commit := range commits { + message := commit.GetCommit().GetMessage() + // Take only the first line of the commit message + if idx := strings.Index(message, "\n"); idx != -1 { + message = message[:idx] + } + + author := commit.GetAuthor().GetLogin() + if author == "" { + commitAuthor := commit.GetCommit().GetAuthor() + if commitAuthor != nil { + author = commitAuthor.GetName() + } else { + author = "Unknown" + } + } + + sha := commit.GetSHA() + if len(sha) > 7 { + sha = sha[:7] + } + sb.WriteString(fmt.Sprintf("- [`%s`](<%s>) %s - *%s*\n", + sha, + commit.GetHTMLURL(), + message, + author, + )) + } + + sb.WriteString(fmt.Sprintf("\n[View Full Comparison](<%s>)", comparison.GetHTMLURL())) + return sb.String() +} + +func handleChangelogAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Update cache if needed + if err := updateReleaseCache(); err != nil { + log.Printf("Error updating release cache: %v", err) + } + + releaseCacheMutex.RLock() + defer releaseCacheMutex.RUnlock() + + data := i.ApplicationCommandData() + var currentInput string + for _, opt := range data.Options { + if opt.Focused { + currentInput = strings.ToLower(opt.StringValue()) + break + } + } + + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, 25) + for _, release := range releaseCache { + tagName := release.GetTagName() + if currentInput == "" || strings.Contains(strings.ToLower(tagName), currentInput) { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: tagName, + Value: tagName, + }) + } + if len(choices) >= 25 { + break + } + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: choices, + }, + }) +} + +func updateReleaseCache() error { + releaseCacheMutex.RLock() + if time.Since(lastCacheUpdate) < cacheDuration && len(releaseCache) > 0 { + releaseCacheMutex.RUnlock() + return nil + } + releaseCacheMutex.RUnlock() + + releaseCacheMutex.Lock() + defer releaseCacheMutex.Unlock() + + // Double check after acquiring write lock + if time.Since(lastCacheUpdate) < cacheDuration && len(releaseCache) > 0 { + return nil + } + + // Fetch releases + releases, err := GithubClient.GetReleases(GithubOwner, GithubRepo, 100) + if err != nil { + return err + } + + releaseCache = releases + lastCacheUpdate = time.Now() + return nil +} diff --git a/internal/discord/handlers/changelog_handler_test.go b/internal/discord/handlers/changelog_handler_test.go new file mode 100644 index 0000000..15767c2 --- /dev/null +++ b/internal/discord/handlers/changelog_handler_test.go @@ -0,0 +1,1881 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/bwmarrin/discordgo" + gogithub "github.com/google/go-github/v57/github" + internalgithub "github.com/meshtastic/meshtastic-bot/internal/github" +) + +// MockGitHubClient implements internalgithub.Client interface +type MockGitHubClient struct { + GetReleasesFunc func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) + CompareCommitsFunc func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) + CreateIssueFunc func(owner, repo, title, body string, labels []string) (*internalgithub.IssueResponse, error) + GetRepositoryFunc func(owner, repo string) (*gogithub.Repository, error) +} + +func (m *MockGitHubClient) GetReleases(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + if m.GetReleasesFunc != nil { + return m.GetReleasesFunc(owner, repo, limit) + } + return nil, nil +} + +func (m *MockGitHubClient) CompareCommits(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + if m.CompareCommitsFunc != nil { + return m.CompareCommitsFunc(owner, repo, base, head) + } + return nil, nil +} + +func (m *MockGitHubClient) CreateIssue(owner, repo, title, body string, labels []string) (*internalgithub.IssueResponse, error) { + if m.CreateIssueFunc != nil { + return m.CreateIssueFunc(owner, repo, title, body, labels) + } + return nil, nil +} + +func (m *MockGitHubClient) GetRepository(owner, repo string) (*gogithub.Repository, error) { + if m.GetRepositoryFunc != nil { + return m.GetRepositoryFunc(owner, repo) + } + return nil, nil +} + +type MockRoundTripper struct { + RoundTripFunc func(req *http.Request) (*http.Response, error) +} + +func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if m.RoundTripFunc != nil { + return m.RoundTripFunc(req) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + }, + nil +} + +func TestFormatChangelogMessage(t *testing.T) { + strPtr := func(s string) *string { return &s } + intPtr := func(i int) *int { return &i } + + tests := []struct { + name string + base string + head string + comparison *gogithub.CommitsComparison + want []string // Substrings that should be present + dontWant []string // Substrings that should NOT be present + }{ + { + name: "basic comparison", + base: "v1.0.0", + head: "v1.1.0", + comparison: &gogithub.CommitsComparison{ + TotalCommits: intPtr(2), + HTMLURL: strPtr("https://github.com/org/repo/compare/v1.0.0...v1.1.0"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("abcdef123456"), + HTMLURL: strPtr("https://github.com/org/repo/commit/abcdef1"), + Commit: &gogithub.Commit{ + Message: strPtr("feat: cool feature"), + Author: &gogithub.CommitAuthor{ + Name: strPtr("John Doe"), + }, + }, + Author: &gogithub.User{ + Login: strPtr("johndoe"), + }, + }, + { + SHA: strPtr("123456abcdef"), + HTMLURL: strPtr("https://github.com/org/repo/commit/123456a"), + Commit: &gogithub.Commit{ + Message: strPtr("fix: nasty bug\n\nSome details"), + Author: &gogithub.CommitAuthor{ + Name: strPtr("Jane Smith"), + }, + }, + Author: &gogithub.User{ + Login: strPtr("janesmith"), + }, + }, + }, + }, + want: []string{ + "## Changes from v1.0.0 to v1.1.0", + "Total commits: 2", + "[`abcdef1`]()", + "feat: cool feature", + "johndoe", + "[`123456a`]()", + "fix: nasty bug", + "janesmith", + "[View Full Comparison]()", + }, + dontWant: []string{ + "Some details", + "Showing last 10", + }, + }, + { + name: "many commits truncated", + base: "v1.0.0", + head: "v1.1.0", + comparison: &gogithub.CommitsComparison{ + TotalCommits: intPtr(15), + HTMLURL: strPtr("https://github.com/compare"), + Commits: func() []*gogithub.RepositoryCommit { + commits := make([]*gogithub.RepositoryCommit, 15) + for i := 0; i < 15; i++ { + commits[i] = &gogithub.RepositoryCommit{ + SHA: strPtr("longhashvalue"), + HTMLURL: strPtr("url"), + Commit: &gogithub.Commit{ + Message: strPtr("msg"), + Author: &gogithub.CommitAuthor{Name: strPtr("author")}, + }, + Author: &gogithub.User{Login: strPtr("user")}, + } + } + return commits + }(), + }, + want: []string{ + "Total commits: 15", + "*Showing last 10 of 15 commits*", + }, + }, + { + name: "nil author with fallback to commit author", + base: "v1.0.0", + head: "v1.1.0", + comparison: &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("abc123"), + HTMLURL: strPtr("https://github.com/commit/abc123"), + Commit: &gogithub.Commit{ + Message: strPtr("commit with nil author"), + Author: &gogithub.CommitAuthor{ + Name: strPtr("Commit Author"), + }, + }, + Author: nil, // nil author should trigger fallback + }, + }, + }, + want: []string{ + "commit with nil author", + "Commit Author", + }, + }, + { + name: "nil commit author with fallback to Unknown", + base: "v1.0.0", + head: "v1.1.0", + comparison: &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("def456"), + HTMLURL: strPtr("https://github.com/commit/def456"), + Commit: &gogithub.Commit{ + Message: strPtr("commit with all authors nil"), + Author: nil, // nil commit author + }, + Author: nil, // nil author + }, + }, + }, + want: []string{ + "commit with all authors nil", + "Unknown", + }, + }, + { + name: "empty author login with fallback to commit author", + base: "v1.0.0", + head: "v1.1.0", + comparison: &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("ghi789"), + HTMLURL: strPtr("https://github.com/commit/ghi789"), + Commit: &gogithub.Commit{ + Message: strPtr("commit with empty login"), + Author: &gogithub.CommitAuthor{ + Name: strPtr("Fallback Author"), + }, + }, + Author: &gogithub.User{ + Login: strPtr(""), // empty login + }, + }, + }, + }, + want: []string{ + "commit with empty login", + "Fallback Author", + }, + }, + { + name: "nil commit object - tests GetCommit() returning nil", + base: "v1.0.0", + head: "v1.1.0", + comparison: &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("jkl012"), + HTMLURL: strPtr("https://github.com/commit/jkl012"), + Commit: nil, // nil commit object + Author: &gogithub.User{ + Login: strPtr("testuser"), + }, + }, + }, + }, + want: []string{ + "Total commits: 1", + "testuser", + }, + }, + { + name: "nil commit and nil author - complete fallback to Unknown", + base: "v1.0.0", + head: "v1.1.0", + comparison: &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("mno345"), + HTMLURL: strPtr("https://github.com/commit/mno345"), + Commit: nil, // nil commit + Author: nil, // nil author + }, + }, + }, + want: []string{ + "Total commits: 1", + "Unknown", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatChangelogMessage(tt.base, tt.head, tt.comparison) + + for _, w := range tt.want { + if !strings.Contains(got, w) { + t.Errorf("formatChangelogMessage() missing %q\nGot:\n%s", w, got) + } + } + + for _, dw := range tt.dontWant { + if strings.Contains(got, dw) { + t.Errorf("formatChangelogMessage() unexpectedly contains %q", dw) + } + } + }) + } +} + +func TestHandleChangelogAutocomplete(t *testing.T) { + // Save original GithubClient and restore after test + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + tests := []struct { + name string + releases []*gogithub.RepositoryRelease + releaseErr error + userInput string + expectedCount int + expectedValues []string + }{ + { + name: "Cache Update Success - Matches All", + releases: []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + {TagName: gogithub.String("v1.1.0")}, + }, + userInput: "", + expectedCount: 2, + expectedValues: []string{"v1.0.0", "v1.1.0"}, + }, + { + name: "Filtering", + releases: []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + {TagName: gogithub.String("v2.0.0")}, + {TagName: gogithub.String("beta-v3")}, + }, + userInput: "v1", + expectedCount: 1, + expectedValues: []string{"v1.0.0"}, + }, + { + name: "Empty Cache Handling", + releases: []*gogithub.RepositoryRelease{}, + userInput: "", + expectedCount: 0, + }, + { + name: "Error Handling - Cache Update Fails", + releaseErr: errors.New("API Error"), + userInput: "", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup Mock GitHub Client + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return tt.releases, tt.releaseErr + }, + } + GithubClient = mockClient + + // Reset cache for each test run to ensure updateReleaseCache is called + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + // Setup Mock Discord Session + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + // Verify request + if req.Method != "POST" { + t.Errorf("Expected POST request, got %s", req.Method) + } + + // Parse body + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + + if data.Type != discordgo.InteractionApplicationCommandAutocompleteResult { + t.Errorf("Expected response type AutocompleteResult, got %v", data.Type) + } + + choices := data.Data.Choices + if len(choices) != tt.expectedCount { + t.Errorf("Expected %d choices, got %d", tt.expectedCount, len(choices)) + } + + for i, val := range tt.expectedValues { + if i < len(choices) && choices[i].Value != val { + t.Errorf("Expected choice %d to be %s, got %v", i, val, choices[i].Value) + } + } + + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + // Create Interaction + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommandAutocomplete, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Focused: true, + Value: tt.userInput, + Type: discordgo.ApplicationCommandOptionString, + Name: "option", + }, + }, + }, + }, + } + + handleChangelogAutocomplete(s, i) + }) + } +} + +func TestHandleChangelogAutocomplete_Limit(t *testing.T) { + // Save original GithubClient and restore after test + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + // Mock with 30 releases + releases := make([]*gogithub.RepositoryRelease, 30) + for i := 0; i < 30; i++ { + tagName := "v" + strings.Repeat("1", i+1) // Just unique names + releases[i] = &gogithub.RepositoryRelease{TagName: &tagName} + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return releases, nil + }, + } + GithubClient = mockClient + + // Reset cache + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode body: %v", err) + } + choices := data.Data.Choices + if len(choices) != 25 { + t.Errorf("Expected 25 choices, got %d", len(choices)) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString("{}"))}, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommandAutocomplete, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Focused: true, + Value: "", + Type: discordgo.ApplicationCommandOptionString, + }, + }, + }, + }, + } + + handleChangelogAutocomplete(s, i) +} + +func TestHandleChangelogAutocomplete_CaseInsensitive(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("V1.0.0")}, + {TagName: gogithub.String("V1.1.0")}, + {TagName: gogithub.String("v2.0.0")}, + {TagName: gogithub.String("Beta-V3")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode body: %v", err) + } + choices := data.Data.Choices + if len(choices) != 2 { + t.Errorf("Expected 2 choices (case-insensitive match), got %d", len(choices)) + } + expectedValues := map[string]bool{"V1.0.0": true, "V1.1.0": true} + for _, choice := range choices { + if !expectedValues[choice.Value.(string)] { + t.Errorf("Unexpected choice value: %v", choice.Value) + } + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString("{}"))}, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommandAutocomplete, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Focused: true, + Value: "V1", + Type: discordgo.ApplicationCommandOptionString, + }, + }, + }, + }, + } + + handleChangelogAutocomplete(s, i) +} + +func TestHandleChangelogAutocomplete_NoFocusedOption(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + {TagName: gogithub.String("v2.0.0")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode body: %v", err) + } + choices := data.Data.Choices + if len(choices) != 2 { + t.Errorf("Expected 2 choices (all releases when no focus), got %d", len(choices)) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString("{}"))}, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommandAutocomplete, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Focused: false, + Value: "v1", + Type: discordgo.ApplicationCommandOptionString, + }, + }, + }, + }, + } + + handleChangelogAutocomplete(s, i) +} + +func TestHandleChangelogAutocomplete_CacheReuse(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + apiCallCount := 0 + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + apiCallCount++ + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString("{}")), Header: make(http.Header)}, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommandAutocomplete, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Focused: true, + Value: "", + Type: discordgo.ApplicationCommandOptionString, + }, + }, + }, + }, + } + + handleChangelogAutocomplete(s, i) + if apiCallCount != 1 { + t.Errorf("Expected 1 API call on first request, got %d", apiCallCount) + } + + handleChangelogAutocomplete(s, i) + if apiCallCount != 1 { + t.Errorf("Expected cache reuse (still 1 API call), got %d", apiCallCount) + } + + releaseCacheMutex.Lock() + lastCacheUpdate = time.Now().Add(-2 * time.Hour) + releaseCacheMutex.Unlock() + + handleChangelogAutocomplete(s, i) + if apiCallCount != 2 { + t.Errorf("Expected cache refresh (2 API calls after expiry), got %d", apiCallCount) + } +} + +func TestHandleChangelogAutocomplete_PartialMatch(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0-beta")}, + {TagName: gogithub.String("v1.0.0-alpha")}, + {TagName: gogithub.String("v2.0.0")}, + {TagName: gogithub.String("beta-release-3")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode body: %v", err) + } + choices := data.Data.Choices + if len(choices) != 2 { + t.Errorf("Expected 2 choices matching 'beta', got %d", len(choices)) + } + expectedValues := map[string]bool{"v1.0.0-beta": true, "beta-release-3": true} + for _, choice := range choices { + if !expectedValues[choice.Value.(string)] { + t.Errorf("Unexpected choice value: %v", choice.Value) + } + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString("{}"))}, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommandAutocomplete, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Focused: true, + Value: "beta", + Type: discordgo.ApplicationCommandOptionString, + }, + }, + }, + }, + } + + handleChangelogAutocomplete(s, i) +} + +func TestUpdateReleaseCache_InitialLoad(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + {TagName: gogithub.String("v2.0.0")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + err := updateReleaseCache() + if err != nil { + t.Errorf("Expected no error on initial cache load, got %v", err) + } + + releaseCacheMutex.RLock() + defer releaseCacheMutex.RUnlock() + + if len(releaseCache) != 2 { + t.Errorf("Expected 2 releases in cache, got %d", len(releaseCache)) + } + + if lastCacheUpdate.IsZero() { + t.Error("Expected lastCacheUpdate to be set, but it's zero") + } + + if time.Since(lastCacheUpdate) > time.Second { + t.Errorf("Expected lastCacheUpdate to be recent, but it's %v old", time.Since(lastCacheUpdate)) + } +} + +func TestUpdateReleaseCache_CacheExpiration(t *testing.T) { + originalClient := GithubClient + originalCacheDuration := cacheDuration + defer func() { + GithubClient = originalClient + cacheDuration = originalCacheDuration + }() + + cacheDuration = 100 * time.Millisecond + + apiCallCount := 0 + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + apiCallCount++ + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + err := updateReleaseCache() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if apiCallCount != 1 { + t.Errorf("Expected 1 API call initially, got %d", apiCallCount) + } + + err = updateReleaseCache() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if apiCallCount != 1 { + t.Errorf("Expected cache to be reused (still 1 API call), got %d", apiCallCount) + } + + time.Sleep(150 * time.Millisecond) + + err = updateReleaseCache() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if apiCallCount != 2 { + t.Errorf("Expected cache to expire and refresh (2 API calls), got %d", apiCallCount) + } +} + +func TestUpdateReleaseCache_ErrorHandling(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + expectedErr := errors.New("GitHub API error") + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return nil, expectedErr + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + err := updateReleaseCache() + if err == nil { + t.Error("Expected error from failed API call, got nil") + } + + if !errors.Is(err, expectedErr) { + t.Errorf("Expected error to be %v, got %v", expectedErr, err) + } + + releaseCacheMutex.RLock() + cacheIsEmpty := len(releaseCache) == 0 + releaseCacheMutex.RUnlock() + + if !cacheIsEmpty { + t.Error("Expected cache to remain empty after error") + } +} + +func TestUpdateReleaseCache_ConcurrentAccess(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + apiCallCount := 0 + var apiCallMutex sync.Mutex + + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + apiCallMutex.Lock() + apiCallCount++ + apiCallMutex.Unlock() + time.Sleep(50 * time.Millisecond) + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + const numGoroutines = 10 + var wg sync.WaitGroup + wg.Add(numGoroutines) + + errChan := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + if err := updateReleaseCache(); err != nil { + errChan <- err + } + }() + } + + wg.Wait() + close(errChan) + + for err := range errChan { + t.Errorf("Unexpected error from goroutine: %v", err) + } + + apiCallMutex.Lock() + finalCallCount := apiCallCount + apiCallMutex.Unlock() + + if finalCallCount != 1 { + t.Errorf("Expected exactly 1 API call despite concurrent access, got %d", finalCallCount) + } + + releaseCacheMutex.RLock() + cacheLen := len(releaseCache) + releaseCacheMutex.RUnlock() + + if cacheLen != 1 { + t.Errorf("Expected 1 release in cache, got %d", cacheLen) + } +} + +func TestUpdateReleaseCache_DoubleCheckedLocking(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + apiCallCount := 0 + var apiCallMutex sync.Mutex + + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + apiCallMutex.Lock() + apiCallCount++ + apiCallMutex.Unlock() + time.Sleep(100 * time.Millisecond) + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + const numGoroutines = 5 + var wg sync.WaitGroup + startBarrier := make(chan struct{}) + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-startBarrier + updateReleaseCache() + }() + } + + close(startBarrier) + wg.Wait() + + apiCallMutex.Lock() + finalCallCount := apiCallCount + apiCallMutex.Unlock() + + if finalCallCount != 1 { + t.Errorf("Double-checked locking failed: expected 1 API call, got %d", finalCallCount) + } +} + +func TestUpdateReleaseCache_EmptyCacheWithExpiredTime(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + releases := []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + } + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return releases, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Now().Add(-10 * time.Minute) + releaseCacheMutex.Unlock() + + err := updateReleaseCache() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + releaseCacheMutex.RLock() + cacheLen := len(releaseCache) + releaseCacheMutex.RUnlock() + + if cacheLen != 1 { + t.Errorf("Expected cache to be populated, got %d releases", cacheLen) + } +} + +func TestUpdateReleaseCache_EmptyReleasesFromAPI(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + return []*gogithub.RepositoryRelease{}, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + err := updateReleaseCache() + if err != nil { + t.Errorf("Expected no error when API returns empty releases, got %v", err) + } + + releaseCacheMutex.RLock() + defer releaseCacheMutex.RUnlock() + + if releaseCache == nil { + t.Error("Expected releaseCache to be initialized (empty slice), not nil") + } + + if len(releaseCache) != 0 { + t.Errorf("Expected empty cache, got %d releases", len(releaseCache)) + } + + if lastCacheUpdate.IsZero() { + t.Error("Expected lastCacheUpdate to be set even with empty releases") + } +} + +func TestUpdateReleaseCache_ParametersPassedCorrectly(t *testing.T) { + originalClient := GithubClient + originalOwner := GithubOwner + originalRepo := GithubRepo + defer func() { + GithubClient = originalClient + GithubOwner = originalOwner + GithubRepo = originalRepo + }() + + GithubOwner = "test-owner" + GithubRepo = "test-repo" + + var capturedOwner, capturedRepo string + var capturedLimit int + + mockClient := &MockGitHubClient{ + GetReleasesFunc: func(owner, repo string, limit int) ([]*gogithub.RepositoryRelease, error) { + capturedOwner = owner + capturedRepo = repo + capturedLimit = limit + return []*gogithub.RepositoryRelease{ + {TagName: gogithub.String("v1.0.0")}, + }, nil + }, + } + GithubClient = mockClient + + releaseCacheMutex.Lock() + releaseCache = nil + lastCacheUpdate = time.Time{} + releaseCacheMutex.Unlock() + + err := updateReleaseCache() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if capturedOwner != "test-owner" { + t.Errorf("Expected owner 'test-owner', got %q", capturedOwner) + } + + if capturedRepo != "test-repo" { + t.Errorf("Expected repo 'test-repo', got %q", capturedRepo) + } + + if capturedLimit != 100 { + t.Errorf("Expected limit 100, got %d", capturedLimit) + } +} + +func TestGetChangelogMessage_CacheMiss(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + apiCallCount := 0 + strPtr := func(s string) *string { return &s } + intPtr := func(i int) *int { return &i } + + mockClient := &MockGitHubClient{ + CompareCommitsFunc: func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + apiCallCount++ + return &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("abc123"), + HTMLURL: strPtr("https://github.com/commit/abc123"), + Commit: &gogithub.Commit{ + Message: strPtr("test commit"), + Author: &gogithub.CommitAuthor{Name: strPtr("Test Author")}, + }, + Author: &gogithub.User{Login: strPtr("testuser")}, + }, + }, + }, nil + }, + } + GithubClient = mockClient + + comparisonCacheMutex.Lock() + comparisonCache = make(map[string]*CachedComparison) + comparisonCacheMutex.Unlock() + + message, err := getChangelogMessage("v1.0.0", "v2.0.0") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if apiCallCount != 1 { + t.Errorf("Expected 1 API call on cache miss, got %d", apiCallCount) + } + + if !strings.Contains(message, "v1.0.0") || !strings.Contains(message, "v2.0.0") { + t.Errorf("Expected message to contain version info, got: %s", message) + } +} + +func TestGetChangelogMessage_CacheHit(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + apiCallCount := 0 + strPtr := func(s string) *string { return &s } + intPtr := func(i int) *int { return &i } + + mockClient := &MockGitHubClient{ + CompareCommitsFunc: func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + apiCallCount++ + return &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("abc123"), + HTMLURL: strPtr("https://github.com/commit/abc123"), + Commit: &gogithub.Commit{ + Message: strPtr("test commit"), + Author: &gogithub.CommitAuthor{Name: strPtr("Test Author")}, + }, + Author: &gogithub.User{Login: strPtr("testuser")}, + }, + }, + }, nil + }, + } + GithubClient = mockClient + + comparisonCacheMutex.Lock() + comparisonCache = make(map[string]*CachedComparison) + comparisonCacheMutex.Unlock() + + message1, err := getChangelogMessage("v1.0.0", "v2.0.0") + if err != nil { + t.Errorf("Expected no error on first call, got %v", err) + } + + if apiCallCount != 1 { + t.Errorf("Expected 1 API call on first request, got %d", apiCallCount) + } + + message2, err := getChangelogMessage("v1.0.0", "v2.0.0") + if err != nil { + t.Errorf("Expected no error on second call, got %v", err) + } + + if apiCallCount != 1 { + t.Errorf("Expected cache hit (still 1 API call), got %d", apiCallCount) + } + + if message1 != message2 { + t.Error("Expected cached message to match original") + } +} + +func TestGetChangelogMessage_CacheExpiration(t *testing.T) { + originalClient := GithubClient + originalTTL := comparisonCacheTTL + defer func() { + GithubClient = originalClient + comparisonCacheTTL = originalTTL + }() + + comparisonCacheTTL = 100 * time.Millisecond + + apiCallCount := 0 + strPtr := func(s string) *string { return &s } + intPtr := func(i int) *int { return &i } + + mockClient := &MockGitHubClient{ + CompareCommitsFunc: func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + apiCallCount++ + return &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("abc123"), + HTMLURL: strPtr("https://github.com/commit/abc123"), + Commit: &gogithub.Commit{ + Message: strPtr("test commit"), + Author: &gogithub.CommitAuthor{Name: strPtr("Test Author")}, + }, + Author: &gogithub.User{Login: strPtr("testuser")}, + }, + }, + }, nil + }, + } + GithubClient = mockClient + + comparisonCacheMutex.Lock() + comparisonCache = make(map[string]*CachedComparison) + comparisonCacheMutex.Unlock() + + _, err := getChangelogMessage("v1.0.0", "v2.0.0") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if apiCallCount != 1 { + t.Errorf("Expected 1 API call initially, got %d", apiCallCount) + } + + time.Sleep(150 * time.Millisecond) + + _, err = getChangelogMessage("v1.0.0", "v2.0.0") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if apiCallCount != 2 { + t.Errorf("Expected cache expiration (2 API calls), got %d", apiCallCount) + } +} + +func TestGetChangelogMessage_ErrorHandling(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + expectedErr := errors.New("GitHub API error") + + mockClient := &MockGitHubClient{ + CompareCommitsFunc: func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + return nil, expectedErr + }, + } + GithubClient = mockClient + + comparisonCacheMutex.Lock() + comparisonCache = make(map[string]*CachedComparison) + comparisonCacheMutex.Unlock() + + _, err := getChangelogMessage("v1.0.0", "v2.0.0") + if err == nil { + t.Error("Expected error from failed API call, got nil") + } + + if !errors.Is(err, expectedErr) { + t.Errorf("Expected error to be %v, got %v", expectedErr, err) + } + + comparisonCacheMutex.RLock() + cacheLen := len(comparisonCache) + comparisonCacheMutex.RUnlock() + + if cacheLen != 0 { + t.Errorf("Expected cache to remain empty after error, got %d entries", cacheLen) + } +} + +func TestGetChangelogMessage_ConcurrentAccess(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + apiCallCount := 0 + var apiCallMutex sync.Mutex + strPtr := func(s string) *string { return &s } + intPtr := func(i int) *int { return &i } + + mockClient := &MockGitHubClient{ + CompareCommitsFunc: func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + apiCallMutex.Lock() + apiCallCount++ + apiCallMutex.Unlock() + time.Sleep(50 * time.Millisecond) + return &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("abc123"), + HTMLURL: strPtr("https://github.com/commit/abc123"), + Commit: &gogithub.Commit{ + Message: strPtr("test commit"), + Author: &gogithub.CommitAuthor{Name: strPtr("Test Author")}, + }, + Author: &gogithub.User{Login: strPtr("testuser")}, + }, + }, + }, nil + }, + } + GithubClient = mockClient + + comparisonCacheMutex.Lock() + comparisonCache = make(map[string]*CachedComparison) + comparisonCacheMutex.Unlock() + + const numGoroutines = 10 + var wg sync.WaitGroup + wg.Add(numGoroutines) + + messages := make([]string, numGoroutines) + errChan := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(index int) { + defer wg.Done() + msg, err := getChangelogMessage("v1.0.0", "v2.0.0") + if err != nil { + errChan <- err + return + } + messages[index] = msg + }(i) + } + + wg.Wait() + close(errChan) + + for err := range errChan { + t.Errorf("Unexpected error from goroutine: %v", err) + } + + apiCallMutex.Lock() + finalCallCount := apiCallCount + apiCallMutex.Unlock() + + if finalCallCount > 3 { + t.Errorf("Expected at most 3 API calls with concurrent access, got %d", finalCallCount) + } + + firstMessage := messages[0] + for i, msg := range messages { + if msg != firstMessage { + t.Errorf("Message %d differs from first message", i) + } + } +} + +func TestGetChangelogMessage_DifferentComparisons(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + apiCallCount := 0 + strPtr := func(s string) *string { return &s } + intPtr := func(i int) *int { return &i } + + mockClient := &MockGitHubClient{ + CompareCommitsFunc: func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + apiCallCount++ + return &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("abc123"), + HTMLURL: strPtr("https://github.com/commit/abc123"), + Commit: &gogithub.Commit{ + Message: strPtr("test commit"), + Author: &gogithub.CommitAuthor{Name: strPtr("Test Author")}, + }, + Author: &gogithub.User{Login: strPtr("testuser")}, + }, + }, + }, nil + }, + } + GithubClient = mockClient + + comparisonCacheMutex.Lock() + comparisonCache = make(map[string]*CachedComparison) + comparisonCacheMutex.Unlock() + + msg1, _ := getChangelogMessage("v1.0.0", "v2.0.0") + msg2, _ := getChangelogMessage("v2.0.0", "v3.0.0") + + if apiCallCount != 2 { + t.Errorf("Expected 2 API calls for different comparisons, got %d", apiCallCount) + } + + if !strings.Contains(msg1, "v1.0.0") { + t.Error("First message should contain v1.0.0") + } + + if !strings.Contains(msg2, "v2.0.0") && !strings.Contains(msg2, "v3.0.0") { + t.Error("Second message should contain v2.0.0 or v3.0.0") + } + + comparisonCacheMutex.RLock() + cacheLen := len(comparisonCache) + comparisonCacheMutex.RUnlock() + + if cacheLen != 2 { + t.Errorf("Expected 2 cache entries, got %d", cacheLen) + } +} + +func TestHandleChangelog_MissingBaseParameter(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + mockClient := &MockGitHubClient{} + GithubClient = mockClient + + respondCalled := false + var capturedResponse *discordgo.InteractionResponse + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + respondCalled = true + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + capturedResponse = &data + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "head", + Type: discordgo.ApplicationCommandOptionString, + Value: "v2.0.0", + }, + }, + }, + }, + } + + handleChangelog(s, i) + + if !respondCalled { + t.Error("Expected InteractionRespond to be called") + } + + if capturedResponse.Type != discordgo.InteractionResponseChannelMessageWithSource { + t.Errorf("Expected response type ChannelMessageWithSource, got %v", capturedResponse.Type) + } + + if capturedResponse.Data.Content != "Please provide both base and head versions." { + t.Errorf("Expected validation error message, got: %s", capturedResponse.Data.Content) + } + + if capturedResponse.Data.Flags != discordgo.MessageFlagsEphemeral { + t.Error("Expected ephemeral flag to be set") + } +} + +func TestHandleChangelog_MissingHeadParameter(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + mockClient := &MockGitHubClient{} + GithubClient = mockClient + + respondCalled := false + var capturedResponse *discordgo.InteractionResponse + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + respondCalled = true + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + capturedResponse = &data + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "base", + Type: discordgo.ApplicationCommandOptionString, + Value: "v1.0.0", + }, + }, + }, + }, + } + + handleChangelog(s, i) + + if !respondCalled { + t.Error("Expected InteractionRespond to be called") + } + + if capturedResponse.Data.Content != "Please provide both base and head versions." { + t.Errorf("Expected validation error message, got: %s", capturedResponse.Data.Content) + } +} + +func TestHandleChangelog_EmptyParameters(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + mockClient := &MockGitHubClient{} + GithubClient = mockClient + + respondCalled := false + var capturedResponse *discordgo.InteractionResponse + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + respondCalled = true + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + capturedResponse = &data + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "base", + Type: discordgo.ApplicationCommandOptionString, + Value: "", + }, + { + Name: "head", + Type: discordgo.ApplicationCommandOptionString, + Value: "", + }, + }, + }, + }, + } + + handleChangelog(s, i) + + if !respondCalled { + t.Error("Expected InteractionRespond to be called") + } + + if capturedResponse.Data.Content != "Please provide both base and head versions." { + t.Errorf("Expected validation error message, got: %s", capturedResponse.Data.Content) + } +} + +func TestHandleChangelog_SuccessfulComparison(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + strPtr := func(s string) *string { return &s } + intPtr := func(i int) *int { return &i } + + mockClient := &MockGitHubClient{ + CompareCommitsFunc: func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + return &gogithub.CommitsComparison{ + TotalCommits: intPtr(1), + HTMLURL: strPtr("https://github.com/compare"), + Commits: []*gogithub.RepositoryCommit{ + { + SHA: strPtr("abc123"), + HTMLURL: strPtr("https://github.com/commit/abc123"), + Commit: &gogithub.Commit{ + Message: strPtr("test commit"), + Author: &gogithub.CommitAuthor{Name: strPtr("Test Author")}, + }, + Author: &gogithub.User{Login: strPtr("testuser")}, + }, + }, + }, nil + }, + } + GithubClient = mockClient + + comparisonCacheMutex.Lock() + comparisonCache = make(map[string]*CachedComparison) + comparisonCacheMutex.Unlock() + + callSequence := []string{} + deferredResponseSeen := false + editResponseSeen := false + var finalContent string + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/callback") { + callSequence = append(callSequence, "respond") + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + if data.Type == discordgo.InteractionResponseDeferredChannelMessageWithSource { + deferredResponseSeen = true + } + } else if req.Method == "PATCH" { + callSequence = append(callSequence, "edit") + editResponseSeen = true + var edit discordgo.WebhookEdit + if err := json.NewDecoder(req.Body).Decode(&edit); err != nil { + t.Errorf("Failed to decode edit body: %v", err) + } + if edit.Content != nil { + finalContent = *edit.Content + } + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "base", + Type: discordgo.ApplicationCommandOptionString, + Value: "v1.0.0", + }, + { + Name: "head", + Type: discordgo.ApplicationCommandOptionString, + Value: "v2.0.0", + }, + }, + }, + }, + } + + handleChangelog(s, i) + + if !deferredResponseSeen { + t.Error("Expected deferred response to be sent") + } + + if !editResponseSeen { + t.Error("Expected response edit to be called") + } + + if len(callSequence) != 2 || callSequence[0] != "respond" || callSequence[1] != "edit" { + t.Errorf("Expected call sequence [respond, edit], got %v", callSequence) + } + + if !strings.Contains(finalContent, "v1.0.0") || !strings.Contains(finalContent, "v2.0.0") { + t.Errorf("Expected final content to contain version info, got: %s", finalContent) + } + + if !strings.Contains(finalContent, "test commit") { + t.Errorf("Expected final content to contain commit message, got: %s", finalContent) + } +} + +func TestHandleChangelog_GitHubAPIError(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + expectedErr := errors.New("GitHub API error") + mockClient := &MockGitHubClient{ + CompareCommitsFunc: func(owner, repo, base, head string) (*gogithub.CommitsComparison, error) { + return nil, expectedErr + }, + } + GithubClient = mockClient + + comparisonCacheMutex.Lock() + comparisonCache = make(map[string]*CachedComparison) + comparisonCacheMutex.Unlock() + + deferredResponseSeen := false + editResponseSeen := false + var errorContent string + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/callback") { + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + if data.Type == discordgo.InteractionResponseDeferredChannelMessageWithSource { + deferredResponseSeen = true + } + } else if req.Method == "PATCH" { + editResponseSeen = true + var edit discordgo.WebhookEdit + if err := json.NewDecoder(req.Body).Decode(&edit); err != nil { + t.Errorf("Failed to decode edit body: %v", err) + } + if edit.Content != nil { + errorContent = *edit.Content + } + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "base", + Type: discordgo.ApplicationCommandOptionString, + Value: "v1.0.0", + }, + { + Name: "head", + Type: discordgo.ApplicationCommandOptionString, + Value: "v2.0.0", + }, + }, + }, + }, + } + + handleChangelog(s, i) + + if !deferredResponseSeen { + t.Error("Expected deferred response to be sent") + } + + if !editResponseSeen { + t.Error("Expected error response edit to be called") + } + + expectedErrorMsg := "Failed to compare versions: v1.0.0...v2.0.0" + if errorContent != expectedErrorMsg { + t.Errorf("Expected error message %q, got %q", expectedErrorMsg, errorContent) + } +} + +func TestHandleChangelog_NoOptions(t *testing.T) { + originalClient := GithubClient + defer func() { GithubClient = originalClient }() + + mockClient := &MockGitHubClient{} + GithubClient = mockClient + + respondCalled := false + var capturedResponse *discordgo.InteractionResponse + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + respondCalled = true + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + capturedResponse = &data + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{}, + }, + }, + } + + handleChangelog(s, i) + + if !respondCalled { + t.Error("Expected InteractionRespond to be called") + } + + if capturedResponse.Data.Content != "Please provide both base and head versions." { + t.Errorf("Expected validation error message, got: %s", capturedResponse.Data.Content) + } +} diff --git a/internal/discord/handlers/github_handler.go b/internal/discord/handlers/github_handler.go new file mode 100644 index 0000000..f307213 --- /dev/null +++ b/internal/discord/handlers/github_handler.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "fmt" + "log" + + "github.com/bwmarrin/discordgo" +) + +func handleRepo(s *discordgo.Session, i *discordgo.InteractionCreate) { + options := i.ApplicationCommandData().Options + + var repo string + if len(options) > 0 && options[0].Name == "name" { + repo = options[0].StringValue() + } + + // Use default repo if none specified + if repo == "" { + repo = GithubRepo + } + + // Defer response as API call might take time + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + }) + + // Validate repository exists + repository, err := GithubClient.GetRepository(GithubOwner, repo) + if err != nil { + log.Printf("Error getting repository %s/%s: %v", GithubOwner, repo, err) + errorMsg := fmt.Sprintf("Repository `%s/%s` not found in the organization.", GithubOwner, repo) + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &errorMsg, + }) + return + } + + githubURL := repository.GetHTMLURL() + + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &githubURL, + }) +} diff --git a/internal/discord/handlers/github_handler_test.go b/internal/discord/handlers/github_handler_test.go new file mode 100644 index 0000000..d918279 --- /dev/null +++ b/internal/discord/handlers/github_handler_test.go @@ -0,0 +1,367 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/bwmarrin/discordgo" + gogithub "github.com/google/go-github/v57/github" +) + +func TestHandleRepo_DefaultRepository(t *testing.T) { + originalClient := GithubClient + originalRepo := GithubRepo + originalOwner := GithubOwner + defer func() { + GithubClient = originalClient + GithubRepo = originalRepo + GithubOwner = originalOwner + }() + + GithubOwner = "test-owner" + GithubRepo = "default-repo" + + expectedURL := "https://github.com/test-owner/default-repo" + + mockClient := &MockGitHubClient{ + GetRepositoryFunc: func(owner, repo string) (*gogithub.Repository, error) { + if owner != "test-owner" || repo != "default-repo" { + t.Errorf("Expected owner=test-owner and repo=default-repo, got owner=%s, repo=%s", owner, repo) + } + return &gogithub.Repository{ + HTMLURL: gogithub.String(expectedURL), + }, nil + }, + } + GithubClient = mockClient + + deferredResponseSeen := false + editResponseSeen := false + var finalContent string + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/callback") { + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + if data.Type == discordgo.InteractionResponseDeferredChannelMessageWithSource { + deferredResponseSeen = true + } + } else if req.Method == "PATCH" { + editResponseSeen = true + var edit discordgo.WebhookEdit + if err := json.NewDecoder(req.Body).Decode(&edit); err != nil { + t.Errorf("Failed to decode edit body: %v", err) + } + if edit.Content != nil { + finalContent = *edit.Content + } + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{}, + }, + }, + } + + handleRepo(s, i) + + if !deferredResponseSeen { + t.Error("Expected deferred response to be sent") + } + + if !editResponseSeen { + t.Error("Expected response edit to be called") + } + + if finalContent != expectedURL { + t.Errorf("Expected final content to be %q, got %q", expectedURL, finalContent) + } +} + +func TestHandleRepo_SpecificRepository(t *testing.T) { + originalClient := GithubClient + originalOwner := GithubOwner + defer func() { + GithubClient = originalClient + GithubOwner = originalOwner + }() + + GithubOwner = "test-owner" + expectedURL := "https://github.com/test-owner/custom-repo" + + mockClient := &MockGitHubClient{ + GetRepositoryFunc: func(owner, repo string) (*gogithub.Repository, error) { + if owner != "test-owner" || repo != "custom-repo" { + t.Errorf("Expected owner=test-owner and repo=custom-repo, got owner=%s, repo=%s", owner, repo) + } + return &gogithub.Repository{ + HTMLURL: gogithub.String(expectedURL), + }, nil + }, + } + GithubClient = mockClient + + var finalContent string + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + if req.Method == "PATCH" { + var edit discordgo.WebhookEdit + if err := json.NewDecoder(req.Body).Decode(&edit); err != nil { + t.Errorf("Failed to decode edit body: %v", err) + } + if edit.Content != nil { + finalContent = *edit.Content + } + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "name", + Type: discordgo.ApplicationCommandOptionString, + Value: "custom-repo", + }, + }, + }, + }, + } + + handleRepo(s, i) + + if finalContent != expectedURL { + t.Errorf("Expected final content to be %q, got %q", expectedURL, finalContent) + } +} + +func TestHandleRepo_RepositoryNotFound(t *testing.T) { + originalClient := GithubClient + originalOwner := GithubOwner + defer func() { + GithubClient = originalClient + GithubOwner = originalOwner + }() + + GithubOwner = "test-owner" + + expectedErr := errors.New("404 Not Found") + mockClient := &MockGitHubClient{ + GetRepositoryFunc: func(owner, repo string) (*gogithub.Repository, error) { + return nil, expectedErr + }, + } + GithubClient = mockClient + + deferredResponseSeen := false + editResponseSeen := false + var errorContent string + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/callback") { + var data discordgo.InteractionResponse + if err := json.NewDecoder(req.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + if data.Type == discordgo.InteractionResponseDeferredChannelMessageWithSource { + deferredResponseSeen = true + } + } else if req.Method == "PATCH" { + editResponseSeen = true + var edit discordgo.WebhookEdit + if err := json.NewDecoder(req.Body).Decode(&edit); err != nil { + t.Errorf("Failed to decode edit body: %v", err) + } + if edit.Content != nil { + errorContent = *edit.Content + } + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "name", + Type: discordgo.ApplicationCommandOptionString, + Value: "nonexistent-repo", + }, + }, + }, + }, + } + + handleRepo(s, i) + + if !deferredResponseSeen { + t.Error("Expected deferred response to be sent") + } + + if !editResponseSeen { + t.Error("Expected error response edit to be called") + } + + expectedErrorMsg := "Repository `test-owner/nonexistent-repo` not found in the organization." + if errorContent != expectedErrorMsg { + t.Errorf("Expected error message %q, got %q", expectedErrorMsg, errorContent) + } +} + +func TestHandleRepo_EmptyRepositoryName(t *testing.T) { + originalClient := GithubClient + originalRepo := GithubRepo + originalOwner := GithubOwner + defer func() { + GithubClient = originalClient + GithubRepo = originalRepo + GithubOwner = originalOwner + }() + + GithubOwner = "test-owner" + GithubRepo = "default-repo" + + var capturedRepo string + + mockClient := &MockGitHubClient{ + GetRepositoryFunc: func(owner, repo string) (*gogithub.Repository, error) { + capturedRepo = repo + return &gogithub.Repository{ + HTMLURL: gogithub.String("https://github.com/test-owner/default-repo"), + }, nil + }, + } + GithubClient = mockClient + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "name", + Type: discordgo.ApplicationCommandOptionString, + Value: "", + }, + }, + }, + }, + } + + handleRepo(s, i) + + if capturedRepo != "default-repo" { + t.Errorf("Expected default repo to be used when empty string provided, got %q", capturedRepo) + } +} + +func TestHandleRepo_NoOptions(t *testing.T) { + originalClient := GithubClient + originalRepo := GithubRepo + originalOwner := GithubOwner + defer func() { + GithubClient = originalClient + GithubRepo = originalRepo + GithubOwner = originalOwner + }() + + GithubOwner = "test-owner" + GithubRepo = "default-repo" + + var capturedRepo string + + mockClient := &MockGitHubClient{ + GetRepositoryFunc: func(owner, repo string) (*gogithub.Repository, error) { + capturedRepo = repo + return &gogithub.Repository{ + HTMLURL: gogithub.String("https://github.com/test-owner/default-repo"), + }, nil + }, + } + GithubClient = mockClient + + s, _ := discordgo.New("") + s.Client = &http.Client{ + Transport: &MockRoundTripper{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{}")), + Header: make(http.Header), + }, nil + }, + }, + } + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommand, + Data: discordgo.ApplicationCommandInteractionData{ + Options: []*discordgo.ApplicationCommandInteractionDataOption{}, + }, + }, + } + + handleRepo(s, i) + + if capturedRepo != "default-repo" { + t.Errorf("Expected default repo to be used when no options provided, got %q", capturedRepo) + } +} diff --git a/internal/discord/handlers/interaction.go b/internal/discord/handlers/interaction.go index 35a99b0..57b1fff 100644 --- a/internal/discord/handlers/interaction.go +++ b/internal/discord/handlers/interaction.go @@ -8,7 +8,7 @@ import ( ) var ( - GithubClient *github.Client + GithubClient github.Client GithubOwner string GithubRepo string ) @@ -34,10 +34,12 @@ type ModalState struct { var modalStates = make(map[string]*ModalState) var commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "tapsign": handleTapsign, - "feature": handleFeature, - "faq": handleFaq, - "bug": handleBug, + "tapsign": handleTapsign, + "feature": handleFeature, + "faq": handleFaq, + "bug": handleBug, + "changelog": handleChangelog, + "repo": handleRepo, } // HandleInteraction routes interactions to appropriate handlers @@ -60,7 +62,9 @@ func handleTapsign(s *discordgo.Session, i *discordgo.InteractionCreate) { helpText := "**How to get help or make a suggestion:**\n" + "`/bug`: To report a bug with the app.\n" + "`/feature`: To request a new feature. \n" + - "`/faq`: Frequently Asked Questions.\n" + "`/faq`: Frequently Asked Questions.\n" + + "`/changelog`: View changes between two versions.\n" + + "`/repo`: Get the GitHub URL for a repository.\n" s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, @@ -77,5 +81,7 @@ func handleAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) { switch data.Name { case "faq": handleFaqAutocomplete(s, i) + case "changelog": + handleChangelogAutocomplete(s, i) } } diff --git a/internal/github/client.go b/internal/github/client.go index 435e5d8..d8df9fb 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -4,15 +4,36 @@ import ( "context" "fmt" "log" + "sync" + "time" "github.com/google/go-github/v57/github" "golang.org/x/oauth2" ) -type Client struct { - token string - client *github.Client - ctx context.Context +const ( + // RepositoryCacheTTL defines how long repository metadata is cached + RepositoryCacheTTL = 4 * time.Hour +) + +type Client interface { + GetReleases(owner, repo string, limit int) ([]*github.RepositoryRelease, error) + CompareCommits(owner, repo, base, head string) (*github.CommitsComparison, error) + CreateIssue(owner, repo, title, body string, labels []string) (*IssueResponse, error) + GetRepository(owner, repo string) (*github.Repository, error) +} + +type CachedRepository struct { + Repository *github.Repository + Timestamp time.Time +} + +type LiveGitHubClient struct { + token string + client *github.Client + ctx context.Context + repoCache map[string]*CachedRepository + cacheMux sync.RWMutex } type IssueRequest struct { @@ -27,20 +48,43 @@ type IssueResponse struct { ID int64 `json:"id"` } -func NewClient(token string) *Client { +func NewClient(token string) Client { ctx := context.Background() ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) tc := oauth2.NewClient(ctx, ts) - return &Client{ - token: token, - client: github.NewClient(tc), - ctx: ctx, + return &LiveGitHubClient{ + token: token, + client: github.NewClient(tc), + ctx: ctx, + repoCache: make(map[string]*CachedRepository), + } +} + +func (c *LiveGitHubClient) GetReleases(owner, repo string, limit int) ([]*github.RepositoryRelease, error) { + opts := &github.ListOptions{ + PerPage: limit, + } + releases, _, err := c.client.Repositories.ListReleases(c.ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list releases: %w", err) + } + return releases, nil +} + +func (c *LiveGitHubClient) CompareCommits(owner, repo, base, head string) (*github.CommitsComparison, error) { + comparison, resp, err := c.client.Repositories.CompareCommits(c.ctx, owner, repo, base, head, nil) + if err != nil { + if resp != nil { + return nil, fmt.Errorf("github API returned %d: failed to compare commits: %w", resp.StatusCode, err) + } + return nil, fmt.Errorf("failed to compare commits: %w", err) } + return comparison, nil } -func (c *Client) CreateIssue(owner, repo, title, body string, labels []string) (*IssueResponse, error) { +func (c *LiveGitHubClient) CreateIssue(owner, repo, title, body string, labels []string) (*IssueResponse, error) { log.Printf("[GitHub API] Creating issue in %s/%s", owner, repo) log.Printf("[GitHub API] Title: %s", title) log.Printf("[GitHub API] Labels: %v", labels) @@ -70,6 +114,45 @@ func (c *Client) CreateIssue(owner, repo, title, body string, labels []string) ( }, nil } +func (c *LiveGitHubClient) GetRepository(owner, repo string) (*github.Repository, error) { + cacheKey := fmt.Sprintf("%s/%s", owner, repo) + + // First check with read lock + c.cacheMux.RLock() + if cached, exists := c.repoCache[cacheKey]; exists { + if time.Since(cached.Timestamp) < RepositoryCacheTTL { + c.cacheMux.RUnlock() + return cached.Repository, nil + } + } + c.cacheMux.RUnlock() + + // Cache miss or expired - acquire write lock + c.cacheMux.Lock() + defer c.cacheMux.Unlock() + + // Double-check after acquiring write lock + if cached, exists := c.repoCache[cacheKey]; exists { + if time.Since(cached.Timestamp) < RepositoryCacheTTL { + return cached.Repository, nil + } + } + + // Fetch from GitHub API + repository, _, err := c.client.Repositories.Get(c.ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("failed to get repository: %w", err) + } + + // Store in cache with timestamp + c.repoCache[cacheKey] = &CachedRepository{ + Repository: repository, + Timestamp: time.Now(), + } + + return repository, nil +} + func FormatIssueBody(username, userID, description string) string { return fmt.Sprintf(`**Reported by:** %s (ID: %s) @@ -77,4 +160,4 @@ func FormatIssueBody(username, userID, description string) string { --- *This issue was automatically created from Discord*`, username, userID, description) -} +} \ No newline at end of file