- 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.
306 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|