Skip to content
32 changes: 32 additions & 0 deletions internal/discord/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
}
}
2 changes: 1 addition & 1 deletion internal/discord/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

var (
githubClient *github.Client
githubClient github.Client
)

// ModalState tracks the state of multi-part modals
Expand Down
239 changes: 239 additions & 0 deletions internal/discord/handlers/changelog_handler.go
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider adding a comment explaining the cache invalidation strategy. The double-checked locking pattern is correctly implemented, but it would be helpful to document why a 5-minute cache duration was chosen and what tradeoffs it represents (freshness vs. API rate limits).

Suggested change
lastCacheUpdate time.Time
lastCacheUpdate time.Time
// cacheDuration determines how long release data is cached before refreshing.
// A 5-minute duration balances data freshness with API rate limits: frequent updates
// could exceed GitHub's rate limits, while longer durations may serve stale data.

Copilot uses AI. Check for mistakes.
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"
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential panic if commit.GetSHA() returns an empty string or a string with fewer than 7 characters. Add a length check before slicing:

sha := commit.GetSHA()
shortSHA := sha
if len(sha) > 7 {
    shortSHA = sha[:7]
}

Copilot uses AI. Check for mistakes.
}
}

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)
}
Comment on lines +174 to +178
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The autocomplete handler handleChangelogAutocomplete lacks test coverage. Consider adding tests to verify: 1) filtering releases based on user input, 2) limiting results to 25 choices, and 3) handling empty or failed cache updates.

Copilot uses AI. Check for mistakes.

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
}
Comment on lines +214 to +228
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache update logic with double-checked locking lacks test coverage. Consider adding tests to verify: 1) concurrent access doesn't cause race conditions, 2) cache expiration works correctly, and 3) the double-check mechanism prevents redundant API calls.

Copilot uses AI. Check for mistakes.

// Fetch releases
releases, err := GithubClient.GetReleases(GithubOwner, GithubRepo, 100)
if err != nil {
return err
}

releaseCache = releases
lastCacheUpdate = time.Now()
return nil
}
Comment on lines +214 to +239
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for updateReleaseCache function. This function implements double-checked locking pattern for cache management, which is complex and error-prone. The concurrency behavior, cache expiration, and error handling should be tested to ensure correctness. Consider adding tests for:

  • Cache expiration after duration
  • Concurrent access patterns
  • Error handling when GitHub API fails
  • Cache initialization from empty state

Copilot uses AI. Check for mistakes.
Loading