348 lines
11 KiB
Go
348 lines
11 KiB
Go
package issue
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/flag"
|
|
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
|
|
|
|
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
type recordedReq struct {
|
|
method string
|
|
path string
|
|
rawBody []byte
|
|
}
|
|
|
|
func newPatchBackend(t *testing.T, respBody string) (*httptest.Server, *[]recordedReq) {
|
|
t.Helper()
|
|
records := make([]recordedReq, 0, 2)
|
|
// Serve the SDK's startup version probe so NewClient succeeds.
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"version":"11.0.0+gitea-1.22.0"}`))
|
|
})
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
records = append(records, recordedReq{
|
|
method: r.Method,
|
|
path: r.URL.Path,
|
|
rawBody: body,
|
|
})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(respBody))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
flag.URL = srv.URL
|
|
flag.Token = "tkn"
|
|
flag.UserAgent = "test"
|
|
|
|
c, err := forgejo_sdk.NewClient(srv.URL,
|
|
forgejo_sdk.SetToken("tkn"),
|
|
forgejo_sdk.SetUserAgent("test"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to build SDK client for test: %v", err)
|
|
}
|
|
forgejo.SetClientForTesting(c)
|
|
return srv, &records
|
|
}
|
|
|
|
func makeReq(args map[string]any) mcp.CallToolRequest {
|
|
return mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: args}}
|
|
}
|
|
|
|
func TestUpdateIssue_AssigneeSingular(t *testing.T) {
|
|
_, records := newPatchBackend(t, `{"id":1,"number":42}`)
|
|
|
|
res, err := UpdateIssueFn(context.Background(), makeReq(map[string]any{
|
|
"owner": "goern",
|
|
"repo": "forgejo-mcp",
|
|
"index": float64(42),
|
|
"assignee": "goern",
|
|
}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("UpdateIssueFn returned error: err=%v res=%+v", err, res)
|
|
}
|
|
|
|
if len(*records) == 0 {
|
|
t.Fatal("expected at least one HTTP request to backend")
|
|
}
|
|
last := (*records)[len(*records)-1]
|
|
if last.method != http.MethodPatch {
|
|
t.Fatalf("expected PATCH, got %s", last.method)
|
|
}
|
|
if !strings.Contains(last.path, "/issues/42") {
|
|
t.Fatalf("unexpected path: %s", last.path)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(last.rawBody, &payload); err != nil {
|
|
t.Fatalf("invalid JSON body: %v\nbody: %s", err, last.rawBody)
|
|
}
|
|
assignees, ok := payload["assignees"].([]any)
|
|
if !ok {
|
|
t.Fatalf("assignees field missing or wrong type: %T %v", payload["assignees"], payload["assignees"])
|
|
}
|
|
if len(assignees) != 1 || assignees[0] != "goern" {
|
|
t.Fatalf("expected assignees=[goern], got %v", assignees)
|
|
}
|
|
}
|
|
|
|
func TestUpdateIssue_AssigneesCSV(t *testing.T) {
|
|
_, records := newPatchBackend(t, `{"id":1,"number":42}`)
|
|
|
|
res, err := UpdateIssueFn(context.Background(), makeReq(map[string]any{
|
|
"owner": "goern",
|
|
"repo": "forgejo-mcp",
|
|
"index": float64(42),
|
|
"assignees": "alice, bob ,carol",
|
|
}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("UpdateIssueFn returned error: err=%v res=%+v", err, res)
|
|
}
|
|
|
|
last := (*records)[len(*records)-1]
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(last.rawBody, &payload); err != nil {
|
|
t.Fatalf("invalid JSON body: %v\nbody: %s", err, last.rawBody)
|
|
}
|
|
assignees, _ := payload["assignees"].([]any)
|
|
want := []string{"alice", "bob", "carol"}
|
|
if len(assignees) != len(want) {
|
|
t.Fatalf("len mismatch: got %v want %v", assignees, want)
|
|
}
|
|
for i, v := range want {
|
|
if assignees[i] != v {
|
|
t.Fatalf("assignees[%d]: got %v want %s", i, assignees[i], v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateIssue_AssigneesEmptyClears(t *testing.T) {
|
|
_, records := newPatchBackend(t, `{"id":1,"number":42}`)
|
|
|
|
res, err := UpdateIssueFn(context.Background(), makeReq(map[string]any{
|
|
"owner": "goern",
|
|
"repo": "forgejo-mcp",
|
|
"index": float64(42),
|
|
"assignees": "",
|
|
"assignee": "ignored-because-assignees-wins",
|
|
}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("UpdateIssueFn returned error: err=%v res=%+v", err, res)
|
|
}
|
|
|
|
last := (*records)[len(*records)-1]
|
|
if !strings.Contains(string(last.rawBody), `"assignees":[]`) {
|
|
t.Fatalf("expected empty assignees array in body, got: %s", last.rawBody)
|
|
}
|
|
}
|
|
|
|
// newLabelsBackend serves /api/v1/repos/{owner}/{repo}/labels and
|
|
// /api/v1/orgs/{owner}/labels with caller-supplied status codes and bodies.
|
|
// Caller passes maps keyed by exact path. Status defaults to 200 if 0.
|
|
func newLabelsBackend(
|
|
t *testing.T,
|
|
repoLabelsBody string, repoStatus int,
|
|
orgLabelsBody string, orgStatus int,
|
|
) (*httptest.Server, *[]recordedReq) {
|
|
t.Helper()
|
|
records := make([]recordedReq, 0, 4)
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"version":"11.0.0+gitea-1.22.0"}`))
|
|
})
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
records = append(records, recordedReq{method: r.Method, path: r.URL.Path, rawBody: body})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case strings.HasPrefix(r.URL.Path, "/api/v1/repos/") && strings.HasSuffix(r.URL.Path, "/labels"):
|
|
if repoStatus == 0 {
|
|
repoStatus = http.StatusOK
|
|
}
|
|
w.WriteHeader(repoStatus)
|
|
_, _ = w.Write([]byte(repoLabelsBody))
|
|
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/") && strings.HasSuffix(r.URL.Path, "/labels"):
|
|
if orgStatus == 0 {
|
|
orgStatus = http.StatusOK
|
|
}
|
|
w.WriteHeader(orgStatus)
|
|
_, _ = w.Write([]byte(orgLabelsBody))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
flag.URL = srv.URL
|
|
flag.Token = "tkn"
|
|
flag.UserAgent = "test"
|
|
|
|
c, err := forgejo_sdk.NewClient(srv.URL,
|
|
forgejo_sdk.SetToken("tkn"),
|
|
forgejo_sdk.SetUserAgent("test"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to build SDK client for test: %v", err)
|
|
}
|
|
forgejo.SetClientForTesting(c)
|
|
return srv, &records
|
|
}
|
|
|
|
func TestListOrgLabels_Success(t *testing.T) {
|
|
_, records := newLabelsBackend(t,
|
|
"", 0,
|
|
`[{"id":1,"name":"bug","color":"ff0000","description":"a bug","url":""},{"id":2,"name":"enh","color":"00ff00","description":"","url":""}]`, http.StatusOK,
|
|
)
|
|
res, err := ListOrgLabelsFn(context.Background(), makeReq(map[string]any{"org": "codeberg"}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("ListOrgLabelsFn err: %v res=%+v", err, res)
|
|
}
|
|
if len(*records) == 0 {
|
|
t.Fatal("expected request to backend")
|
|
}
|
|
last := (*records)[len(*records)-1]
|
|
if last.method != http.MethodGet {
|
|
t.Fatalf("expected GET, got %s", last.method)
|
|
}
|
|
if !strings.HasPrefix(last.path, "/api/v1/orgs/codeberg/labels") {
|
|
t.Fatalf("unexpected path: %s", last.path)
|
|
}
|
|
// Result body should serialize each entry with scope=org.
|
|
if !strings.Contains(textOf(res), `"scope":"org"`) {
|
|
t.Fatalf("expected scope=org in result, got %q", textOf(res))
|
|
}
|
|
}
|
|
|
|
func TestListOrgLabels_404IsEmpty(t *testing.T) {
|
|
_, _ = newLabelsBackend(t, "", 0, ``, http.StatusNotFound)
|
|
res, err := ListOrgLabelsFn(context.Background(), makeReq(map[string]any{"org": "ghost"}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("expected success on 404, got err=%v res=%+v", err, res)
|
|
}
|
|
if !strings.Contains(textOf(res), "[]") && !strings.Contains(textOf(res), "null") {
|
|
t.Fatalf("expected empty result for 404, got %q", textOf(res))
|
|
}
|
|
}
|
|
|
|
func TestListOrgLabels_UnauthorizedSurfaces(t *testing.T) {
|
|
_, _ = newLabelsBackend(t, "", 0, ``, http.StatusUnauthorized)
|
|
_, err := ListOrgLabelsFn(context.Background(), makeReq(map[string]any{"org": "codeberg"}))
|
|
if err == nil {
|
|
t.Fatal("expected error on 401, got nil")
|
|
}
|
|
}
|
|
|
|
func TestListRepoLabels_MergeWithOrgLabels(t *testing.T) {
|
|
_, _ = newLabelsBackend(t,
|
|
`[{"id":10,"name":"good-first-issue","color":"7057ff","description":"","url":""}]`, http.StatusOK,
|
|
`[{"id":20,"name":"security","color":"d73a4a","description":"","url":""}]`, http.StatusOK,
|
|
)
|
|
res, err := ListRepoLabelsFn(context.Background(), makeReq(map[string]any{
|
|
"owner": "codeberg-org", "repo": "project",
|
|
}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("ListRepoLabelsFn err: %v res=%+v", err, res)
|
|
}
|
|
out := textOf(res)
|
|
if !strings.Contains(out, `"scope":"repo"`) || !strings.Contains(out, `"scope":"org"`) {
|
|
t.Fatalf("expected both scopes in result, got %q", out)
|
|
}
|
|
if !strings.Contains(out, `"good-first-issue"`) || !strings.Contains(out, `"security"`) {
|
|
t.Fatalf("expected both label names in merged result, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestListRepoLabels_IncludeOrgFalseSkipsOrgCall(t *testing.T) {
|
|
_, records := newLabelsBackend(t,
|
|
`[{"id":10,"name":"good-first-issue","color":"7057ff","description":"","url":""}]`, http.StatusOK,
|
|
`[{"id":20,"name":"security","color":"d73a4a","description":"","url":""}]`, http.StatusOK,
|
|
)
|
|
res, err := ListRepoLabelsFn(context.Background(), makeReq(map[string]any{
|
|
"owner": "codeberg-org",
|
|
"repo": "project",
|
|
"include_org_labels": false,
|
|
}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("ListRepoLabelsFn err: %v res=%+v", err, res)
|
|
}
|
|
for _, r := range *records {
|
|
if strings.HasPrefix(r.path, "/api/v1/orgs/") {
|
|
t.Fatalf("did not expect org-labels request, got %s %s", r.method, r.path)
|
|
}
|
|
}
|
|
out := textOf(res)
|
|
if strings.Contains(out, `"scope":"org"`) || strings.Contains(out, `"security"`) {
|
|
t.Fatalf("expected no org-scoped labels in result, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestListRepoLabels_UserOwnerOrgEndpoint404(t *testing.T) {
|
|
_, _ = newLabelsBackend(t,
|
|
`[{"id":10,"name":"bug","color":"ff0000","description":"","url":""}]`, http.StatusOK,
|
|
``, http.StatusNotFound,
|
|
)
|
|
res, err := ListRepoLabelsFn(context.Background(), makeReq(map[string]any{
|
|
"owner": "alice", "repo": "project",
|
|
}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("expected success when org endpoint 404s, got err=%v res=%+v", err, res)
|
|
}
|
|
out := textOf(res)
|
|
if !strings.Contains(out, `"scope":"repo"`) {
|
|
t.Fatalf("expected repo-scoped label in result, got %q", out)
|
|
}
|
|
if strings.Contains(out, `"scope":"org"`) {
|
|
t.Fatalf("expected no org-scoped labels when org endpoint 404s, got %q", out)
|
|
}
|
|
}
|
|
|
|
// textOf flattens a CallToolResult into a single string for substring assertions.
|
|
func textOf(res *mcp.CallToolResult) string {
|
|
var b strings.Builder
|
|
for _, c := range res.Content {
|
|
if tc, ok := c.(mcp.TextContent); ok {
|
|
b.WriteString(tc.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func TestUpdateIssue_NoAssigneeFieldsOmitsAssignees(t *testing.T) {
|
|
_, records := newPatchBackend(t, `{"id":1,"number":42}`)
|
|
|
|
res, err := UpdateIssueFn(context.Background(), makeReq(map[string]any{
|
|
"owner": "goern",
|
|
"repo": "forgejo-mcp",
|
|
"index": float64(42),
|
|
"title": "new title",
|
|
}))
|
|
if err != nil || res == nil || res.IsError {
|
|
t.Fatalf("UpdateIssueFn returned error: err=%v res=%+v", err, res)
|
|
}
|
|
|
|
last := (*records)[len(*records)-1]
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(last.rawBody, &payload); err != nil {
|
|
t.Fatalf("invalid JSON body: %v\nbody: %s", err, last.rawBody)
|
|
}
|
|
if v, ok := payload["assignees"]; ok && v != nil {
|
|
// Acceptable for SDK to emit "assignees":null since Assignees is a slice; reject only non-null arrays.
|
|
if arr, isArr := v.([]any); isArr && len(arr) > 0 {
|
|
t.Fatalf("expected no assignees set, got %v", v)
|
|
}
|
|
}
|
|
}
|