The workspace map previously only cleaned up during Capture() calls, meaning stale entries would accumulate indefinitely if no new captures occurred. This adds: - Background sweeper goroutine (Start/Stop lifecycle) that runs every 5 minutes to evict expired workspaces - Configurable MaxWorkspaces and WorkspaceTTLMinutes in Config (defaults: 100 entries, 24h TTL) replacing hardcoded constants - cleanup() now returns eviction count for observability logging - Nil-config fallback to safe defaults Fixes #54 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
546 lines
14 KiB
Go
546 lines
14 KiB
Go
// Package bugseti provides services for the BugSETI distributed bug fixing application.
|
|
package bugseti
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ConfigService manages application configuration and persistence.
|
|
type ConfigService struct {
|
|
config *Config
|
|
path string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// Config holds all BugSETI configuration.
|
|
type Config struct {
|
|
// Authentication
|
|
GitHubToken string `json:"githubToken,omitempty"`
|
|
|
|
// Repositories
|
|
WatchedRepos []string `json:"watchedRepos"`
|
|
Labels []string `json:"labels"`
|
|
|
|
// Scheduling
|
|
WorkHours *WorkHours `json:"workHours,omitempty"`
|
|
FetchInterval int `json:"fetchIntervalMinutes"`
|
|
|
|
// Notifications
|
|
NotificationsEnabled bool `json:"notificationsEnabled"`
|
|
NotificationSound bool `json:"notificationSound"`
|
|
|
|
// Workspace
|
|
WorkspaceDir string `json:"workspaceDir,omitempty"`
|
|
DataDir string `json:"dataDir,omitempty"`
|
|
// Marketplace MCP
|
|
MarketplaceMCPRoot string `json:"marketplaceMcpRoot,omitempty"`
|
|
|
|
// Onboarding
|
|
Onboarded bool `json:"onboarded"`
|
|
OnboardedAt time.Time `json:"onboardedAt,omitempty"`
|
|
|
|
// UI Preferences
|
|
Theme string `json:"theme"`
|
|
ShowTrayPanel bool `json:"showTrayPanel"`
|
|
|
|
// Advanced
|
|
MaxConcurrentIssues int `json:"maxConcurrentIssues"`
|
|
AutoSeedContext bool `json:"autoSeedContext"`
|
|
|
|
// Workspace cache
|
|
MaxWorkspaces int `json:"maxWorkspaces"` // Upper bound on cached workspace entries (0 = default 100)
|
|
WorkspaceTTLMinutes int `json:"workspaceTtlMinutes"` // TTL for workspace entries in minutes (0 = default 1440 = 24h)
|
|
|
|
// Updates
|
|
UpdateChannel string `json:"updateChannel"` // stable, beta, nightly
|
|
AutoUpdate bool `json:"autoUpdate"` // Automatically install updates
|
|
UpdateCheckInterval int `json:"updateCheckInterval"` // Check interval in hours (0 = disabled)
|
|
LastUpdateCheck time.Time `json:"lastUpdateCheck,omitempty"`
|
|
}
|
|
|
|
// WorkHours defines when BugSETI should actively fetch issues.
|
|
type WorkHours struct {
|
|
Enabled bool `json:"enabled"`
|
|
StartHour int `json:"startHour"` // 0-23
|
|
EndHour int `json:"endHour"` // 0-23
|
|
Days []int `json:"days"` // 0=Sunday, 6=Saturday
|
|
Timezone string `json:"timezone"`
|
|
}
|
|
|
|
// NewConfigService creates a new ConfigService with default values.
|
|
func NewConfigService() *ConfigService {
|
|
// Determine config path
|
|
configDir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
|
}
|
|
|
|
bugsetiDir := filepath.Join(configDir, "bugseti")
|
|
if err := os.MkdirAll(bugsetiDir, 0755); err != nil {
|
|
log.Printf("Warning: could not create config directory: %v", err)
|
|
}
|
|
|
|
return &ConfigService{
|
|
path: filepath.Join(bugsetiDir, "config.json"),
|
|
config: &Config{
|
|
WatchedRepos: []string{},
|
|
Labels: []string{
|
|
"good first issue",
|
|
"help wanted",
|
|
"beginner-friendly",
|
|
},
|
|
FetchInterval: 15,
|
|
NotificationsEnabled: true,
|
|
NotificationSound: true,
|
|
Theme: "dark",
|
|
ShowTrayPanel: true,
|
|
MaxConcurrentIssues: 1,
|
|
AutoSeedContext: true,
|
|
DataDir: bugsetiDir,
|
|
MarketplaceMCPRoot: "",
|
|
MaxWorkspaces: 100,
|
|
WorkspaceTTLMinutes: 1440, // 24 hours
|
|
UpdateChannel: "stable",
|
|
AutoUpdate: false,
|
|
UpdateCheckInterval: 6, // Check every 6 hours
|
|
},
|
|
}
|
|
}
|
|
|
|
// ServiceName returns the service name for Wails.
|
|
func (c *ConfigService) ServiceName() string {
|
|
return "ConfigService"
|
|
}
|
|
|
|
// Load reads the configuration from disk.
|
|
func (c *ConfigService) Load() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
data, err := os.ReadFile(c.path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
// No config file yet, use defaults
|
|
return c.saveUnsafe()
|
|
}
|
|
return err
|
|
}
|
|
|
|
var config Config
|
|
if err := json.Unmarshal(data, &config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Merge with defaults for any new fields
|
|
c.mergeDefaults(&config)
|
|
c.config = &config
|
|
return nil
|
|
}
|
|
|
|
// Save persists the configuration to disk.
|
|
func (c *ConfigService) Save() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// saveUnsafe writes config without acquiring lock.
|
|
func (c *ConfigService) saveUnsafe() error {
|
|
data, err := json.MarshalIndent(c.config, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(c.path, data, 0600)
|
|
}
|
|
|
|
// mergeDefaults fills in default values for any unset fields.
|
|
func (c *ConfigService) mergeDefaults(config *Config) {
|
|
if config.Labels == nil || len(config.Labels) == 0 {
|
|
config.Labels = c.config.Labels
|
|
}
|
|
if config.FetchInterval == 0 {
|
|
config.FetchInterval = 15
|
|
}
|
|
if config.Theme == "" {
|
|
config.Theme = "dark"
|
|
}
|
|
if config.MaxConcurrentIssues == 0 {
|
|
config.MaxConcurrentIssues = 1
|
|
}
|
|
if config.DataDir == "" {
|
|
config.DataDir = c.config.DataDir
|
|
}
|
|
if config.MaxWorkspaces == 0 {
|
|
config.MaxWorkspaces = 100
|
|
}
|
|
if config.WorkspaceTTLMinutes == 0 {
|
|
config.WorkspaceTTLMinutes = 1440
|
|
}
|
|
if config.UpdateChannel == "" {
|
|
config.UpdateChannel = "stable"
|
|
}
|
|
if config.UpdateCheckInterval == 0 {
|
|
config.UpdateCheckInterval = 6
|
|
}
|
|
}
|
|
|
|
// GetConfig returns a copy of the current configuration.
|
|
func (c *ConfigService) GetConfig() Config {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return *c.config
|
|
}
|
|
|
|
// GetMarketplaceMCPRoot returns the configured marketplace MCP root path.
|
|
func (c *ConfigService) GetMarketplaceMCPRoot() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.MarketplaceMCPRoot
|
|
}
|
|
|
|
// SetConfig updates the configuration and saves it.
|
|
func (c *ConfigService) SetConfig(config Config) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config = &config
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetWatchedRepos returns the list of watched repositories.
|
|
func (c *ConfigService) GetWatchedRepos() []string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.WatchedRepos
|
|
}
|
|
|
|
// AddWatchedRepo adds a repository to the watch list.
|
|
func (c *ConfigService) AddWatchedRepo(repo string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
for _, r := range c.config.WatchedRepos {
|
|
if r == repo {
|
|
return nil // Already watching
|
|
}
|
|
}
|
|
|
|
c.config.WatchedRepos = append(c.config.WatchedRepos, repo)
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// RemoveWatchedRepo removes a repository from the watch list.
|
|
func (c *ConfigService) RemoveWatchedRepo(repo string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
for i, r := range c.config.WatchedRepos {
|
|
if r == repo {
|
|
c.config.WatchedRepos = append(c.config.WatchedRepos[:i], c.config.WatchedRepos[i+1:]...)
|
|
return c.saveUnsafe()
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetLabels returns the issue labels to filter by.
|
|
func (c *ConfigService) GetLabels() []string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.Labels
|
|
}
|
|
|
|
// SetLabels updates the issue labels.
|
|
func (c *ConfigService) SetLabels(labels []string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.Labels = labels
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetFetchInterval returns the fetch interval as a duration.
|
|
func (c *ConfigService) GetFetchInterval() time.Duration {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return time.Duration(c.config.FetchInterval) * time.Minute
|
|
}
|
|
|
|
// SetFetchInterval sets the fetch interval in minutes.
|
|
func (c *ConfigService) SetFetchInterval(minutes int) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.FetchInterval = minutes
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// IsWithinWorkHours checks if the current time is within configured work hours.
|
|
func (c *ConfigService) IsWithinWorkHours() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
if c.config.WorkHours == nil || !c.config.WorkHours.Enabled {
|
|
return true // No work hours restriction
|
|
}
|
|
|
|
wh := c.config.WorkHours
|
|
now := time.Now()
|
|
|
|
// Check timezone
|
|
if wh.Timezone != "" {
|
|
loc, err := time.LoadLocation(wh.Timezone)
|
|
if err == nil {
|
|
now = now.In(loc)
|
|
}
|
|
}
|
|
|
|
// Check day
|
|
day := int(now.Weekday())
|
|
dayAllowed := false
|
|
for _, d := range wh.Days {
|
|
if d == day {
|
|
dayAllowed = true
|
|
break
|
|
}
|
|
}
|
|
if !dayAllowed {
|
|
return false
|
|
}
|
|
|
|
// Check hour
|
|
hour := now.Hour()
|
|
if wh.StartHour <= wh.EndHour {
|
|
return hour >= wh.StartHour && hour < wh.EndHour
|
|
}
|
|
// Handle overnight (e.g., 22:00 - 06:00)
|
|
return hour >= wh.StartHour || hour < wh.EndHour
|
|
}
|
|
|
|
// GetWorkHours returns the work hours configuration.
|
|
func (c *ConfigService) GetWorkHours() *WorkHours {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.WorkHours
|
|
}
|
|
|
|
// SetWorkHours updates the work hours configuration.
|
|
func (c *ConfigService) SetWorkHours(wh *WorkHours) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.WorkHours = wh
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// IsNotificationsEnabled returns whether notifications are enabled.
|
|
func (c *ConfigService) IsNotificationsEnabled() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.NotificationsEnabled
|
|
}
|
|
|
|
// SetNotificationsEnabled enables or disables notifications.
|
|
func (c *ConfigService) SetNotificationsEnabled(enabled bool) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.NotificationsEnabled = enabled
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetWorkspaceDir returns the workspace directory.
|
|
func (c *ConfigService) GetWorkspaceDir() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.WorkspaceDir
|
|
}
|
|
|
|
// SetWorkspaceDir sets the workspace directory.
|
|
func (c *ConfigService) SetWorkspaceDir(dir string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.WorkspaceDir = dir
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetDataDir returns the data directory.
|
|
func (c *ConfigService) GetDataDir() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.DataDir
|
|
}
|
|
|
|
// IsOnboarded returns whether the user has completed onboarding.
|
|
func (c *ConfigService) IsOnboarded() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.Onboarded
|
|
}
|
|
|
|
// CompleteOnboarding marks onboarding as complete.
|
|
func (c *ConfigService) CompleteOnboarding() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.Onboarded = true
|
|
c.config.OnboardedAt = time.Now()
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetTheme returns the current theme.
|
|
func (c *ConfigService) GetTheme() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.Theme
|
|
}
|
|
|
|
// SetTheme sets the theme.
|
|
func (c *ConfigService) SetTheme(theme string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.Theme = theme
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// IsAutoSeedEnabled returns whether automatic context seeding is enabled.
|
|
func (c *ConfigService) IsAutoSeedEnabled() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.AutoSeedContext
|
|
}
|
|
|
|
// SetAutoSeedEnabled enables or disables automatic context seeding.
|
|
func (c *ConfigService) SetAutoSeedEnabled(enabled bool) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.AutoSeedContext = enabled
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetMaxWorkspaces returns the maximum number of cached workspaces.
|
|
func (c *ConfigService) GetMaxWorkspaces() int {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.config.MaxWorkspaces <= 0 {
|
|
return 100
|
|
}
|
|
return c.config.MaxWorkspaces
|
|
}
|
|
|
|
// GetWorkspaceTTL returns the workspace TTL as a time.Duration.
|
|
func (c *ConfigService) GetWorkspaceTTL() time.Duration {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.config.WorkspaceTTLMinutes <= 0 {
|
|
return 24 * time.Hour
|
|
}
|
|
return time.Duration(c.config.WorkspaceTTLMinutes) * time.Minute
|
|
}
|
|
|
|
// UpdateSettings holds update-related configuration.
|
|
type UpdateSettings struct {
|
|
Channel string `json:"channel"`
|
|
AutoUpdate bool `json:"autoUpdate"`
|
|
CheckInterval int `json:"checkInterval"` // Hours
|
|
LastCheck time.Time `json:"lastCheck"`
|
|
}
|
|
|
|
// GetUpdateSettings returns the update settings.
|
|
func (c *ConfigService) GetUpdateSettings() UpdateSettings {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return UpdateSettings{
|
|
Channel: c.config.UpdateChannel,
|
|
AutoUpdate: c.config.AutoUpdate,
|
|
CheckInterval: c.config.UpdateCheckInterval,
|
|
LastCheck: c.config.LastUpdateCheck,
|
|
}
|
|
}
|
|
|
|
// SetUpdateSettings updates the update settings.
|
|
func (c *ConfigService) SetUpdateSettings(settings UpdateSettings) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.UpdateChannel = settings.Channel
|
|
c.config.AutoUpdate = settings.AutoUpdate
|
|
c.config.UpdateCheckInterval = settings.CheckInterval
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetUpdateChannel returns the update channel.
|
|
func (c *ConfigService) GetUpdateChannel() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.UpdateChannel
|
|
}
|
|
|
|
// SetUpdateChannel sets the update channel.
|
|
func (c *ConfigService) SetUpdateChannel(channel string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.UpdateChannel = channel
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// IsAutoUpdateEnabled returns whether automatic updates are enabled.
|
|
func (c *ConfigService) IsAutoUpdateEnabled() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.AutoUpdate
|
|
}
|
|
|
|
// SetAutoUpdateEnabled enables or disables automatic updates.
|
|
func (c *ConfigService) SetAutoUpdateEnabled(enabled bool) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.AutoUpdate = enabled
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetUpdateCheckInterval returns the update check interval in hours.
|
|
func (c *ConfigService) GetUpdateCheckInterval() int {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.UpdateCheckInterval
|
|
}
|
|
|
|
// SetUpdateCheckInterval sets the update check interval in hours.
|
|
func (c *ConfigService) SetUpdateCheckInterval(hours int) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.UpdateCheckInterval = hours
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// GetLastUpdateCheck returns the last update check time.
|
|
func (c *ConfigService) GetLastUpdateCheck() time.Time {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.config.LastUpdateCheck
|
|
}
|
|
|
|
// SetLastUpdateCheck sets the last update check time.
|
|
func (c *ConfigService) SetLastUpdateCheck(t time.Time) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.config.LastUpdateCheck = t
|
|
return c.saveUnsafe()
|
|
}
|
|
|
|
// ShouldCheckForUpdates returns true if it's time to check for updates.
|
|
func (c *ConfigService) ShouldCheckForUpdates() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
if c.config.UpdateCheckInterval <= 0 {
|
|
return false // Updates disabled
|
|
}
|
|
|
|
if c.config.LastUpdateCheck.IsZero() {
|
|
return true // Never checked
|
|
}
|
|
|
|
interval := time.Duration(c.config.UpdateCheckInterval) * time.Hour
|
|
return time.Since(c.config.LastUpdateCheck) >= interval
|
|
}
|