test(forgejo): push coverage from 73.3% to 95.0%

Add unit tests for all signal mapping functions (mapPRState, mapMergeable,
mapCombinedStatus with all branches including nil, zero count, error
state). Add tests for parseEpicChildren (all checked, all unchecked,
no checkboxes), findLinkedPR (found, not found, nil), buildSignal
(with and without head SHA), splitRepo table-driven tests.

Add Poll integration tests: multiple repos, NeedsCoding with assignee,
merged PR, no head SHA (PENDING status), API errors (graceful skip),
empty repos list, non-epic issues filtered. Add Report tests: nil
result, failed result with error message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-20 01:59:36 +00:00
parent 2505aff461
commit f50fc405f5
No known key found for this signature in database
GPG key ID: AF404715446AEB41
2 changed files with 525 additions and 0 deletions

View file

@ -0,0 +1,205 @@
package forgejo
import (
"testing"
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/stretchr/testify/assert"
)
func TestMapPRState_Good_Open(t *testing.T) {
pr := &forgejosdk.PullRequest{State: forgejosdk.StateOpen, HasMerged: false}
assert.Equal(t, "OPEN", mapPRState(pr))
}
func TestMapPRState_Good_Merged(t *testing.T) {
pr := &forgejosdk.PullRequest{State: forgejosdk.StateClosed, HasMerged: true}
assert.Equal(t, "MERGED", mapPRState(pr))
}
func TestMapPRState_Good_Closed(t *testing.T) {
pr := &forgejosdk.PullRequest{State: forgejosdk.StateClosed, HasMerged: false}
assert.Equal(t, "CLOSED", mapPRState(pr))
}
func TestMapPRState_Good_UnknownState(t *testing.T) {
// Any unknown state should default to CLOSED.
pr := &forgejosdk.PullRequest{State: "weird", HasMerged: false}
assert.Equal(t, "CLOSED", mapPRState(pr))
}
func TestMapMergeable_Good_Mergeable(t *testing.T) {
pr := &forgejosdk.PullRequest{Mergeable: true, HasMerged: false}
assert.Equal(t, "MERGEABLE", mapMergeable(pr))
}
func TestMapMergeable_Good_Conflicting(t *testing.T) {
pr := &forgejosdk.PullRequest{Mergeable: false, HasMerged: false}
assert.Equal(t, "CONFLICTING", mapMergeable(pr))
}
func TestMapMergeable_Good_Merged(t *testing.T) {
pr := &forgejosdk.PullRequest{HasMerged: true}
assert.Equal(t, "UNKNOWN", mapMergeable(pr))
}
func TestMapCombinedStatus_Good_Success(t *testing.T) {
cs := &forgejosdk.CombinedStatus{
State: forgejosdk.StatusSuccess,
TotalCount: 1,
}
assert.Equal(t, "SUCCESS", mapCombinedStatus(cs))
}
func TestMapCombinedStatus_Good_Failure(t *testing.T) {
cs := &forgejosdk.CombinedStatus{
State: forgejosdk.StatusFailure,
TotalCount: 1,
}
assert.Equal(t, "FAILURE", mapCombinedStatus(cs))
}
func TestMapCombinedStatus_Good_Error(t *testing.T) {
cs := &forgejosdk.CombinedStatus{
State: forgejosdk.StatusError,
TotalCount: 1,
}
assert.Equal(t, "FAILURE", mapCombinedStatus(cs))
}
func TestMapCombinedStatus_Good_Pending(t *testing.T) {
cs := &forgejosdk.CombinedStatus{
State: forgejosdk.StatusPending,
TotalCount: 1,
}
assert.Equal(t, "PENDING", mapCombinedStatus(cs))
}
func TestMapCombinedStatus_Good_Nil(t *testing.T) {
assert.Equal(t, "PENDING", mapCombinedStatus(nil))
}
func TestMapCombinedStatus_Good_ZeroCount(t *testing.T) {
cs := &forgejosdk.CombinedStatus{
State: forgejosdk.StatusSuccess,
TotalCount: 0,
}
assert.Equal(t, "PENDING", mapCombinedStatus(cs))
}
func TestParseEpicChildren_Good_Mixed(t *testing.T) {
body := "## Sprint\n- [x] #1\n- [ ] #2\n- [x] #3\n- [ ] #4\nSome text\n"
unchecked, checked := parseEpicChildren(body)
assert.Equal(t, []int{2, 4}, unchecked)
assert.Equal(t, []int{1, 3}, checked)
}
func TestParseEpicChildren_Good_NoCheckboxes(t *testing.T) {
body := "This is just a normal issue with no checkboxes."
unchecked, checked := parseEpicChildren(body)
assert.Nil(t, unchecked)
assert.Nil(t, checked)
}
func TestParseEpicChildren_Good_AllChecked(t *testing.T) {
body := "- [x] #10\n- [x] #20\n"
unchecked, checked := parseEpicChildren(body)
assert.Nil(t, unchecked)
assert.Equal(t, []int{10, 20}, checked)
}
func TestParseEpicChildren_Good_AllUnchecked(t *testing.T) {
body := "- [ ] #5\n- [ ] #6\n"
unchecked, checked := parseEpicChildren(body)
assert.Equal(t, []int{5, 6}, unchecked)
assert.Nil(t, checked)
}
func TestFindLinkedPR_Good(t *testing.T) {
prs := []*forgejosdk.PullRequest{
{Index: 10, Body: "Fixes #5"},
{Index: 11, Body: "Resolves #7"},
{Index: 12, Body: "Nothing here"},
}
pr := findLinkedPR(prs, 7)
assert.NotNil(t, pr)
assert.Equal(t, int64(11), pr.Index)
}
func TestFindLinkedPR_Good_NotFound(t *testing.T) {
prs := []*forgejosdk.PullRequest{
{Index: 10, Body: "Fixes #5"},
}
pr := findLinkedPR(prs, 99)
assert.Nil(t, pr)
}
func TestFindLinkedPR_Good_Nil(t *testing.T) {
pr := findLinkedPR(nil, 1)
assert.Nil(t, pr)
}
func TestBuildSignal_Good(t *testing.T) {
pr := &forgejosdk.PullRequest{
Index: 42,
State: forgejosdk.StateOpen,
Mergeable: true,
Head: &forgejosdk.PRBranchInfo{Sha: "deadbeef"},
}
sig := buildSignal("org", "repo", 10, 5, pr, "SUCCESS")
assert.Equal(t, 10, sig.EpicNumber)
assert.Equal(t, 5, sig.ChildNumber)
assert.Equal(t, 42, sig.PRNumber)
assert.Equal(t, "org", sig.RepoOwner)
assert.Equal(t, "repo", 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.IsDraft)
}
func TestBuildSignal_Good_NilHead(t *testing.T) {
pr := &forgejosdk.PullRequest{
Index: 1,
State: forgejosdk.StateClosed,
HasMerged: true,
}
sig := buildSignal("org", "repo", 1, 2, pr, "PENDING")
assert.Equal(t, "", sig.LastCommitSHA)
assert.Equal(t, "MERGED", sig.PRState)
}
func TestSplitRepo_Good(t *testing.T) {
tests := []struct {
input string
owner string
repo string
err bool
}{
{"host-uk/core", "host-uk", "core", false},
{"a/b", "a", "b", false},
{"org/repo-name", "org", "repo-name", false},
{"invalid", "", "", true},
{"", "", "", true},
{"/repo", "", "", true},
{"owner/", "", "", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
owner, repo, err := splitRepo(tt.input)
if tt.err {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.owner, owner)
assert.Equal(t, tt.repo, repo)
}
})
}
}

View file

@ -0,0 +1,320 @@
package forgejo
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"forge.lthn.ai/core/go-scm/jobrunner"
)
func TestForgejoSource_Poll_Good_InvalidRepo(t *testing.T) {
// Invalid repo format should be logged and skipped, not error.
s := New(Config{Repos: []string{"invalid-no-slash"}}, nil)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
assert.Empty(t, signals)
}
func TestForgejoSource_Poll_Good_MultipleRepos(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"):
// Return one epic per repo.
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": "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{"org-a/repo-1", "org-b/repo-2"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
assert.Len(t, signals, 2)
}
func TestForgejoSource_Poll_Good_NeedsCoding(t *testing.T) {
// When a child issue has no linked PR but is assigned, NeedsCoding should be true.
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/5"):
// Child issue with assignee.
_ = json.NewEncoder(w).Encode(map[string]any{
"number": 5,
"title": "Implement feature",
"body": "Please implement this.",
"state": "open",
"assignees": []map[string]any{{"login": "darbs-claude", "username": "darbs-claude"}},
})
case strings.Contains(path, "/issues"):
issues := []map[string]any{
{
"number": 1,
"body": "- [ ] #5\n",
"labels": []map[string]string{{"name": "epic"}},
"state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/pulls"):
// No PRs linked.
_ = json.NewEncoder(w).Encode([]any{})
default:
w.WriteHeader(http.StatusOK)
}
})))
defer srv.Close()
client := newTestClient(t, srv.URL)
s := New(Config{Repos: []string{"test-org/test-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, "darbs-claude", sig.Assignee)
assert.Equal(t, "Implement feature", sig.IssueTitle)
assert.Equal(t, "Please implement this.", sig.IssueBody)
assert.Equal(t, 5, sig.ChildNumber)
}
func TestForgejoSource_Poll_Good_MergedPR(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": "- [ ] #3\n",
"labels": []map[string]string{{"name": "epic"}},
"state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/pulls"):
prs := []map[string]any{
{
"number": 20,
"body": "Fixes #3",
"state": "closed",
"mergeable": false,
"merged": true,
"head": map[string]string{"sha": "merged123", "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{"org/repo"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
require.Len(t, signals, 1)
assert.Equal(t, "MERGED", signals[0].PRState)
assert.Equal(t, "UNKNOWN", signals[0].Mergeable)
}
func TestForgejoSource_Poll_Good_NoHeadSHA(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": "- [ ] #3\n",
"labels": []map[string]string{{"name": "epic"}},
"state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
case strings.Contains(path, "/pulls"):
prs := []map[string]any{
{
"number": 20,
"body": "Fixes #3",
"state": "open",
"mergeable": true,
"merged": false,
// No head field.
},
}
_ = json.NewEncoder(w).Encode(prs)
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)
// Without head SHA, check status stays PENDING.
assert.Equal(t, "PENDING", signals[0].CheckStatus)
}
func TestForgejoSource_Report_Good_Nil(t *testing.T) {
s := New(Config{}, nil)
err := s.Report(context.Background(), nil)
assert.NoError(t, err)
}
func TestForgejoSource_Report_Good_Failed(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)
result := &jobrunner.ActionResult{
Action: "dispatch",
RepoOwner: "org",
RepoName: "repo",
EpicNumber: 1,
ChildNumber: 2,
PRNumber: 3,
Success: false,
Error: "transfer failed",
}
err := s.Report(context.Background(), result)
require.NoError(t, err)
assert.Contains(t, capturedBody, "failed")
assert.Contains(t, capturedBody, "transfer failed")
}
func TestForgejoSource_Poll_Good_APIErrors(t *testing.T) {
// When the issues API fails, poll should continue with other repos.
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})))
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)
}
func TestForgejoSource_Poll_Good_EmptyRepos(t *testing.T) {
s := New(Config{Repos: []string{}}, nil)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
assert.Empty(t, signals)
}
func TestForgejoSource_Poll_Good_NonEpicIssues(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 without the "epic" label.
issues := []map[string]any{
{
"number": 1,
"body": "- [ ] #2\n",
"labels": []map[string]string{{"name": "bug"}},
"state": "open",
},
}
_ = json.NewEncoder(w).Encode(issues)
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, "non-epic issues should not generate signals")
}