cli/pkg/jobrunner/github/source_test.go
Snider 070f0c7c71 feat(jobrunner): add automated PR workflow system (#329)
- 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
2026-02-05 10:36:21 +00:00

270 lines
6.5 KiB
Go

package github
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGitHubSource_Name_Good(t *testing.T) {
src := NewGitHubSource(Config{Repos: []string{"owner/repo"}})
assert.Equal(t, "github", src.Name())
}
func TestGitHubSource_Poll_Good(t *testing.T) {
epic := ghIssue{
Number: 10,
Title: "Epic: feature rollout",
Body: "Tasks:\n- [ ] #5\n- [x] #6\n- [ ] #7",
Labels: []ghLabel{{Name: "epic"}},
State: "open",
}
pr5 := ghPR{
Number: 50,
Title: "Implement child #5",
Body: "Closes #5",
State: "open",
Draft: false,
MergeableState: "clean",
Head: ghRef{SHA: "abc123", Ref: "feature-5"},
}
// PR 7 has no linked reference to any child, so child #7 should not produce a signal.
pr99 := ghPR{
Number: 99,
Title: "Unrelated PR",
Body: "No issue links here",
State: "open",
Draft: false,
MergeableState: "dirty",
Head: ghRef{SHA: "def456", Ref: "feature-other"},
}
checkSuites := ghCheckSuites{
TotalCount: 1,
CheckSuites: []ghCheckSuite{
{ID: 1, Status: "completed", Conclusion: "success"},
},
}
mux := http.NewServeMux()
mux.HandleFunc("GET /repos/test-org/test-repo/issues", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "epic", r.URL.Query().Get("labels"))
assert.Equal(t, "open", r.URL.Query().Get("state"))
w.Header().Set("ETag", `"epic-etag-1"`)
_ = json.NewEncoder(w).Encode([]ghIssue{epic})
})
mux.HandleFunc("GET /repos/test-org/test-repo/pulls", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "open", r.URL.Query().Get("state"))
_ = json.NewEncoder(w).Encode([]ghPR{pr5, pr99})
})
mux.HandleFunc("GET /repos/test-org/test-repo/commits/abc123/check-suites", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(checkSuites)
})
srv := httptest.NewServer(mux)
defer srv.Close()
src := NewGitHubSource(Config{
Repos: []string{"test-org/test-repo"},
APIURL: srv.URL,
})
signals, err := src.Poll(context.Background())
require.NoError(t, err)
// Only child #5 has a linked PR (pr5 references #5 in body).
// Child #7 is unchecked but no PR references it.
// Child #6 is checked so it's ignored.
require.Len(t, signals, 1)
sig := signals[0]
assert.Equal(t, 10, sig.EpicNumber)
assert.Equal(t, 5, sig.ChildNumber)
assert.Equal(t, 50, sig.PRNumber)
assert.Equal(t, "test-org", sig.RepoOwner)
assert.Equal(t, "test-repo", sig.RepoName)
assert.Equal(t, "OPEN", sig.PRState)
assert.Equal(t, false, sig.IsDraft)
assert.Equal(t, "MERGEABLE", sig.Mergeable)
assert.Equal(t, "SUCCESS", sig.CheckStatus)
assert.Equal(t, "abc123", sig.LastCommitSHA)
}
func TestGitHubSource_Poll_Good_NotModified(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("GET /repos/test-org/test-repo/issues", func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 1 {
w.Header().Set("ETag", `"etag-v1"`)
_ = json.NewEncoder(w).Encode([]ghIssue{})
} else {
// Second call should have If-None-Match.
assert.Equal(t, `"etag-v1"`, r.Header.Get("If-None-Match"))
w.WriteHeader(http.StatusNotModified)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
src := NewGitHubSource(Config{
Repos: []string{"test-org/test-repo"},
APIURL: srv.URL,
})
// First poll — gets empty list, stores ETag.
signals, err := src.Poll(context.Background())
require.NoError(t, err)
assert.Empty(t, signals)
// Second poll — sends If-None-Match, gets 304.
signals, err = src.Poll(context.Background())
require.NoError(t, err)
assert.Empty(t, signals)
assert.Equal(t, 2, callCount)
}
func TestParseEpicChildren_Good(t *testing.T) {
body := `## Epic
Tasks to complete:
- [ ] #1
- [x] #2
- [ ] #3
- [x] #4
- [ ] #5
`
unchecked, checked := parseEpicChildren(body)
assert.Equal(t, []int{1, 3, 5}, unchecked)
assert.Equal(t, []int{2, 4}, checked)
}
func TestParseEpicChildren_Good_Empty(t *testing.T) {
unchecked, checked := parseEpicChildren("No checklist here")
assert.Nil(t, unchecked)
assert.Nil(t, checked)
}
func TestFindLinkedPR_Good(t *testing.T) {
prs := []ghPR{
{Number: 10, Body: "Unrelated work"},
{Number: 20, Body: "Fixes #42 and updates docs"},
{Number: 30, Body: "Closes #99"},
}
pr := findLinkedPR(prs, 42)
require.NotNil(t, pr)
assert.Equal(t, 20, pr.Number)
}
func TestFindLinkedPR_Good_NoMatch(t *testing.T) {
prs := []ghPR{
{Number: 10, Body: "Unrelated work"},
{Number: 20, Body: "Closes #99"},
}
pr := findLinkedPR(prs, 42)
assert.Nil(t, pr)
}
func TestAggregateCheckStatus_Good(t *testing.T) {
tests := []struct {
name string
suites []ghCheckSuite
want string
}{
{
name: "all success",
suites: []ghCheckSuite{{Status: "completed", Conclusion: "success"}},
want: "SUCCESS",
},
{
name: "one failure",
suites: []ghCheckSuite{{Status: "completed", Conclusion: "failure"}},
want: "FAILURE",
},
{
name: "in progress",
suites: []ghCheckSuite{{Status: "in_progress", Conclusion: ""}},
want: "PENDING",
},
{
name: "empty",
suites: nil,
want: "PENDING",
},
{
name: "mixed completed",
suites: []ghCheckSuite{
{Status: "completed", Conclusion: "success"},
{Status: "completed", Conclusion: "failure"},
},
want: "FAILURE",
},
{
name: "neutral is success",
suites: []ghCheckSuite{
{Status: "completed", Conclusion: "neutral"},
{Status: "completed", Conclusion: "success"},
},
want: "SUCCESS",
},
{
name: "skipped is success",
suites: []ghCheckSuite{
{Status: "completed", Conclusion: "skipped"},
},
want: "SUCCESS",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := aggregateCheckStatus(tc.suites)
assert.Equal(t, tc.want, got)
})
}
}
func TestMergeableToString_Good(t *testing.T) {
tests := []struct {
input string
want string
}{
{"clean", "MERGEABLE"},
{"has_hooks", "MERGEABLE"},
{"unstable", "MERGEABLE"},
{"dirty", "CONFLICTING"},
{"blocked", "CONFLICTING"},
{"unknown", "UNKNOWN"},
{"", "UNKNOWN"},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got := mergeableToString(tc.input)
assert.Equal(t, tc.want, got)
})
}
}
func TestGitHubSource_Report_Good(t *testing.T) {
src := NewGitHubSource(Config{Repos: []string{"owner/repo"}})
err := src.Report(context.Background(), nil)
assert.NoError(t, err)
}