- 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
270 lines
6.5 KiB
Go
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)
|
|
}
|