forgejo-mcp/operation/pull/resources_test.go
Christoph Görn dd9116fbca
fix: 🚨 clear golangci-lint v2 backlog (closes bead forgejo-mcp-hc9)
- errorlint: fmt.Errorf %v→%w across all handlers (errors now wrap the
  underlying SDK/HTTP error for errors.Is/As); errors.Is for
  context.Canceled in cmd
- staticcheck quickfixes: Fprintf over WriteString(Sprintf), tagged
  switches, omit inferred 'any'; QF1001 resolved by hoisting hex checks
  into isHex predicates (normalizeColor, validateSHA)
- govet: reflect.Ptr → reflect.Pointer in pkg/ptr
- errcheck: explicit '_ =' for fs.Parse (ExitOnError) and deferred
  zap Sync
- repair four test assertions the autofix broke (it dropped the
  're.Code != X' half of combined type-assert checks, inverting the
  test); all errors.As sites use 'var re *resource.ResourceError'

golangci-lint: 0 issues. staticcheck, vet, tests: clean.
2026-06-11 00:03:36 +02:00

306 lines
8.6 KiB
Go

package pull
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/goern/forgejo-mcp/v2/operation/resource"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
)
// prRoutingHandler routes /pulls/{index}, /issues/{index}/comments, and /pulls/{index}/reviews.
type prRoutingHandler struct {
prStatus int
prBody interface{}
commentsStatus int
commentsBody interface{}
reviewsStatus int
reviewsBody interface{}
}
func (h *prRoutingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
path := r.URL.Path
switch {
case strings.Contains(path, "/reviews"):
w.WriteHeader(h.reviewsStatus)
if h.reviewsBody != nil {
json.NewEncoder(w).Encode(h.reviewsBody)
}
case strings.Contains(path, "/issues/") && strings.Contains(path, "/comments"):
w.WriteHeader(h.commentsStatus)
if h.commentsBody != nil {
json.NewEncoder(w).Encode(h.commentsBody)
}
case strings.Contains(path, "/pulls/"):
w.WriteHeader(h.prStatus)
if h.prBody != nil {
json.NewEncoder(w).Encode(h.prBody)
}
default:
w.WriteHeader(http.StatusNotFound)
}
}
func setupPRMockServer(t *testing.T, h *prRoutingHandler) *httptest.Server {
t.Helper()
srv := httptest.NewServer(h)
client, err := forgejo_sdk.NewClient(srv.URL, forgejo_sdk.SetForgejoVersion("7.0.0"))
if err != nil {
t.Fatalf("creating test client: %v", err)
}
forgejo.SetClientForTesting(client)
return srv
}
func makePRResourceRequest(index int) mcp.ReadResourceRequest {
return mcp.ReadResourceRequest{
Params: mcp.ReadResourceParams{
URI: fmt.Sprintf("forgejo://repo/testowner/testrepo/pr/%d", index),
},
}
}
func fakePR(merged bool) map[string]interface{} {
now := time.Now().Format(time.RFC3339)
pr := map[string]interface{}{
"id": 1,
"number": 7,
"title": "feat: add resources",
"body": "This PR adds MCP resource support.",
"state": "open",
"merged": merged,
"mergeable": true,
"comments": 2,
"user": map[string]interface{}{"login": "alice"},
"labels": []interface{}{},
"assignees": []interface{}{},
"created_at": now,
"updated_at": now,
"head": map[string]interface{}{
"label": "alice:feature",
"ref": "feature",
"sha": "abc123",
},
"base": map[string]interface{}{
"label": "main",
"ref": "main",
"sha": "def456",
},
}
if merged {
pr["state"] = "closed"
pr["merged_at"] = now
}
return pr
}
func fakeReviews(n int) []map[string]interface{} {
result := make([]map[string]interface{}, n)
for i := range result {
result[i] = map[string]interface{}{
"id": i + 1,
"state": "approved",
"body": fmt.Sprintf("review body %d", i+1),
"user": map[string]interface{}{"login": "reviewer"},
"submitted_at": time.Now().Format(time.RFC3339),
}
}
return result
}
func TestPRResourceHandler_OpenPR_HappyPath(t *testing.T) {
h := &prRoutingHandler{
prStatus: http.StatusOK,
prBody: fakePR(false),
commentsStatus: http.StatusOK,
commentsBody: fakeReviews(2), // reuse shape; comments just need id/body/user/created_at
reviewsStatus: http.StatusOK,
reviewsBody: fakeReviews(1),
}
srv := setupPRMockServer(t, h)
defer srv.Close()
req := makePRResourceRequest(7)
contents, err := prResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(contents) != 2 {
t.Fatalf("expected 2 content blocks, got %d", len(contents))
}
block := contents[0].(mcp.TextResourceContents)
if block.MIMEType != "application/json" {
t.Errorf("MIME: got %q, want application/json", block.MIMEType)
}
var payload prResourcePayload
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if payload.State != "open" {
t.Errorf("expected state=open, got %q", payload.State)
}
if !payload.Mergeable {
t.Error("expected mergeable=true")
}
if payload.CommentsTruncated {
t.Error("expected comments not truncated")
}
mdBlock := contents[1].(mcp.TextResourceContents)
if mdBlock.MIMEType != "text/markdown" {
t.Errorf("sidecar MIME: got %q, want text/markdown", mdBlock.MIMEType)
}
if !strings.Contains(mdBlock.Text, "feat: add resources") {
t.Errorf("sidecar missing title, got %q", mdBlock.Text)
}
}
func TestPRResourceHandler_MergedPR(t *testing.T) {
h := &prRoutingHandler{
prStatus: http.StatusOK,
prBody: fakePR(true),
commentsStatus: http.StatusOK,
commentsBody: []interface{}{},
reviewsStatus: http.StatusOK,
reviewsBody: []interface{}{},
}
srv := setupPRMockServer(t, h)
defer srv.Close()
req := makePRResourceRequest(7)
contents, err := prResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var payload prResourcePayload
json.Unmarshal([]byte(contents[0].(mcp.TextResourceContents).Text), &payload)
if payload.State != "merged" {
t.Errorf("expected state=merged, got %q", payload.State)
}
if payload.MergedAt == "" {
t.Error("expected merged_at to be set")
}
}
func TestPRResourceHandler_OverCapComments(t *testing.T) {
comments := make([]map[string]interface{}, resource.EmbeddedListCap+1)
for i := range comments {
comments[i] = map[string]interface{}{
"id": i + 1,
"body": "comment",
"user": map[string]interface{}{"login": "bob"},
"created_at": time.Now().Format(time.RFC3339),
"updated_at": time.Now().Format(time.RFC3339),
}
}
h := &prRoutingHandler{
prStatus: http.StatusOK,
prBody: fakePR(false),
commentsStatus: http.StatusOK,
commentsBody: comments,
reviewsStatus: http.StatusOK,
reviewsBody: []interface{}{},
}
srv := setupPRMockServer(t, h)
defer srv.Close()
req := makePRResourceRequest(7)
contents, err := prResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var payload prResourcePayload
json.Unmarshal([]byte(contents[0].(mcp.TextResourceContents).Text), &payload)
if !payload.CommentsTruncated {
t.Error("expected comments_truncated=true")
}
if payload.CommentsListTool != "list_issue_comments" {
t.Errorf("expected comments_list_tool=list_issue_comments, got %q", payload.CommentsListTool)
}
if len(payload.RecentComments) != resource.EmbeddedListCap {
t.Errorf("expected %d capped comments, got %d", resource.EmbeddedListCap, len(payload.RecentComments))
}
}
func TestPRResourceHandler_OverCapReviews(t *testing.T) {
h := &prRoutingHandler{
prStatus: http.StatusOK,
prBody: fakePR(false),
commentsStatus: http.StatusOK,
commentsBody: []interface{}{},
reviewsStatus: http.StatusOK,
reviewsBody: fakeReviews(resource.EmbeddedListCap + 1),
}
srv := setupPRMockServer(t, h)
defer srv.Close()
req := makePRResourceRequest(7)
contents, err := prResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var payload prResourcePayload
json.Unmarshal([]byte(contents[0].(mcp.TextResourceContents).Text), &payload)
if !payload.ReviewsTruncated {
t.Error("expected reviews_truncated=true")
}
if payload.ReviewsListTool != "list_pull_reviews" {
t.Errorf("expected reviews_list_tool=list_pull_reviews, got %q", payload.ReviewsListTool)
}
if len(payload.RecentReviews) != resource.EmbeddedListCap {
t.Errorf("expected %d capped reviews, got %d", resource.EmbeddedListCap, len(payload.RecentReviews))
}
}
func TestPRResourceHandler_NonNumericIndex(t *testing.T) {
req := mcp.ReadResourceRequest{
Params: mcp.ReadResourceParams{
URI: "forgejo://repo/testowner/testrepo/pr/abc",
},
}
_, err := prResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error for non-numeric index")
}
var re *resource.ResourceError
if !errors.As(err, &re) {
t.Fatalf("expected *resource.ResourceError, got %T", err)
}
if re.Code != -32602 {
t.Errorf("expected -32602, got %d", re.Code)
}
}
func TestPRResourceHandler_NotFound(t *testing.T) {
h := &prRoutingHandler{
prStatus: http.StatusNotFound,
prBody: map[string]string{"message": "not found"},
}
srv := setupPRMockServer(t, h)
defer srv.Close()
req := makePRResourceRequest(999)
_, err := prResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error for 404")
}
var re *resource.ResourceError
if !errors.As(err, &re) {
t.Fatalf("expected *resource.ResourceError, got %T", err)
}
if re.Code != -32003 {
t.Errorf("expected -32003, got %d", re.Code)
}
}