463 lines
13 KiB
Go
463 lines
13 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
mcpgo "github.com/mark3labs/mcp-go/server"
|
|
|
|
"gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker"
|
|
)
|
|
|
|
const repoJSON = `{"id":7,"forge_remote_id":"101","name":"demo","owner":"me","full_name":"me/demo","default_branch":"main","private":false,"trusted":true,"active":true,"config_file":".woodpecker.yml","timeout":60,"forge_id":1}`
|
|
|
|
const pipelineJSON = `{
|
|
"id":1,"number":42,"status":"failure","event":"push",
|
|
"commit":"abc123","branch":"main","ref":"refs/heads/main",
|
|
"message":"fix: nope","author":"me","author_email":"me@example.com",
|
|
"created_at":10,"updated_at":11,"started_at":12,"finished_at":13,
|
|
"workflows":[
|
|
{"id":1,"name":"build","state":"failure","start_time":12,"end_time":13,"pipeline_id":1,
|
|
"children":[
|
|
{"id":11,"name":"build","uuid":"u1","type":"commands","state":"failure","error":"exit code 1","exit_code":1,"start_time":12,"end_time":13,"pipeline_id":1},
|
|
{"id":12,"name":"publish","uuid":"u2","type":"plugin","state":"skipped","pipeline_id":1}
|
|
]}
|
|
]
|
|
}`
|
|
|
|
// newFakeClient spins up a fake Woodpecker API backed by mux and returns a
|
|
// client pointed at it.
|
|
func newFakeClient(t *testing.T, mux *http.ServeMux) *woodpecker.Client {
|
|
t.Helper()
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
c, err := woodpecker.NewClient(srv.URL+"/api", "test-token", woodpecker.WithHTTPClient(srv.Client()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// call invokes a tool handler with the given arguments.
|
|
func call(t *testing.T, h mcpgo.ToolHandlerFunc, args map[string]any) *mcp.CallToolResult {
|
|
t.Helper()
|
|
res, err := h(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: args}})
|
|
if err != nil {
|
|
t.Fatalf("handler returned error: %v", err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func resultText(t *testing.T, res *mcp.CallToolResult) string {
|
|
t.Helper()
|
|
if res == nil || len(res.Content) == 0 {
|
|
t.Fatal("result has no content")
|
|
}
|
|
tc, ok := res.Content[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatalf("unexpected content type %T", res.Content[0])
|
|
}
|
|
return tc.Text
|
|
}
|
|
|
|
func decodeResult[T any](t *testing.T, res *mcp.CallToolResult) T {
|
|
t.Helper()
|
|
var out T
|
|
if err := json.Unmarshal([]byte(resultText(t, res)), &out); err != nil {
|
|
t.Fatalf("result is not valid JSON: %v\n%s", err, resultText(t, res))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func assertQuery(t *testing.T, r *http.Request, key, want string) {
|
|
t.Helper()
|
|
if got := r.URL.Query().Get(key); got != want {
|
|
t.Errorf("query %q = %q, want %q", key, got, want)
|
|
}
|
|
}
|
|
|
|
// --- search_repository ---
|
|
|
|
func TestSearchRepositorySuccess(t *testing.T) {
|
|
var gotPath string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/lookup/", func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.EscapedPath()
|
|
_, _ = w.Write([]byte(repoJSON))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := searchRepositoryTool(c)
|
|
res := call(t, h, map[string]any{"name": "me/demo"})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
if gotPath != "/api/repos/lookup/me%2Fdemo" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["name"] != "me/demo" {
|
|
t.Errorf("name = %v", out["name"])
|
|
}
|
|
repo, _ := out["repository"].(map[string]any)
|
|
if repo == nil {
|
|
t.Fatal("missing repository field")
|
|
}
|
|
if repo["full_name"] != "me/demo" || repo["id"] != float64(7) {
|
|
t.Errorf("repository = %v", repo)
|
|
}
|
|
if repo["default_branch"] != "main" {
|
|
t.Errorf("default_branch = %v", repo["default_branch"])
|
|
}
|
|
}
|
|
|
|
func TestSearchRepositoryInvalidName(t *testing.T) {
|
|
c := newFakeClient(t, http.NewServeMux())
|
|
_, h := searchRepositoryTool(c)
|
|
|
|
for _, name := range []string{"no-slash", "owner/repo/extra", "space name/also", ""} {
|
|
res := call(t, h, map[string]any{"name": name})
|
|
if !res.IsError {
|
|
t.Errorf("name %q: expected error result", name)
|
|
}
|
|
text := resultText(t, res)
|
|
if !strings.Contains(text, "Invalid repository name format") {
|
|
t.Errorf("name %q: error text %q", name, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSearchRepositoryNotFound(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/lookup/", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":"repository not found"}`))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := searchRepositoryTool(c)
|
|
res := call(t, h, map[string]any{"name": "me/missing"})
|
|
|
|
if !res.IsError {
|
|
t.Fatal("expected error result")
|
|
}
|
|
text := resultText(t, res)
|
|
if !strings.Contains(text, `"error":"NOT_FOUND"`) {
|
|
t.Errorf("expected NOT_FOUND context, got %s", text)
|
|
}
|
|
if !strings.Contains(text, "repository lookup for me/missing") {
|
|
t.Errorf("expected operation context, got %s", text)
|
|
}
|
|
}
|
|
|
|
// --- get_repository_by_id ---
|
|
|
|
func TestGetRepositoryByID(t *testing.T) {
|
|
var gotPath string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/", func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
_, _ = w.Write([]byte(repoJSON))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := getRepositoryByIDTool(c)
|
|
res := call(t, h, map[string]any{"repoId": float64(7)})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
if gotPath != "/api/repos/7" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["repoId"] != float64(7) {
|
|
t.Errorf("repoId = %v", out["repoId"])
|
|
}
|
|
}
|
|
|
|
func TestGetRepositoryByIDInvalid(t *testing.T) {
|
|
c := newFakeClient(t, http.NewServeMux())
|
|
_, h := getRepositoryByIDTool(c)
|
|
res := call(t, h, map[string]any{"repoId": float64(0)})
|
|
if !res.IsError {
|
|
t.Fatal("expected error result for repoId 0")
|
|
}
|
|
}
|
|
|
|
// --- list_repositories ---
|
|
|
|
func TestListRepositoriesParamsAndCount(t *testing.T) {
|
|
var req *http.Request
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos", func(w http.ResponseWriter, r *http.Request) {
|
|
req = r.Clone(r.Context())
|
|
_, _ = w.Write([]byte("[" + repoJSON + "," + repoJSON + "]"))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := listRepositoriesTool(c)
|
|
res := call(t, h, map[string]any{
|
|
"page": float64(2),
|
|
"perPage": float64(10),
|
|
"active": true,
|
|
})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
assertQuery(t, req, "page", "2")
|
|
assertQuery(t, req, "perPage", "10")
|
|
assertQuery(t, req, "active", "true")
|
|
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["count"] != float64(2) {
|
|
t.Errorf("count = %v", out["count"])
|
|
}
|
|
}
|
|
|
|
func TestListRepositoriesDefaults(t *testing.T) {
|
|
var req *http.Request
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos", func(w http.ResponseWriter, r *http.Request) {
|
|
req = r.Clone(r.Context())
|
|
_, _ = w.Write([]byte("[]"))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := listRepositoriesTool(c)
|
|
res := call(t, h, map[string]any{})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
assertQuery(t, req, "page", "1")
|
|
assertQuery(t, req, "perPage", "50")
|
|
if _, ok := req.URL.Query()["active"]; ok {
|
|
t.Error("active should be omitted when not provided")
|
|
}
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["count"] != float64(0) {
|
|
t.Errorf("count = %v", out["count"])
|
|
}
|
|
}
|
|
|
|
func TestListRepositoriesRejectsBadPerPage(t *testing.T) {
|
|
c := newFakeClient(t, http.NewServeMux())
|
|
_, h := listRepositoriesTool(c)
|
|
res := call(t, h, map[string]any{"perPage": float64(200)})
|
|
if !res.IsError {
|
|
t.Fatal("expected error result for perPage 200")
|
|
}
|
|
if !strings.Contains(resultText(t, res), "between") {
|
|
t.Errorf("error text %q", resultText(t, res))
|
|
}
|
|
}
|
|
|
|
// --- list_pipelines ---
|
|
|
|
func TestListPipelines(t *testing.T) {
|
|
var req *http.Request
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/1/pipelines", func(w http.ResponseWriter, r *http.Request) {
|
|
req = r.Clone(r.Context())
|
|
_, _ = w.Write([]byte("[" + pipelineJSON + "]"))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := listPipelinesTool(c)
|
|
res := call(t, h, map[string]any{
|
|
"repoId": float64(1),
|
|
"before": "2023-12-01T10:00:00Z",
|
|
"after": "2023-11-01T10:00:00Z",
|
|
"perPage": float64(5),
|
|
})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
assertQuery(t, req, "page", "1")
|
|
assertQuery(t, req, "perPage", "5")
|
|
assertQuery(t, req, "before", "2023-12-01T10:00:00Z")
|
|
assertQuery(t, req, "after", "2023-11-01T10:00:00Z")
|
|
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["repoId"] != float64(1) || out["count"] != float64(1) {
|
|
t.Errorf("out = %v", out)
|
|
}
|
|
pipelines, _ := out["pipelines"].([]any)
|
|
first, _ := pipelines[0].(map[string]any)
|
|
if first["number"] != float64(42) || first["status"] != "failure" {
|
|
t.Errorf("first pipeline = %v", first)
|
|
}
|
|
}
|
|
|
|
// --- get_pipeline ---
|
|
|
|
func TestGetPipelineWorkflowsAndSteps(t *testing.T) {
|
|
var gotPath string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/1/pipelines/42", func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
_, _ = w.Write([]byte(pipelineJSON))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := getPipelineTool(c)
|
|
res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42)})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
if gotPath != "/api/repos/1/pipelines/42" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["pipelineNumber"] != float64(42) {
|
|
t.Errorf("pipelineNumber = %v", out["pipelineNumber"])
|
|
}
|
|
p, _ := out["pipeline"].(map[string]any)
|
|
if p["email"] != "me@example.com" {
|
|
t.Errorf("email = %v", p["email"])
|
|
}
|
|
workflows, _ := p["workflows"].([]any)
|
|
wf, _ := workflows[0].(map[string]any)
|
|
if wf["name"] != "build" {
|
|
t.Errorf("workflow = %v", wf)
|
|
}
|
|
children, _ := wf["children"].([]any)
|
|
step, _ := children[0].(map[string]any)
|
|
if step["exitCode"] != float64(1) || step["error"] != "exit code 1" {
|
|
t.Errorf("step = %v", step)
|
|
}
|
|
if _, hasError := wf["error"]; hasError {
|
|
t.Error("error should be omitted when null in the API response")
|
|
}
|
|
}
|
|
|
|
func TestGetPipelineMissingNumber(t *testing.T) {
|
|
c := newFakeClient(t, http.NewServeMux())
|
|
_, h := getPipelineTool(c)
|
|
res := call(t, h, map[string]any{"repoId": float64(1)})
|
|
if !res.IsError {
|
|
t.Fatal("expected error result")
|
|
}
|
|
}
|
|
|
|
// --- get_pipeline_config ---
|
|
|
|
func TestGetPipelineConfigDecodesBase64(t *testing.T) {
|
|
configData := base64.StdEncoding.EncodeToString([]byte("pipeline:\n build:\n image: alpine"))
|
|
payload := fmt.Sprintf(`[{"name":"ci/.woodpecker.yml","data":%q,"hash":"abc123"}]`, configData)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/1/pipelines/42/config", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(payload))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := getPipelineConfigTool(c)
|
|
res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42)})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["count"] != float64(1) {
|
|
t.Errorf("count = %v", out["count"])
|
|
}
|
|
configs, _ := out["configs"].([]any)
|
|
cfg, _ := configs[0].(map[string]any)
|
|
if cfg["content"] != "pipeline:\n build:\n image: alpine" {
|
|
t.Errorf("content = %q", cfg["content"])
|
|
}
|
|
if cfg["hash"] != "abc123" || cfg["name"] != "ci/.woodpecker.yml" {
|
|
t.Errorf("cfg = %v", cfg)
|
|
}
|
|
}
|
|
|
|
func TestGetPipelineConfigInvalidBase64FallsBack(t *testing.T) {
|
|
payload := `[{"name":"bad.woodpecker.yml","data":"not!base64!","hash":"h"}]`
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/1/pipelines/1/config", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(payload))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := getPipelineConfigTool(c)
|
|
res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(1)})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
out := decodeResult[map[string]any](t, res)
|
|
configs, _ := out["configs"].([]any)
|
|
cfg, _ := configs[0].(map[string]any)
|
|
if cfg["content"] != "not!base64!" {
|
|
t.Errorf("content = %q, want raw fallback", cfg["content"])
|
|
}
|
|
}
|
|
|
|
// --- get_step_logs ---
|
|
|
|
func TestGetStepLogsDecodesAndJoins(t *testing.T) {
|
|
lineOne := base64.StdEncoding.EncodeToString([]byte("line one"))
|
|
lineThree := base64.StdEncoding.EncodeToString([]byte("line three"))
|
|
payload := fmt.Sprintf(
|
|
`[{"id":1,"step_id":11,"time":1,"line":1,"data":%q,"type":0},`+
|
|
`{"id":2,"step_id":11,"time":2,"line":2,"data":null,"type":0},`+
|
|
`{"id":3,"step_id":11,"time":3,"line":3,"data":%q,"type":1}]`,
|
|
lineOne, lineThree,
|
|
)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/1/logs/42/11", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(payload))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := getStepLogsTool(c)
|
|
res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42), "stepId": float64(11)})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["stepId"] != float64(11) {
|
|
t.Errorf("stepId = %v", out["stepId"])
|
|
}
|
|
if out["logs"] != "line one\n\nline three" {
|
|
t.Errorf("logs = %q", out["logs"])
|
|
}
|
|
}
|
|
|
|
func TestGetStepLogsNullBody(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/repos/1/logs/42/11", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("null"))
|
|
})
|
|
c := newFakeClient(t, mux)
|
|
|
|
_, h := getStepLogsTool(c)
|
|
res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42), "stepId": float64(11)})
|
|
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", resultText(t, res))
|
|
}
|
|
out := decodeResult[map[string]any](t, res)
|
|
if out["logs"] != noLogsMessage {
|
|
t.Errorf("logs = %q, want no-logs message", out["logs"])
|
|
}
|
|
}
|