cli/pkg/jobrunner/forgejo/source.go
Claude d32c51d816 feat(jobrunner): port from GitHub to Forgejo using pkg/forge
Replace all GitHub API and gh CLI dependencies with Forgejo SDK via
pkg/forge. The bash dispatcher burned a week of credit in a day due to
bugs — the jobrunner now talks directly to Forgejo.

- Add forge client methods: CreateIssueComment, CloseIssue, MergePullRequest,
  SetPRDraft, ListPRReviews, GetCombinedStatus, DismissReview
- Create ForgejoSource implementing JobSource (epic polling, checklist
  parsing, commit status via combined status API)
- Rewrite all 5 handlers to accept *forge.Client instead of shelling out
- Replace ResolveThreadsHandler with DismissReviewsHandler (Forgejo has
  no thread resolution API — dismiss stale REQUEST_CHANGES reviews instead)
- Delete pkg/jobrunner/github/ and handlers/exec.go entirely
- Update internal/core-ide/headless.go to wire Forgejo source and handlers
- All 33 tests pass with mock Forgejo HTTP servers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 00:40:49 +00:00

153 lines
3.9 KiB
Go

package forgejo
import (
"context"
"fmt"
"strings"
"github.com/host-uk/core/pkg/forge"
"github.com/host-uk/core/pkg/jobrunner"
"github.com/host-uk/core/pkg/log"
)
// Config configures a ForgejoSource.
type Config struct {
Repos []string // "owner/repo" format
}
// ForgejoSource polls a Forgejo instance for pipeline signals from epic issues.
type ForgejoSource struct {
repos []string
forge *forge.Client
}
// New creates a ForgejoSource using the given forge client.
func New(cfg Config, client *forge.Client) *ForgejoSource {
return &ForgejoSource{
repos: cfg.Repos,
forge: client,
}
}
// Name returns the source identifier.
func (s *ForgejoSource) Name() string {
return "forgejo"
}
// Poll fetches epics and their linked PRs from all configured repositories,
// returning a PipelineSignal for each unchecked child that has a linked PR.
func (s *ForgejoSource) Poll(ctx context.Context) ([]*jobrunner.PipelineSignal, error) {
var signals []*jobrunner.PipelineSignal
for _, repoFull := range s.repos {
owner, repo, err := splitRepo(repoFull)
if err != nil {
log.Error("invalid repo format", "repo", repoFull, "err", err)
continue
}
repoSignals, err := s.pollRepo(ctx, owner, repo)
if err != nil {
log.Error("poll repo failed", "repo", repoFull, "err", err)
continue
}
signals = append(signals, repoSignals...)
}
return signals, nil
}
// Report posts the action result as a comment on the epic issue.
func (s *ForgejoSource) Report(ctx context.Context, result *jobrunner.ActionResult) error {
if result == nil {
return nil
}
status := "succeeded"
if !result.Success {
status = "failed"
}
body := fmt.Sprintf("**jobrunner** `%s` %s for #%d (PR #%d)", result.Action, status, result.ChildNumber, result.PRNumber)
if result.Error != "" {
body += fmt.Sprintf("\n\n```\n%s\n```", result.Error)
}
return s.forge.CreateIssueComment(result.RepoOwner, result.RepoName, int64(result.EpicNumber), body)
}
// pollRepo fetches epics and PRs for a single repository.
func (s *ForgejoSource) pollRepo(_ context.Context, owner, repo string) ([]*jobrunner.PipelineSignal, error) {
// Fetch epic issues (label=epic, state=open).
issues, err := s.forge.ListIssues(owner, repo, forge.ListIssuesOpts{State: "open"})
if err != nil {
return nil, fmt.Errorf("fetch issues: %w", err)
}
// Filter to epics only.
var epics []epicInfo
for _, issue := range issues {
for _, label := range issue.Labels {
if label.Name == "epic" {
epics = append(epics, epicInfo{
Number: int(issue.Index),
Body: issue.Body,
})
break
}
}
}
if len(epics) == 0 {
return nil, nil
}
// Fetch all open PRs (and also merged/closed to catch MERGED state).
prs, err := s.forge.ListPullRequests(owner, repo, "all")
if err != nil {
return nil, fmt.Errorf("fetch PRs: %w", err)
}
var signals []*jobrunner.PipelineSignal
for _, epic := range epics {
unchecked, _ := parseEpicChildren(epic.Body)
for _, childNum := range unchecked {
pr := findLinkedPR(prs, childNum)
if pr == nil {
continue
}
// Get combined commit status for the PR's head SHA.
checkStatus := "PENDING"
if pr.Head != nil && pr.Head.Sha != "" {
cs, err := s.forge.GetCombinedStatus(owner, repo, pr.Head.Sha)
if err != nil {
log.Error("fetch combined status failed", "repo", owner+"/"+repo, "sha", pr.Head.Sha, "err", err)
} else {
checkStatus = mapCombinedStatus(cs)
}
}
sig := buildSignal(owner, repo, epic.Number, childNum, pr, checkStatus)
signals = append(signals, sig)
}
}
return signals, nil
}
type epicInfo struct {
Number int
Body string
}
// splitRepo parses "owner/repo" into its components.
func splitRepo(full string) (string, string, error) {
parts := strings.SplitN(full, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("expected owner/repo format, got %q", full)
}
return parts[0], parts[1], nil
}