cli/internal/bugseti/config.go
Snider 69a0cd631a feat(bugseti): migrate from GitHub gh CLI to Forgejo SDK
Replace all exec.Command("gh", ...) calls with the existing pkg/forge
wrapper around the Forgejo Go SDK. BugSETI no longer requires the gh
CLI to be installed.

Changes:
- fetcher: use forge.ListIssues/GetIssue instead of gh issue list/view
- submit: use forge.ForkRepo/CreatePullRequest instead of gh pr create
- seeder: use git clone with forge URL + token auth instead of gh clone
- ghcheck: CheckForge() returns *forge.Client via forge.NewFromConfig()
- config: add ForgeURL/ForgeToken fields (GitHubToken kept for migration)
- pkg/forge: add Token(), GetCurrentUser(), ForkRepo(), CreatePullRequest(),
  ListIssueComments(), and label filtering to ListIssuesOpts

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00

564 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 — Forgejo API (resolved via pkg/forge config if empty)
ForgeURL string `json:"forgeUrl,omitempty"`
ForgeToken string `json:"forgeToken,omitempty"`
// Deprecated: use ForgeToken. Kept for migration.
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()
}
// GetForgeURL returns the configured Forge URL (may be empty to use pkg/forge defaults).
func (c *ConfigService) GetForgeURL() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.ForgeURL
}
// GetForgeToken returns the configured Forge token (may be empty to use pkg/forge defaults).
func (c *ConfigService) GetForgeToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.ForgeToken
}
// 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
}