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>
This commit is contained in:
parent
1e2e8e9c11
commit
69a0cd631a
11 changed files with 314 additions and 361 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue