diff --git a/jobrunner/forgejo/signals_test.go b/jobrunner/forgejo/signals_test.go new file mode 100644 index 0000000..4b72535 --- /dev/null +++ b/jobrunner/forgejo/signals_test.go @@ -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) + } + }) + } +} diff --git a/jobrunner/forgejo/source_extra_test.go b/jobrunner/forgejo/source_extra_test.go new file mode 100644 index 0000000..7548e76 --- /dev/null +++ b/jobrunner/forgejo/source_extra_test.go @@ -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") +}