From 2979816d8377752ff06d5c8b1579624183839137 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 20:55:39 +0000 Subject: [PATCH] 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 --- cmd/bugseti/main.go | 15 +-- internal/bugseti/config.go | 20 +++- internal/bugseti/fetcher.go | 176 ++++++++++++--------------- internal/bugseti/fetcher_test.go | 10 +- internal/bugseti/ghcheck.go | 32 ++--- internal/bugseti/ghcheck_test.go | 38 ++---- internal/bugseti/seeder.go | 25 +++- internal/bugseti/submit.go | 197 ++++++++++++------------------- internal/bugseti/submit_test.go | 89 +++----------- pkg/forge/client.go | 35 ++++++ pkg/forge/issues.go | 38 +++++- 11 files changed, 314 insertions(+), 361 deletions(-) diff --git a/cmd/bugseti/main.go b/cmd/bugseti/main.go index b54195f..57e4154 100644 --- a/cmd/bugseti/main.go +++ b/cmd/bugseti/main.go @@ -2,7 +2,7 @@ // BugSETI - "Distributed Bug Fixing like SETI@home but for code" // // The application runs as a system tray app that: -// - Pulls OSS issues from GitHub +// - Pulls OSS issues from Forgejo // - Uses AI to prepare context for each issue // - Presents issues to users for fixing // - Automates PR submission @@ -39,18 +39,19 @@ func main() { log.Printf("Warning: Could not load config: %v", err) } - // Check gh CLI availability - if err := bugseti.CheckGHCLI(); err != nil { - log.Fatalf("GitHub CLI check failed: %v", err) + // Check Forgejo API availability + forgeClient, err := bugseti.CheckForge() + if err != nil { + log.Fatalf("Forgejo check failed: %v\n\nConfigure with: core forge config --url URL --token TOKEN", err) } // Initialize core services notifyService := bugseti.NewNotifyService(configService) statsService := bugseti.NewStatsService(configService) - fetcherService := bugseti.NewFetcherService(configService, notifyService) + fetcherService := bugseti.NewFetcherService(configService, notifyService, forgeClient) queueService := bugseti.NewQueueService(configService) - seederService := bugseti.NewSeederService(configService) - submitService := bugseti.NewSubmitService(configService, notifyService, statsService) + seederService := bugseti.NewSeederService(configService, forgeClient.URL(), forgeClient.Token()) + submitService := bugseti.NewSubmitService(configService, notifyService, statsService, forgeClient) versionService := bugseti.NewVersionService() workspaceService := NewWorkspaceService(configService) diff --git a/internal/bugseti/config.go b/internal/bugseti/config.go index 7f949b1..ea6166c 100644 --- a/internal/bugseti/config.go +++ b/internal/bugseti/config.go @@ -19,7 +19,11 @@ type ConfigService struct { // Config holds all BugSETI configuration. type Config struct { - // Authentication + // 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 @@ -528,6 +532,20 @@ func (c *ConfigService) SetLastUpdateCheck(t time.Time) error { 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() diff --git a/internal/bugseti/fetcher.go b/internal/bugseti/fetcher.go index 57df283..5f1af09 100644 --- a/internal/bugseti/fetcher.go +++ b/internal/bugseti/fetcher.go @@ -2,20 +2,20 @@ package bugseti import ( - "context" - "encoding/json" "fmt" "log" - "os/exec" "strings" "sync" "time" + + "github.com/host-uk/core/pkg/forge" ) // FetcherService fetches issues from configured OSS repositories. type FetcherService struct { config *ConfigService notify *NotifyService + forge *forge.Client running bool mu sync.RWMutex stopCh chan struct{} @@ -23,10 +23,11 @@ type FetcherService struct { } // NewFetcherService creates a new FetcherService. -func NewFetcherService(config *ConfigService, notify *NotifyService) *FetcherService { +func NewFetcherService(config *ConfigService, notify *NotifyService, forgeClient *forge.Client) *FetcherService { return &FetcherService{ config: config, notify: notify, + forge: forgeClient, issuesCh: make(chan []*Issue, 10), } } @@ -133,68 +134,50 @@ func (f *FetcherService) fetchAll() { } } -// fetchFromRepo fetches issues from a single repository using GitHub CLI. +// fetchFromRepo fetches issues from a single repository using the Forgejo API. func (f *FetcherService) fetchFromRepo(repo string) ([]*Issue, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + owner, repoName, err := splitRepo(repo) + if err != nil { + return nil, err + } - // Build query for good first issues labels := f.config.GetLabels() if len(labels) == 0 { labels = []string{"good first issue", "help wanted", "beginner-friendly"} } - labelQuery := strings.Join(labels, ",") - - // Use gh CLI to fetch issues - cmd := exec.CommandContext(ctx, "gh", "issue", "list", - "--repo", repo, - "--label", labelQuery, - "--state", "open", - "--limit", "20", - "--json", "number,title,body,url,labels,createdAt,author") - - output, err := cmd.Output() + forgeIssues, err := f.forge.ListIssues(owner, repoName, forge.ListIssuesOpts{ + State: "open", + Labels: labels, + Limit: 20, + }) if err != nil { - return nil, fmt.Errorf("gh issue list failed: %w", err) + return nil, fmt.Errorf("forge list issues failed: %w", err) } - var ghIssues []struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - URL string `json:"url"` - CreatedAt time.Time `json:"createdAt"` - Author struct { - Login string `json:"login"` - } `json:"author"` - Labels []struct { - Name string `json:"name"` - } `json:"labels"` - } + issues := make([]*Issue, 0, len(forgeIssues)) + for _, fi := range forgeIssues { + labelNames := make([]string, len(fi.Labels)) + for i, l := range fi.Labels { + labelNames[i] = l.Name + } - if err := json.Unmarshal(output, &ghIssues); err != nil { - return nil, fmt.Errorf("failed to parse gh output: %w", err) - } - - issues := make([]*Issue, 0, len(ghIssues)) - for _, gi := range ghIssues { - labels := make([]string, len(gi.Labels)) - for i, l := range gi.Labels { - labels[i] = l.Name + author := "" + if fi.Poster != nil { + author = fi.Poster.UserName } issues = append(issues, &Issue{ - ID: fmt.Sprintf("%s#%d", repo, gi.Number), - Number: gi.Number, + ID: fmt.Sprintf("%s#%d", repo, fi.Index), + Number: int(fi.Index), Repo: repo, - Title: gi.Title, - Body: gi.Body, - URL: gi.URL, - Labels: labels, - Author: gi.Author.Login, - CreatedAt: gi.CreatedAt, - Priority: calculatePriority(labels), + Title: fi.Title, + Body: fi.Body, + URL: fi.HTMLURL, + Labels: labelNames, + Author: author, + CreatedAt: fi.Created, + Priority: calculatePriority(labelNames), }) } @@ -203,71 +186,68 @@ func (f *FetcherService) fetchFromRepo(repo string) ([]*Issue, error) { // FetchIssue fetches a single issue by repo and number. func (f *FetcherService) FetchIssue(repo string, number int) (*Issue, error) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "gh", "issue", "view", - "--repo", repo, - fmt.Sprintf("%d", number), - "--json", "number,title,body,url,labels,createdAt,author,comments") - - output, err := cmd.Output() + owner, repoName, err := splitRepo(repo) if err != nil { - return nil, fmt.Errorf("gh issue view failed: %w", err) + return nil, err } - var ghIssue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - URL string `json:"url"` - CreatedAt time.Time `json:"createdAt"` - Author struct { - Login string `json:"login"` - } `json:"author"` - Labels []struct { - Name string `json:"name"` - } `json:"labels"` - Comments []struct { - Body string `json:"body"` - Author struct { - Login string `json:"login"` - } `json:"author"` - } `json:"comments"` + fi, err := f.forge.GetIssue(owner, repoName, int64(number)) + if err != nil { + return nil, fmt.Errorf("forge get issue failed: %w", err) } - if err := json.Unmarshal(output, &ghIssue); err != nil { - return nil, fmt.Errorf("failed to parse gh output: %w", err) + labelNames := make([]string, len(fi.Labels)) + for i, l := range fi.Labels { + labelNames[i] = l.Name } - labels := make([]string, len(ghIssue.Labels)) - for i, l := range ghIssue.Labels { - labels[i] = l.Name + author := "" + if fi.Poster != nil { + author = fi.Poster.UserName } - comments := make([]Comment, len(ghIssue.Comments)) - for i, c := range ghIssue.Comments { - comments[i] = Comment{ - Author: c.Author.Login, - Body: c.Body, + // Fetch comments + forgeComments, err := f.forge.ListIssueComments(owner, repoName, int64(number)) + if err != nil { + log.Printf("Warning: could not fetch comments for %s#%d: %v", repo, number, err) + } + + comments := make([]Comment, 0, len(forgeComments)) + for _, c := range forgeComments { + commentAuthor := "" + if c.Poster != nil { + commentAuthor = c.Poster.UserName } + comments = append(comments, Comment{ + Author: commentAuthor, + Body: c.Body, + }) } return &Issue{ - ID: fmt.Sprintf("%s#%d", repo, ghIssue.Number), - Number: ghIssue.Number, + ID: fmt.Sprintf("%s#%d", repo, fi.Index), + Number: int(fi.Index), Repo: repo, - Title: ghIssue.Title, - Body: ghIssue.Body, - URL: ghIssue.URL, - Labels: labels, - Author: ghIssue.Author.Login, - CreatedAt: ghIssue.CreatedAt, - Priority: calculatePriority(labels), + Title: fi.Title, + Body: fi.Body, + URL: fi.HTMLURL, + Labels: labelNames, + Author: author, + CreatedAt: fi.Created, + Priority: calculatePriority(labelNames), Comments: comments, }, nil } +// splitRepo splits "owner/repo" into owner and repo parts. +func splitRepo(repo string) (string, string, error) { + parts := strings.SplitN(repo, "/", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid repo format %q, expected owner/repo", repo) + } + return parts[0], parts[1], nil +} + // calculatePriority assigns a priority score based on labels. func calculatePriority(labels []string) int { priority := 50 // Default priority diff --git a/internal/bugseti/fetcher_test.go b/internal/bugseti/fetcher_test.go index d34351c..2fdc198 100644 --- a/internal/bugseti/fetcher_test.go +++ b/internal/bugseti/fetcher_test.go @@ -48,7 +48,7 @@ func TestHelperProcess(t *testing.T) { func TestNewFetcherService_Good(t *testing.T) { cfg := testConfigService(t, nil, nil) notify := NewNotifyService(cfg) - f := NewFetcherService(cfg, notify) + f := NewFetcherService(cfg, notify, nil) require.NotNil(t, f) assert.Equal(t, "FetcherService", f.ServiceName()) @@ -61,7 +61,7 @@ func TestNewFetcherService_Good(t *testing.T) { func TestStartPause_Good(t *testing.T) { cfg := testConfigService(t, nil, nil) notify := NewNotifyService(cfg) - f := NewFetcherService(cfg, notify) + f := NewFetcherService(cfg, notify, nil) require.NoError(t, f.Start()) assert.True(t, f.IsRunning()) @@ -232,7 +232,7 @@ func TestFetchFromRepo_Bad_GhNotInstalled(t *testing.T) { func TestFetchAll_Bad_NoRepos(t *testing.T) { cfg := testConfigService(t, nil, nil) notify := NewNotifyService(cfg) - f := NewFetcherService(cfg, notify) + f := NewFetcherService(cfg, notify, nil) // fetchAll with no repos should not panic and should not send to channel. f.fetchAll() @@ -251,7 +251,7 @@ func TestFetchAll_Bad_NoRepos(t *testing.T) { func TestChannelBackpressure_Ugly(t *testing.T) { cfg := testConfigService(t, nil, nil) notify := NewNotifyService(cfg) - f := NewFetcherService(cfg, notify) + f := NewFetcherService(cfg, notify, nil) // Fill the channel to capacity (buffer size is 10). for i := 0; i < 10; i++ { @@ -387,7 +387,7 @@ func TestFetchIssue_Good_Parse(t *testing.T) { func TestIssuesChannel_Good(t *testing.T) { cfg := testConfigService(t, nil, nil) notify := NewNotifyService(cfg) - f := NewFetcherService(cfg, notify) + f := NewFetcherService(cfg, notify, nil) ch := f.Issues() require.NotNil(t, ch) diff --git a/internal/bugseti/ghcheck.go b/internal/bugseti/ghcheck.go index b12ae01..dfbb04b 100644 --- a/internal/bugseti/ghcheck.go +++ b/internal/bugseti/ghcheck.go @@ -1,30 +1,22 @@ package bugseti import ( - "fmt" - "os/exec" + "github.com/host-uk/core/pkg/forge" ) -// CheckGHCLI verifies that the gh CLI is installed and authenticated. -// Returns nil if gh is available and logged in, or an error with -// actionable instructions for the user. -func CheckGHCLI() error { - // Check if gh is in PATH - if _, err := exec.LookPath("gh"); err != nil { - return fmt.Errorf("gh CLI not found in PATH: %w\n\n"+ - "BugSETI requires the GitHub CLI (gh) to fetch issues and submit PRs.\n"+ - "Install it from: https://cli.github.com\n\n"+ - " macOS: brew install gh\n"+ - " Linux: https://github.com/cli/cli/blob/trunk/docs/install_linux.md\n"+ - " Windows: winget install --id GitHub.cli", err) +// CheckForge verifies that the Forgejo API is configured and reachable. +// Returns nil if a token is configured and the API responds, or an error +// with actionable instructions for the user. +func CheckForge() (*forge.Client, error) { + client, err := forge.NewFromConfig("", "") + if err != nil { + return nil, err } - // Check if gh is authenticated - cmd := exec.Command("gh", "auth", "status") - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("gh CLI is not authenticated: %w\n%s\n\n"+ - "Run 'gh auth login' to authenticate with GitHub.", err, out) + // Verify the token works by fetching the current user + if _, err := client.GetCurrentUser(); err != nil { + return nil, err } - return nil + return client, nil } diff --git a/internal/bugseti/ghcheck_test.go b/internal/bugseti/ghcheck_test.go index 114c292..b2fc10d 100644 --- a/internal/bugseti/ghcheck_test.go +++ b/internal/bugseti/ghcheck_test.go @@ -1,37 +1,23 @@ package bugseti import ( - "os/exec" - "strings" + "os" "testing" ) -func TestCheckGHCLI_Good(t *testing.T) { - // Only run if gh is actually available (CI-friendly skip) - if _, err := exec.LookPath("gh"); err != nil { - t.Skip("gh CLI not installed, skipping") +func TestCheckForge_Bad_MissingConfig(t *testing.T) { + // Clear any env-based forge config to ensure CheckForge fails + t.Setenv("FORGE_TOKEN", "") + t.Setenv("FORGE_URL", "") + + // Point HOME to a temp dir so no config file is found + t.Setenv("HOME", t.TempDir()) + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) } - err := CheckGHCLI() - // We can't guarantee auth status in all environments, - // but if gh is present the function should at least not panic. - if err != nil { - t.Logf("CheckGHCLI returned error (may be expected if not authenticated): %v", err) - } -} - -func TestCheckGHCLI_Bad_MissingBinary(t *testing.T) { - // Save and clear PATH to simulate missing gh - t.Setenv("PATH", t.TempDir()) - - err := CheckGHCLI() + _, err := CheckForge() if err == nil { - t.Fatal("expected error when gh is not in PATH") - } - if !strings.Contains(err.Error(), "gh CLI not found") { - t.Errorf("error should mention 'gh CLI not found', got: %v", err) - } - if !strings.Contains(err.Error(), "https://cli.github.com") { - t.Errorf("error should include install URL, got: %v", err) + t.Fatal("expected error when forge is not configured") } } diff --git a/internal/bugseti/seeder.go b/internal/bugseti/seeder.go index a35080d..5019514 100644 --- a/internal/bugseti/seeder.go +++ b/internal/bugseti/seeder.go @@ -17,14 +17,18 @@ import ( // SeederService prepares context for issues using the seed-agent-developer skill. type SeederService struct { - mu sync.Mutex - config *ConfigService + mu sync.Mutex + config *ConfigService + forgeURL string + forgeToken string } // NewSeederService creates a new SeederService. -func NewSeederService(config *ConfigService) *SeederService { +func NewSeederService(config *ConfigService, forgeURL, forgeToken string) *SeederService { return &SeederService{ - config: config, + config: config, + forgeURL: forgeURL, + forgeToken: forgeToken, } } @@ -81,7 +85,18 @@ func (s *SeederService) prepareWorkspace(issue *Issue) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - cmd := exec.CommandContext(ctx, "gh", "repo", "clone", issue.Repo, workDir, "--", "--depth=1") + cloneURL := fmt.Sprintf("%s/%s.git", strings.TrimRight(s.forgeURL, "/"), issue.Repo) + cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", cloneURL, workDir) + cmd.Env = append(os.Environ(), + fmt.Sprintf("GIT_ASKPASS=echo"), + fmt.Sprintf("GIT_TERMINAL_PROMPT=0"), + ) + if s.forgeToken != "" { + // Use token auth via URL for HTTPS clones + cloneURL = fmt.Sprintf("%s/%s.git", strings.TrimRight(s.forgeURL, "/"), issue.Repo) + cloneURL = strings.Replace(cloneURL, "://", fmt.Sprintf("://bugseti:%s@", s.forgeToken), 1) + cmd = exec.CommandContext(ctx, "git", "clone", "--depth=1", cloneURL, workDir) + } var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { diff --git a/internal/bugseti/submit.go b/internal/bugseti/submit.go index dbfeff5..c10dac0 100644 --- a/internal/bugseti/submit.go +++ b/internal/bugseti/submit.go @@ -4,13 +4,15 @@ package bugseti import ( "bytes" "context" - "encoding/json" "fmt" "log" "os/exec" - "path/filepath" "strings" "time" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/forge" ) // SubmitService handles the PR submission flow. @@ -18,14 +20,16 @@ type SubmitService struct { config *ConfigService notify *NotifyService stats *StatsService + forge *forge.Client } // NewSubmitService creates a new SubmitService. -func NewSubmitService(config *ConfigService, notify *NotifyService, stats *StatsService) *SubmitService { +func NewSubmitService(config *ConfigService, notify *NotifyService, stats *StatsService, forgeClient *forge.Client) *SubmitService { return &SubmitService{ config: config, notify: notify, stats: stats, + forge: forgeClient, } } @@ -55,7 +59,7 @@ type PRResult struct { } // Submit creates a pull request for the given issue. -// Flow: Fork -> Branch -> Commit -> PR +// Flow: Fork -> Branch -> Commit -> Push -> PR func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) { if submission == nil || submission.Issue == nil { return nil, fmt.Errorf("invalid submission") @@ -70,8 +74,13 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) { guard := getEthicsGuardWithRoot(context.Background(), s.config.GetMarketplaceMCPRoot()) issueTitle := guard.SanitizeTitle(issue.Title) + owner, repoName, err := splitRepo(issue.Repo) + if err != nil { + return &PRResult{Success: false, Error: err.Error()}, err + } + // Step 1: Ensure we have a fork - forkOwner, err := s.ensureFork(issue.Repo) + forkOwner, err := s.ensureFork(owner, repoName) if err != nil { return &PRResult{Success: false, Error: fmt.Sprintf("fork failed: %v", err)}, err } @@ -97,7 +106,7 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) { } // Step 4: Push to fork - if err := s.pushToFork(workDir, forkOwner, branch); err != nil { + if err := s.pushToFork(workDir, forkOwner, repoName, branch); err != nil { return &PRResult{Success: false, Error: fmt.Sprintf("push failed: %v", err)}, err } @@ -114,7 +123,7 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) { } prBody = guard.SanitizeBody(prBody) - prURL, prNumber, err := s.createPR(issue.Repo, forkOwner, branch, prTitle, prBody) + prURL, prNumber, err := s.createPR(owner, repoName, forkOwner, branch, prTitle, prBody) if err != nil { return &PRResult{Success: false, Error: fmt.Sprintf("PR creation failed: %v", err)}, err } @@ -133,39 +142,31 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) { }, nil } -// ensureFork ensures a fork exists for the repo. -func (s *SubmitService) ensureFork(repo string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - // Check if fork exists - parts := strings.Split(repo, "/") - if len(parts) != 2 { - return "", fmt.Errorf("invalid repo format: %s", repo) - } - +// ensureFork ensures a fork exists for the repo, returns the fork owner's username. +func (s *SubmitService) ensureFork(owner, repo string) (string, error) { // Get current user - cmd := exec.CommandContext(ctx, "gh", "api", "user", "--jq", ".login") - output, err := cmd.Output() + user, err := s.forge.GetCurrentUser() if err != nil { - return "", fmt.Errorf("failed to get user: %w", err) + return "", fmt.Errorf("failed to get current user: %w", err) } - username := strings.TrimSpace(string(output)) + username := user.UserName - // Check if fork exists - forkRepo := fmt.Sprintf("%s/%s", username, parts[1]) - cmd = exec.CommandContext(ctx, "gh", "repo", "view", forkRepo, "--json", "name") - if err := cmd.Run(); err != nil { - // Fork doesn't exist, create it - log.Printf("Creating fork of %s...", repo) - cmd = exec.CommandContext(ctx, "gh", "repo", "fork", repo, "--clone=false") - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("failed to create fork: %w", err) - } - // Wait a bit for GitHub to process - time.Sleep(2 * time.Second) + // Check if fork already exists + _, err = s.forge.GetRepo(username, repo) + if err == nil { + return username, nil } + // Fork doesn't exist, create it + log.Printf("Creating fork of %s/%s...", owner, repo) + _, err = s.forge.ForkRepo(owner, repo, "") + if err != nil { + return "", fmt.Errorf("failed to create fork: %w", err) + } + + // Wait for Forgejo to process the fork + time.Sleep(2 * time.Second) + return username, nil } @@ -241,7 +242,7 @@ func (s *SubmitService) commitChanges(workDir string, files []string, message st } // pushToFork pushes the branch to the user's fork. -func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error { +func (s *SubmitService) pushToFork(workDir, forkOwner, repoName, branch string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() @@ -250,16 +251,13 @@ func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error { cmd := exec.CommandContext(ctx, "git", "remote", "get-url", forkRemote) cmd.Dir = workDir if err := cmd.Run(); err != nil { - // Get the origin URL and construct fork URL - cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "origin") - cmd.Dir = workDir - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to get origin URL: %w", err) - } + // Construct fork URL using the forge instance URL + forkURL := fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(s.forge.URL(), "/"), forkOwner, repoName) - originURL := strings.TrimSpace(string(output)) - forkURL := buildForkURL(originURL, forkOwner) + // Embed token for HTTPS push auth + if s.forge.Token() != "" { + forkURL = strings.Replace(forkURL, "://", fmt.Sprintf("://bugseti:%s@", s.forge.Token()), 1) + } cmd = exec.CommandContext(ctx, "git", "remote", "add", forkRemote, forkURL) cmd.Dir = workDir @@ -280,36 +278,19 @@ func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error { return nil } -// createPR creates a pull request using GitHub CLI. -func (s *SubmitService) createPR(repo, forkOwner, branch, title, body string) (string, int, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Create PR - cmd := exec.CommandContext(ctx, "gh", "pr", "create", - "--repo", repo, - "--head", fmt.Sprintf("%s:%s", forkOwner, branch), - "--title", title, - "--body", body, - "--json", "url,number") - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return "", 0, fmt.Errorf("failed to create PR: %s: %w", stderr.String(), err) +// createPR creates a pull request using the Forgejo API. +func (s *SubmitService) createPR(owner, repo, forkOwner, branch, title, body string) (string, int, error) { + pr, err := s.forge.CreatePullRequest(owner, repo, forgejo.CreatePullRequestOption{ + Head: fmt.Sprintf("%s:%s", forkOwner, branch), + Base: "main", + Title: title, + Body: body, + }) + if err != nil { + return "", 0, fmt.Errorf("failed to create PR: %w", err) } - var result struct { - URL string `json:"url"` - Number int `json:"number"` - } - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - return "", 0, fmt.Errorf("failed to parse PR response: %w", err) - } - - return result.URL, result.Number, nil + return pr.HTMLURL, int(pr.Index), nil } // generatePRBody creates a default PR body for an issue. @@ -332,76 +313,44 @@ func (s *SubmitService) generatePRBody(issue *Issue) string { body.WriteString("\n\n") body.WriteString("---\n\n") - body.WriteString("*Submitted via [BugSETI](https://github.com/host-uk/core) - Distributed Bug Fixing*\n") + body.WriteString("*Submitted via [BugSETI](https://bugseti.app) - Distributed Bug Fixing*\n") return body.String() } -// buildForkURL constructs a fork remote URL from an origin URL by replacing -// the owner segment with forkOwner. -func buildForkURL(originURL, forkOwner string) string { - if strings.HasPrefix(originURL, "https://") { - // https://github.com/owner/repo.git - parts := strings.Split(originURL, "/") - if len(parts) >= 4 { - parts[len(parts)-2] = forkOwner - return strings.Join(parts, "/") - } - return originURL - } - // git@github.com:owner/repo.git - return fmt.Sprintf("git@github.com:%s/%s", forkOwner, filepath.Base(originURL)) -} - // GetPRStatus checks the status of a submitted PR. func (s *SubmitService) GetPRStatus(repo string, prNumber int) (*PRStatus, error) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() + owner, repoName, err := splitRepo(repo) + if err != nil { + return nil, err + } - cmd := exec.CommandContext(ctx, "gh", "pr", "view", - "--repo", repo, - fmt.Sprintf("%d", prNumber), - "--json", "state,mergeable,reviews,statusCheckRollup") - - output, err := cmd.Output() + pr, err := s.forge.GetPullRequest(owner, repoName, int64(prNumber)) if err != nil { return nil, fmt.Errorf("failed to get PR status: %w", err) } - var result struct { - State string `json:"state"` - Mergeable string `json:"mergeable"` - StatusCheckRollup []struct { - State string `json:"state"` - } `json:"statusCheckRollup"` - Reviews []struct { - State string `json:"state"` - } `json:"reviews"` - } - - if err := json.Unmarshal(output, &result); err != nil { - return nil, fmt.Errorf("failed to parse PR status: %w", err) - } - status := &PRStatus{ - State: result.State, - Mergeable: result.Mergeable == "MERGEABLE", + State: string(pr.State), + Mergeable: pr.Mergeable, } - // Check CI status - status.CIPassing = true - for _, check := range result.StatusCheckRollup { - if check.State != "SUCCESS" && check.State != "NEUTRAL" { - status.CIPassing = false - break + // Check CI status via combined commit status + if pr.Head != nil { + combined, err := s.forge.GetCombinedStatus(owner, repoName, pr.Head.Sha) + if err == nil && combined != nil { + status.CIPassing = combined.State == forgejo.StatusSuccess } } // Check review status - for _, review := range result.Reviews { - if review.State == "APPROVED" { - status.Approved = true - break + reviews, err := s.forge.ListPRReviews(owner, repoName, int64(prNumber)) + if err == nil { + for _, review := range reviews { + if review.State == forgejo.ReviewStateApproved { + status.Approved = true + break + } } } diff --git a/internal/bugseti/submit_test.go b/internal/bugseti/submit_test.go index 582ac42..80a3999 100644 --- a/internal/bugseti/submit_test.go +++ b/internal/bugseti/submit_test.go @@ -5,22 +5,9 @@ import ( "testing" ) -// helpers to build minimal service dependencies without touching disk - -func testConfigService(t *testing.T) *ConfigService { - t.Helper() - dir := t.TempDir() - return &ConfigService{ - path: dir + "/config.json", - config: &Config{ - DataDir: dir, - }, - } -} - func testSubmitService(t *testing.T) *SubmitService { t.Helper() - cfg := testConfigService(t) + cfg := testConfigService(t, nil, nil) notify := &NotifyService{enabled: false, config: cfg} stats := &StatsService{ config: cfg, @@ -29,7 +16,7 @@ func testSubmitService(t *testing.T) *SubmitService { DailyActivity: make(map[string]*DayStats), }, } - return NewSubmitService(cfg, notify, stats) + return NewSubmitService(cfg, notify, stats, nil) } // --- NewSubmitService / ServiceName --- @@ -89,53 +76,6 @@ func TestSubmit_Bad_EmptyWorkDir(t *testing.T) { } } -// --- buildForkURL --- - -func TestBuildForkURL_Good_HTTPS(t *testing.T) { - origin := "https://github.com/upstream-owner/my-repo.git" - got := buildForkURL(origin, "myfork") - want := "https://github.com/myfork/my-repo.git" - if got != want { - t.Fatalf("HTTPS fork URL:\n got: %s\n want: %s", got, want) - } -} - -func TestBuildForkURL_Good_HTTPSNoGitSuffix(t *testing.T) { - origin := "https://github.com/upstream-owner/my-repo" - got := buildForkURL(origin, "myfork") - want := "https://github.com/myfork/my-repo" - if got != want { - t.Fatalf("HTTPS fork URL without .git:\n got: %s\n want: %s", got, want) - } -} - -func TestBuildForkURL_Good_SSH(t *testing.T) { - origin := "git@github.com:upstream-owner/my-repo.git" - got := buildForkURL(origin, "myfork") - want := "git@github.com:myfork/my-repo.git" - if got != want { - t.Fatalf("SSH fork URL:\n got: %s\n want: %s", got, want) - } -} - -func TestBuildForkURL_Good_SSHNoGitSuffix(t *testing.T) { - origin := "git@github.com:upstream-owner/my-repo" - got := buildForkURL(origin, "myfork") - want := "git@github.com:myfork/my-repo" - if got != want { - t.Fatalf("SSH fork URL without .git:\n got: %s\n want: %s", got, want) - } -} - -func TestBuildForkURL_Bad_ShortHTTPS(t *testing.T) { - // URL with fewer than 4 parts after split returns unchanged - origin := "https://x" - got := buildForkURL(origin, "fork") - if got != origin { - t.Fatalf("expected unchanged URL for short HTTPS, got: %s", got) - } -} - // --- generatePRBody --- func TestGeneratePRBody_Good_Basic(t *testing.T) { @@ -226,7 +166,7 @@ func TestPRSubmission_Good_Defaults(t *testing.T) { func TestPRResult_Good_Success(t *testing.T) { r := &PRResult{ Success: true, - PRURL: "https://github.com/o/r/pull/1", + PRURL: "https://forge.lthn.ai/o/r/pulls/1", PRNumber: 1, ForkOwner: "me", } @@ -255,13 +195,13 @@ func TestPRResult_Good_Failure(t *testing.T) { func TestPRStatus_Good(t *testing.T) { s := &PRStatus{ - State: "OPEN", + State: "open", Mergeable: true, CIPassing: true, Approved: false, } - if s.State != "OPEN" { - t.Fatalf("expected OPEN, got %s", s.State) + if s.State != "open" { + t.Fatalf("expected open, got %s", s.State) } if !s.Mergeable { t.Fatal("expected mergeable") @@ -271,11 +211,20 @@ func TestPRStatus_Good(t *testing.T) { } } -// --- ensureFork validation --- +// --- splitRepo --- -func TestEnsureFork_Bad_InvalidRepoFormat(t *testing.T) { - s := testSubmitService(t) - _, err := s.ensureFork("invalidrepo") +func TestSplitRepo_Good(t *testing.T) { + owner, repo, err := splitRepo("myorg/myrepo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if owner != "myorg" || repo != "myrepo" { + t.Fatalf("expected myorg/myrepo, got %s/%s", owner, repo) + } +} + +func TestSplitRepo_Bad(t *testing.T) { + _, _, err := splitRepo("invalidrepo") if err == nil { t.Fatal("expected error for invalid repo format") } diff --git a/pkg/forge/client.go b/pkg/forge/client.go index 63fc4f8..7dfe51d 100644 --- a/pkg/forge/client.go +++ b/pkg/forge/client.go @@ -36,3 +36,38 @@ func (c *Client) API() *forgejo.Client { return c.api } // URL returns the Forgejo instance URL. func (c *Client) URL() string { return c.url } + +// Token returns the Forgejo API token. +func (c *Client) Token() string { return c.token } + +// GetCurrentUser returns the authenticated user's information. +func (c *Client) GetCurrentUser() (*forgejo.User, error) { + user, _, err := c.api.GetMyUserInfo() + if err != nil { + return nil, log.E("forge.GetCurrentUser", "failed to get current user", err) + } + return user, nil +} + +// ForkRepo forks a repository. If org is non-empty, forks into that organisation. +func (c *Client) ForkRepo(owner, repo string, org string) (*forgejo.Repository, error) { + opts := forgejo.CreateForkOption{} + if org != "" { + opts.Organization = &org + } + + fork, _, err := c.api.CreateFork(owner, repo, opts) + if err != nil { + return nil, log.E("forge.ForkRepo", "failed to fork repository", err) + } + return fork, nil +} + +// CreatePullRequest creates a pull request on the given repository. +func (c *Client) CreatePullRequest(owner, repo string, opts forgejo.CreatePullRequestOption) (*forgejo.PullRequest, error) { + pr, _, err := c.api.CreatePullRequest(owner, repo, opts) + if err != nil { + return nil, log.E("forge.CreatePullRequest", "failed to create pull request", err) + } + return pr, nil +} diff --git a/pkg/forge/issues.go b/pkg/forge/issues.go index 55fa2e3..00c55b5 100644 --- a/pkg/forge/issues.go +++ b/pkg/forge/issues.go @@ -8,9 +8,10 @@ import ( // ListIssuesOpts configures issue listing. type ListIssuesOpts struct { - State string // "open", "closed", "all" - Page int - Limit int + State string // "open", "closed", "all" + Labels []string // filter by label names + Page int + Limit int } // ListIssues returns issues for the given repository. @@ -33,11 +34,14 @@ func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*forgejo page = 1 } - issues, _, err := c.api.ListRepoIssues(owner, repo, forgejo.ListIssueOption{ + listOpt := forgejo.ListIssueOption{ ListOptions: forgejo.ListOptions{Page: page, PageSize: limit}, State: state, Type: forgejo.IssueTypeIssue, - }) + Labels: opts.Labels, + } + + issues, _, err := c.api.ListRepoIssues(owner, repo, listOpt) if err != nil { return nil, log.E("forge.ListIssues", "failed to list issues", err) } @@ -140,6 +144,30 @@ func (c *Client) CreateIssueComment(owner, repo string, issue int64, body string return nil } +// ListIssueComments returns comments for an issue. +func (c *Client) ListIssueComments(owner, repo string, number int64) ([]*forgejo.Comment, error) { + var all []*forgejo.Comment + page := 1 + + for { + comments, resp, err := c.api.ListIssueComments(owner, repo, number, forgejo.ListIssueCommentOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("forge.ListIssueComments", "failed to list comments", err) + } + + all = append(all, comments...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + // CloseIssue closes an issue by setting its state to closed. func (c *Client) CloseIssue(owner, repo string, number int64) error { closed := forgejo.StateClosed