- Core poller: 5min cycle, journal-backed state, signal dispatch - GitHub client: PR fetching, child issue enumeration - 11 action handlers: link/publish/merge/tick/resolve/etc. - core-ide: headless mode + MCP handler + systemd service - 39 tests, all passing
161 lines
3.9 KiB
Go
161 lines
3.9 KiB
Go
package github
|
|
|
|
import (
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/host-uk/core/pkg/jobrunner"
|
|
)
|
|
|
|
// ghIssue is a minimal GitHub issue response.
|
|
type ghIssue struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Labels []ghLabel `json:"labels"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// ghLabel is a GitHub label.
|
|
type ghLabel struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// ghPR is a minimal GitHub pull request response.
|
|
type ghPR struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
Draft bool `json:"draft"`
|
|
MergeableState string `json:"mergeable_state"`
|
|
Head ghRef `json:"head"`
|
|
}
|
|
|
|
// ghRef is a Git reference (branch head).
|
|
type ghRef struct {
|
|
SHA string `json:"sha"`
|
|
Ref string `json:"ref"`
|
|
}
|
|
|
|
// ghCheckSuites is the response for the check-suites endpoint.
|
|
type ghCheckSuites struct {
|
|
TotalCount int `json:"total_count"`
|
|
CheckSuites []ghCheckSuite `json:"check_suites"`
|
|
}
|
|
|
|
// ghCheckSuite is a single check suite.
|
|
type ghCheckSuite struct {
|
|
ID int `json:"id"`
|
|
Status string `json:"status"` // queued, in_progress, completed
|
|
Conclusion string `json:"conclusion"` // success, failure, neutral, cancelled, etc.
|
|
}
|
|
|
|
// epicChildRe matches checklist items in epic bodies: - [ ] #42 or - [x] #42
|
|
var epicChildRe = regexp.MustCompile(`- \[([ x])\] #(\d+)`)
|
|
|
|
// parseEpicChildren extracts child issue numbers from an epic body's checklist.
|
|
// Returns two slices: unchecked (pending) and checked (done) issue numbers.
|
|
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 []ghPR, issueNumber int) *ghPR {
|
|
target := strconv.Itoa(issueNumber)
|
|
for i := range prs {
|
|
matches := linkedPRRe.FindAllStringSubmatch(prs[i].Body, -1)
|
|
for _, m := range matches {
|
|
if m[1] == target {
|
|
return &prs[i]
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// aggregateCheckStatus returns SUCCESS, FAILURE, or PENDING based on check suites.
|
|
func aggregateCheckStatus(suites []ghCheckSuite) string {
|
|
if len(suites) == 0 {
|
|
return "PENDING"
|
|
}
|
|
|
|
allComplete := true
|
|
for _, s := range suites {
|
|
if s.Status != "completed" {
|
|
allComplete = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if !allComplete {
|
|
return "PENDING"
|
|
}
|
|
|
|
for _, s := range suites {
|
|
if s.Conclusion != "success" && s.Conclusion != "neutral" && s.Conclusion != "skipped" {
|
|
return "FAILURE"
|
|
}
|
|
}
|
|
|
|
return "SUCCESS"
|
|
}
|
|
|
|
// mergeableToString maps GitHub's mergeable_state to a canonical string.
|
|
func mergeableToString(state string) string {
|
|
switch state {
|
|
case "clean", "has_hooks", "unstable":
|
|
return "MERGEABLE"
|
|
case "dirty", "blocked":
|
|
return "CONFLICTING"
|
|
default:
|
|
return "UNKNOWN"
|
|
}
|
|
}
|
|
|
|
// buildSignal creates a PipelineSignal from parsed GitHub API data.
|
|
func buildSignal(
|
|
owner, repo string,
|
|
epicNumber, childNumber int,
|
|
pr *ghPR,
|
|
checkStatus string,
|
|
) *jobrunner.PipelineSignal {
|
|
prState := "OPEN"
|
|
switch pr.State {
|
|
case "closed":
|
|
prState = "CLOSED"
|
|
case "open":
|
|
prState = "OPEN"
|
|
}
|
|
|
|
return &jobrunner.PipelineSignal{
|
|
EpicNumber: epicNumber,
|
|
ChildNumber: childNumber,
|
|
PRNumber: pr.Number,
|
|
RepoOwner: owner,
|
|
RepoName: repo,
|
|
PRState: prState,
|
|
IsDraft: pr.Draft,
|
|
Mergeable: mergeableToString(pr.MergeableState),
|
|
CheckStatus: checkStatus,
|
|
LastCommitSHA: pr.Head.SHA,
|
|
LastCommitAt: time.Time{}, // Not available from list endpoint
|
|
LastReviewAt: time.Time{}, // Not available from list endpoint
|
|
}
|
|
}
|