Merge pull request 'feat(bugseti): migrate from GitHub gh CLI to Forgejo SDK' (#159) from feat/bugseti-forgejo-migration into new

This commit is contained in:
Virgil 2026-02-13 21:44:43 +00:00
commit 1facdd602f
11 changed files with 314 additions and 361 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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 {

View file

@ -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("<!-- Describe how you tested your changes -->\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
}
}
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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