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>
320 lines
8.4 KiB
Go
320 lines
8.4 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"
|
|
|
|
"forge.lthn.ai/core/go-agent/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")
|
|
}
|