test(forgejo): add supplementary signal source coverage

Extends Phase 3 forgejo source tests with 9 additional scenarios:
multi-epic with multiple children, combined status fetch error
fallback to PENDING, mixed repos with partial failure, Report
comment body format (table-driven), Report posting to correct
epic issue, signal field completeness, all-children-checked
producing zero signals, and NeedsCoding signal field population.

Co-Authored-By: Charon <developers@lethean.io>
This commit is contained in:
Claude 2026-02-20 11:52:11 +00:00
parent 70e06d2226
commit 4a2e1298a4
No known key found for this signature in database
GPG key ID: AF404715446AEB41

View file

@ -0,0 +1,409 @@
package forgejo
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"forge.lthn.ai/core/go-scm/jobrunner"
)
// ---------------------------------------------------------------------------
// Supplementary Forgejo signal source tests — extends Phase 3 coverage
// ---------------------------------------------------------------------------
func TestForgejoSource_Poll_Good_MultipleEpicsMultipleChildren(t *testing.T) {
// Two epics, each with multiple unchecked children that have linked PRs.
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(path, "/issues"):
issues := []map[string]any{
{
"number": 10,
"body": "## Sprint\n- [ ] #11\n- [ ] #12\n- [x] #13\n",
"labels": []map[string]string{{"name": "epic"}},
"state": "open",
},
{
"number": 20,
"body": "## Sprint 2\n- [ ] #21\n",
"labels": []map[string]string{{"name": "epic"}},
"state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/pulls"):
prs := []map[string]any{
{
"number": 30, "body": "Fixes #11", "state": "open",
"mergeable": true, "merged": false,
"head": map[string]string{"sha": "aaa111", "ref": "fix-11", "label": "fix-11"},
},
{
"number": 31, "body": "Fixes #12", "state": "open",
"mergeable": false, "merged": false,
"head": map[string]string{"sha": "bbb222", "ref": "fix-12", "label": "fix-12"},
},
{
"number": 32, "body": "Resolves #21", "state": "open",
"mergeable": true, "merged": false,
"head": map[string]string{"sha": "ccc333", "ref": "fix-21", "label": "fix-21"},
},
}
_ = json.NewEncoder(w).Encode(prs)
case strings.Contains(path, "/status"):
_ = json.NewEncoder(w).Encode(map[string]any{
"state": "success", "total_count": 1, "statuses": []any{},
})
default:
w.WriteHeader(http.StatusOK)
}
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{Repos: []string{"org/repo"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
// Epic 10 has #11 and #12 unchecked; epic 20 has #21 unchecked. Total 3 signals.
require.Len(t, signals, 3, "expected three signals from two epics")
childNumbers := map[int]bool{}
for _, sig := range signals {
childNumbers[sig.ChildNumber] = true
}
assert.True(t, childNumbers[11])
assert.True(t, childNumbers[12])
assert.True(t, childNumbers[21])
}
func TestForgejoSource_Poll_Good_CombinedStatusFetchErrorFallsToPending(t *testing.T) {
// When combined status fetch fails, check status should default to PENDING.
var statusFetched atomic.Bool
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(path, "/issues"):
issues := []map[string]any{
{
"number": 1, "body": "- [ ] #2\n",
"labels": []map[string]string{{"name": "epic"}}, "state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/pulls"):
prs := []map[string]any{
{
"number": 10, "body": "Fixes #2", "state": "open",
"mergeable": true, "merged": false,
"head": map[string]string{"sha": "sha123", "ref": "fix", "label": "fix"},
},
}
_ = json.NewEncoder(w).Encode(prs)
case strings.Contains(path, "/status"):
statusFetched.Store(true)
w.WriteHeader(http.StatusInternalServerError)
default:
w.WriteHeader(http.StatusOK)
}
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{Repos: []string{"org/repo"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
require.Len(t, signals, 1)
assert.True(t, statusFetched.Load(), "status endpoint should have been called")
assert.Equal(t, "PENDING", signals[0].CheckStatus, "failed status fetch should default to PENDING")
}
func TestForgejoSource_Poll_Good_MixedReposFirstFailsSecondSucceeds(t *testing.T) {
// First repo fails (issues endpoint 500), second repo succeeds.
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(path, "/repos/bad-org/bad-repo/issues"):
w.WriteHeader(http.StatusInternalServerError)
case strings.Contains(path, "/repos/good-org/good-repo/issues"):
issues := []map[string]any{
{
"number": 1, "body": "- [ ] #2\n",
"labels": []map[string]string{{"name": "epic"}}, "state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/repos/good-org/good-repo/pulls"):
prs := []map[string]any{
{
"number": 10, "body": "Fixes #2", "state": "open",
"mergeable": true, "merged": false,
"head": map[string]string{"sha": "abc", "ref": "fix", "label": "fix"},
},
}
_ = json.NewEncoder(w).Encode(prs)
case strings.Contains(path, "/status"):
_ = json.NewEncoder(w).Encode(map[string]any{
"state": "success", "total_count": 1, "statuses": []any{},
})
default:
w.WriteHeader(http.StatusOK)
}
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{Repos: []string{"bad-org/bad-repo", "good-org/good-repo"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
require.Len(t, signals, 1, "only the good repo should produce signals")
assert.Equal(t, "good-org", signals[0].RepoOwner)
assert.Equal(t, "good-repo", signals[0].RepoName)
}
func TestForgejoSource_Report_Good_CommentBodyTable(t *testing.T) {
tests := []struct {
name string
result *jobrunner.ActionResult
wantContains []string
}{
{
name: "successful action",
result: &jobrunner.ActionResult{
Action: "enable_auto_merge", RepoOwner: "org", RepoName: "repo",
EpicNumber: 10, ChildNumber: 11, PRNumber: 20, Success: true,
},
wantContains: []string{"enable_auto_merge", "succeeded", "#11", "PR #20"},
},
{
name: "failed action with error",
result: &jobrunner.ActionResult{
Action: "tick_parent", RepoOwner: "org", RepoName: "repo",
EpicNumber: 10, ChildNumber: 11, PRNumber: 20,
Success: false, Error: "rate limit exceeded",
},
wantContains: []string{"tick_parent", "failed", "#11", "PR #20", "rate limit exceeded"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedBody string
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
capturedBody = body["body"]
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1})
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{}, client)
err := s.Report(context.Background(), tt.result)
require.NoError(t, err)
for _, want := range tt.wantContains {
assert.Contains(t, capturedBody, want)
}
})
}
}
func TestForgejoSource_Report_Good_PostsToCorrectEpicIssue(t *testing.T) {
var capturedPath string
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost {
capturedPath = r.URL.Path
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1})
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{}, client)
result := &jobrunner.ActionResult{
Action: "merge", RepoOwner: "test-org", RepoName: "test-repo",
EpicNumber: 42, ChildNumber: 7, PRNumber: 99, Success: true,
}
err := s.Report(context.Background(), result)
require.NoError(t, err)
expected := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", result.RepoOwner, result.RepoName, result.EpicNumber)
assert.Equal(t, expected, capturedPath, "comment should be posted on the epic issue")
}
func TestForgejoSource_Poll_Good_SignalFieldCompleteness(t *testing.T) {
// Verify that all expected signal fields are populated correctly.
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(path, "/issues"):
issues := []map[string]any{
{
"number": 100, "body": "## Work\n- [ ] #101\n- [x] #102\n",
"labels": []map[string]string{{"name": "epic"}}, "state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/pulls"):
prs := []map[string]any{
{
"number": 200, "body": "Closes #101", "state": "open",
"mergeable": true, "merged": false,
"head": map[string]string{"sha": "deadbeef", "ref": "feature", "label": "feature"},
},
}
_ = json.NewEncoder(w).Encode(prs)
case strings.Contains(path, "/status"):
_ = json.NewEncoder(w).Encode(map[string]any{
"state": "success", "total_count": 2,
"statuses": []map[string]any{{"status": "success"}, {"status": "success"}},
})
default:
w.WriteHeader(http.StatusOK)
}
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{Repos: []string{"acme/widgets"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
require.Len(t, signals, 1)
sig := signals[0]
assert.Equal(t, 100, sig.EpicNumber)
assert.Equal(t, 101, sig.ChildNumber)
assert.Equal(t, 200, sig.PRNumber)
assert.Equal(t, "acme", sig.RepoOwner)
assert.Equal(t, "widgets", sig.RepoName)
assert.Equal(t, "OPEN", sig.PRState)
assert.Equal(t, "MERGEABLE", sig.Mergeable)
assert.Equal(t, "SUCCESS", sig.CheckStatus)
assert.Equal(t, "deadbeef", sig.LastCommitSHA)
assert.False(t, sig.NeedsCoding)
assert.Equal(t, "acme/widgets", sig.RepoFullName())
}
func TestForgejoSource_Poll_Good_AllChildrenCheckedNoSignals(t *testing.T) {
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(path, "/issues"):
issues := []map[string]any{
{
"number": 1, "body": "- [x] #2\n- [x] #3\n",
"labels": []map[string]string{{"name": "epic"}}, "state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/pulls"):
_ = json.NewEncoder(w).Encode([]any{})
default:
w.WriteHeader(http.StatusOK)
}
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{Repos: []string{"org/repo"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
assert.Empty(t, signals, "all children checked means no work to do")
}
func TestForgejoSource_Poll_Good_NeedsCodingSignalFields(t *testing.T) {
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(path, "/issues/7"):
_ = json.NewEncoder(w).Encode(map[string]any{
"number": 7, "title": "Implement authentication",
"body": "Add OAuth2 support.", "state": "open",
"assignees": []map[string]any{{"login": "agent-bot", "username": "agent-bot"}},
})
case strings.Contains(path, "/issues"):
issues := []map[string]any{
{
"number": 1, "body": "- [ ] #7\n",
"labels": []map[string]string{{"name": "epic"}}, "state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/pulls"):
_ = json.NewEncoder(w).Encode([]any{})
default:
w.WriteHeader(http.StatusOK)
}
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{Repos: []string{"org/repo"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
require.Len(t, signals, 1)
sig := signals[0]
assert.True(t, sig.NeedsCoding)
assert.Equal(t, "agent-bot", sig.Assignee)
assert.Equal(t, "Implement authentication", sig.IssueTitle)
assert.Contains(t, sig.IssueBody, "OAuth2 support")
assert.Equal(t, 0, sig.PRNumber, "PRNumber should be zero for NeedsCoding signals")
}