go-agent/jobrunner/forgejo/source_phase3_test.go
Snider 61e01bfdf1 feat: initial go-agent — agentci + jobrunner + plugins marketplace
Consolidates three codebases into a single agent orchestration repo:

- agentci (from go-scm): Clotho dual-run verification, agent config,
  SSH security (sanitisation, secure commands, token masking)
- jobrunner (from go-scm): Poll-dispatch-report pipeline with 7 handlers
  (dispatch, completion, auto-merge, publish draft, dismiss reviews,
  send fix command, tick parent epic)
- plugins marketplace (from agentic/plugins): 27 Claude/Codex/Gemini
  plugins with shared MCP server

All 150+ tests passing across 6 packages.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 15:47:19 +00:00

672 lines
18 KiB
Go

package forgejo
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"forge.lthn.ai/core/go-scm/forge"
"forge.lthn.ai/core/go-agent/jobrunner"
)
// --- Signal parsing and filtering tests ---
func TestParseEpicChildren_Good_EmptyBody(t *testing.T) {
unchecked, checked := parseEpicChildren("")
assert.Nil(t, unchecked)
assert.Nil(t, checked)
}
func TestParseEpicChildren_Good_MixedContent(t *testing.T) {
// Checkboxes mixed with regular markdown content.
body := `## Epic: Refactor Auth
Some description of the epic.
### Tasks
- [x] #10 — Migrate session store
- [ ] #11 — Update OAuth flow
- [x] #12 — Fix token refresh
- [ ] #13 — Add 2FA support
### Notes
This is a note, not a checkbox.
- Regular list item
- Another item
`
unchecked, checked := parseEpicChildren(body)
assert.Equal(t, []int{11, 13}, unchecked)
assert.Equal(t, []int{10, 12}, checked)
}
func TestParseEpicChildren_Good_LargeIssueNumbers(t *testing.T) {
body := "- [ ] #9999\n- [x] #10000\n"
unchecked, checked := parseEpicChildren(body)
assert.Equal(t, []int{9999}, unchecked)
assert.Equal(t, []int{10000}, checked)
}
func TestParseEpicChildren_Good_ConsecutiveCheckboxes(t *testing.T) {
body := "- [ ] #1\n- [ ] #2\n- [ ] #3\n- [ ] #4\n- [ ] #5\n"
unchecked, checked := parseEpicChildren(body)
assert.Equal(t, []int{1, 2, 3, 4, 5}, unchecked)
assert.Nil(t, checked)
}
// --- findLinkedPR tests ---
func TestFindLinkedPR_Good_MultipleReferencesInBody(t *testing.T) {
prs := []*forgejosdk.PullRequest{
{Index: 10, Body: "Fixes #5 and relates to #7"},
{Index: 11, Body: "Closes #8"},
}
// Should find PR #10 because it references #7.
pr := findLinkedPR(prs, 7)
assert.NotNil(t, pr)
assert.Equal(t, int64(10), pr.Index)
// Should find PR #10 because it references #5.
pr = findLinkedPR(prs, 5)
assert.NotNil(t, pr)
assert.Equal(t, int64(10), pr.Index)
}
func TestFindLinkedPR_Good_EmptyBodyPR(t *testing.T) {
prs := []*forgejosdk.PullRequest{
{Index: 10, Body: ""},
{Index: 11, Body: "Fixes #7"},
}
pr := findLinkedPR(prs, 7)
assert.NotNil(t, pr)
assert.Equal(t, int64(11), pr.Index)
}
func TestFindLinkedPR_Good_FirstMatchWins(t *testing.T) {
// Both PRs reference #7, first one should win.
prs := []*forgejosdk.PullRequest{
{Index: 10, Body: "Fixes #7"},
{Index: 11, Body: "Also fixes #7"},
}
pr := findLinkedPR(prs, 7)
assert.NotNil(t, pr)
assert.Equal(t, int64(10), pr.Index)
}
func TestFindLinkedPR_Good_EmptySlice(t *testing.T) {
prs := []*forgejosdk.PullRequest{}
pr := findLinkedPR(prs, 1)
assert.Nil(t, pr)
}
// --- mapPRState edge case ---
func TestMapPRState_Good_MergedOverridesState(t *testing.T) {
// HasMerged=true should return MERGED regardless of State.
pr := &forgejosdk.PullRequest{State: forgejosdk.StateOpen, HasMerged: true}
assert.Equal(t, "MERGED", mapPRState(pr))
}
// --- mapCombinedStatus edge cases ---
func TestMapCombinedStatus_Good_WarningState(t *testing.T) {
// Unknown/warning state should default to PENDING.
cs := &forgejosdk.CombinedStatus{
State: forgejosdk.StatusWarning,
TotalCount: 1,
}
assert.Equal(t, "PENDING", mapCombinedStatus(cs))
}
// --- buildSignal edge cases ---
func TestBuildSignal_Good_ClosedPR(t *testing.T) {
pr := &forgejosdk.PullRequest{
Index: 5,
State: forgejosdk.StateClosed,
Mergeable: false,
HasMerged: false,
Head: &forgejosdk.PRBranchInfo{Sha: "abc"},
}
sig := buildSignal("org", "repo", 1, 2, pr, "FAILURE")
assert.Equal(t, "CLOSED", sig.PRState)
assert.Equal(t, "CONFLICTING", sig.Mergeable)
assert.Equal(t, "FAILURE", sig.CheckStatus)
assert.Equal(t, "abc", sig.LastCommitSHA)
}
func TestBuildSignal_Good_MergedPR(t *testing.T) {
pr := &forgejosdk.PullRequest{
Index: 99,
State: forgejosdk.StateClosed,
Mergeable: false,
HasMerged: true,
Head: &forgejosdk.PRBranchInfo{Sha: "merged123"},
}
sig := buildSignal("owner", "repo", 10, 5, pr, "SUCCESS")
assert.Equal(t, "MERGED", sig.PRState)
assert.Equal(t, "UNKNOWN", sig.Mergeable)
assert.Equal(t, 99, sig.PRNumber)
assert.Equal(t, "merged123", sig.LastCommitSHA)
}
// --- splitRepo edge cases ---
func TestSplitRepo_Bad_OnlySlash(t *testing.T) {
_, _, err := splitRepo("/")
assert.Error(t, err)
}
func TestSplitRepo_Bad_MultipleSlashes(t *testing.T) {
// Should take only the first part as owner, rest as repo.
owner, repo, err := splitRepo("a/b/c")
require.NoError(t, err)
assert.Equal(t, "a", owner)
assert.Equal(t, "b/c", repo)
}
// --- Poll with combined status failure ---
func TestForgejoSource_Poll_Good_CombinedStatusFailure(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": "- [ ] #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": "fail123", "ref": "feature", "label": "feature"},
},
}
_ = json.NewEncoder(w).Encode(prs)
case strings.Contains(path, "/status"):
status := map[string]any{
"state": "failure",
"total_count": 2,
"statuses": []map[string]any{{"status": "failure", "context": "ci"}},
}
_ = json.NewEncoder(w).Encode(status)
default:
w.WriteHeader(http.StatusNotFound)
}
})))
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, "FAILURE", signals[0].CheckStatus)
assert.Equal(t, "OPEN", signals[0].PRState)
assert.Equal(t, "MERGEABLE", signals[0].Mergeable)
}
// --- Poll with combined status error ---
func TestForgejoSource_Poll_Good_CombinedStatusError(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": false,
"merged": false,
"head": map[string]string{"sha": "err123", "ref": "fix", "label": "fix"},
},
}
_ = json.NewEncoder(w).Encode(prs)
// Combined status endpoint returns 500 — should fall back to PENDING.
case strings.Contains(path, "/status"):
w.WriteHeader(http.StatusInternalServerError)
default:
w.WriteHeader(http.StatusNotFound)
}
})))
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)
// Combined status API error -> falls back to PENDING.
assert.Equal(t, "PENDING", signals[0].CheckStatus)
assert.Equal(t, "CONFLICTING", signals[0].Mergeable)
}
// --- Poll with child that has no assignee (NeedsCoding path, no assignee) ---
func TestForgejoSource_Poll_Good_ChildNoAssignee(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/5"):
// Child issue with no assignee.
_ = json.NewEncoder(w).Encode(map[string]any{
"number": 5,
"title": "Unassigned task",
"body": "No one is working on this.",
"state": "open",
"assignees": []map[string]any{},
})
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"):
_ = 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)
// No signal should be emitted when child has no assignee and no PR.
assert.Empty(t, signals)
}
// --- Poll with child issue fetch failure ---
func TestForgejoSource_Poll_Good_ChildFetchFails(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/5"):
// Child issue fetch fails.
w.WriteHeader(http.StatusInternalServerError)
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"):
_ = 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)
// Child fetch error should be logged and skipped, not returned as error.
assert.Empty(t, signals)
}
// --- Poll with multiple epics ---
func TestForgejoSource_Poll_Good_MultipleEpics(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",
},
{
"number": 2,
"body": "- [ ] #4\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 #3",
"state": "open",
"mergeable": true,
"merged": false,
"head": map[string]string{"sha": "aaa", "ref": "f1", "label": "f1"},
},
{
"number": 11,
"body": "Fixes #4",
"state": "open",
"mergeable": true,
"merged": false,
"head": map[string]string{"sha": "bbb", "ref": "f2", "label": "f2"},
},
}
_ = 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, 2)
assert.Equal(t, 1, signals[0].EpicNumber)
assert.Equal(t, 3, signals[0].ChildNumber)
assert.Equal(t, 10, signals[0].PRNumber)
assert.Equal(t, 2, signals[1].EpicNumber)
assert.Equal(t, 4, signals[1].ChildNumber)
assert.Equal(t, 11, signals[1].PRNumber)
}
// --- Report with nil result ---
func TestForgejoSource_Report_Good_NilResult(t *testing.T) {
s := New(Config{}, nil)
err := s.Report(context.Background(), nil)
assert.NoError(t, err)
}
// --- Report constructs correct comment body ---
func TestForgejoSource_Report_Good_SuccessFormat(t *testing.T) {
var capturedPath string
var capturedBody string
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
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: "tick_parent",
RepoOwner: "core",
RepoName: "go-scm",
EpicNumber: 5,
ChildNumber: 10,
PRNumber: 20,
Success: true,
}
err := s.Report(context.Background(), result)
require.NoError(t, err)
// Comment should be on the epic issue.
assert.Contains(t, capturedPath, "/issues/5/comments")
assert.Contains(t, capturedBody, "tick_parent")
assert.Contains(t, capturedBody, "succeeded")
assert.Contains(t, capturedBody, "#10")
assert.Contains(t, capturedBody, "PR #20")
}
func TestForgejoSource_Report_Good_FailureWithError(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: "enable_auto_merge",
RepoOwner: "org",
RepoName: "repo",
EpicNumber: 1,
ChildNumber: 2,
PRNumber: 3,
Success: false,
Error: "merge conflict detected",
}
err := s.Report(context.Background(), result)
require.NoError(t, err)
assert.Contains(t, capturedBody, "failed")
assert.Contains(t, capturedBody, "merge conflict detected")
}
// --- Poll filters only epic-labelled issues ---
func TestForgejoSource_Poll_Good_MixedLabels(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": "- [ ] #2\n",
"labels": []map[string]string{{"name": "epic"}, {"name": "priority-high"}},
"state": "open",
},
{
"number": 3,
"body": "- [ ] #4\n",
"labels": []map[string]string{{"name": "bug"}},
"state": "open",
},
{
"number": 5,
"body": "- [ ] #6\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": "sha1", "ref": "f1", "label": "f1"},
},
{
"number": 11,
"body": "Fixes #4",
"state": "open",
"mergeable": true,
"merged": false,
"head": map[string]string{"sha": "sha2", "ref": "f2", "label": "f2"},
},
{
"number": 12,
"body": "Fixes #6",
"state": "open",
"mergeable": true,
"merged": false,
"head": map[string]string{"sha": "sha3", "ref": "f3", "label": "f3"},
},
}
_ = 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)
// Only issues #1 and #5 have the "epic" label.
require.Len(t, signals, 2)
assert.Equal(t, 1, signals[0].EpicNumber)
assert.Equal(t, 2, signals[0].ChildNumber)
assert.Equal(t, 5, signals[1].EpicNumber)
assert.Equal(t, 6, signals[1].ChildNumber)
}
// --- Poll with PRs error after issues succeed ---
func TestForgejoSource_Poll_Good_PRsAPIError(t *testing.T) {
callCount := 0
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
w.Header().Set("Content-Type", "application/json")
callCount++
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"):
w.WriteHeader(http.StatusInternalServerError)
default:
w.WriteHeader(http.StatusOK)
}
})))
defer srv.Close()
client, err := forge.New(srv.URL, "test-token")
require.NoError(t, err)
s := New(Config{Repos: []string{"org/repo"}}, client)
signals, err := s.Poll(context.Background())
require.NoError(t, err)
// PR API failure -> repo is skipped, no signals.
assert.Empty(t, signals)
}
// --- New creates source correctly ---
func TestForgejoSource_New_Good(t *testing.T) {
s := New(Config{Repos: []string{"a/b", "c/d"}}, nil)
assert.Equal(t, "forgejo", s.Name())
assert.Equal(t, []string{"a/b", "c/d"}, s.repos)
}