cli/pkg/jobrunner/forgejo/signals.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

114 lines
2.7 KiB
Go

package forgejo
import (
"regexp"
"strconv"
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/host-uk/core/pkg/jobrunner"
)
// epicChildRe matches checklist items: - [ ] #42 or - [x] #42
var epicChildRe = regexp.MustCompile(`- \[([ x])\] #(\d+)`)
// parseEpicChildren extracts child issue numbers from an epic body's checklist.
func parseEpicChildren(body string) (unchecked []int, checked []int) {
matches := epicChildRe.FindAllStringSubmatch(body, -1)
for _, m := range matches {
num, err := strconv.Atoi(m[2])
if err != nil {
continue
}
if m[1] == "x" {
checked = append(checked, num)
} else {
unchecked = append(unchecked, num)
}
}
return unchecked, checked
}
// linkedPRRe matches "#N" references in PR bodies.
var linkedPRRe = regexp.MustCompile(`#(\d+)`)
// findLinkedPR finds the first PR whose body references the given issue number.
func findLinkedPR(prs []*forgejosdk.PullRequest, issueNumber int) *forgejosdk.PullRequest {
target := strconv.Itoa(issueNumber)
for _, pr := range prs {
matches := linkedPRRe.FindAllStringSubmatch(pr.Body, -1)
for _, m := range matches {
if m[1] == target {
return pr
}
}
}
return nil
}
// mapPRState maps Forgejo's PR state and merged flag to a canonical string.
func mapPRState(pr *forgejosdk.PullRequest) string {
if pr.HasMerged {
return "MERGED"
}
switch pr.State {
case forgejosdk.StateOpen:
return "OPEN"
case forgejosdk.StateClosed:
return "CLOSED"
default:
return "CLOSED"
}
}
// mapMergeable maps Forgejo's boolean Mergeable field to a canonical string.
func mapMergeable(pr *forgejosdk.PullRequest) string {
if pr.HasMerged {
return "UNKNOWN"
}
if pr.Mergeable {
return "MERGEABLE"
}
return "CONFLICTING"
}
// mapCombinedStatus maps a Forgejo CombinedStatus to SUCCESS/FAILURE/PENDING.
func mapCombinedStatus(cs *forgejosdk.CombinedStatus) string {
if cs == nil || cs.TotalCount == 0 {
return "PENDING"
}
switch cs.State {
case forgejosdk.StatusSuccess:
return "SUCCESS"
case forgejosdk.StatusFailure, forgejosdk.StatusError:
return "FAILURE"
default:
return "PENDING"
}
}
// buildSignal creates a PipelineSignal from Forgejo API data.
func buildSignal(
owner, repo string,
epicNumber, childNumber int,
pr *forgejosdk.PullRequest,
checkStatus string,
) *jobrunner.PipelineSignal {
sig := &jobrunner.PipelineSignal{
EpicNumber: epicNumber,
ChildNumber: childNumber,
PRNumber: int(pr.Index),
RepoOwner: owner,
RepoName: repo,
PRState: mapPRState(pr),
IsDraft: false, // SDK v2.2.0 doesn't expose Draft; treat as non-draft
Mergeable: mapMergeable(pr),
CheckStatus: checkStatus,
}
if pr.Head != nil {
sig.LastCommitSHA = pr.Head.Sha
}
return sig
}