mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2026-08-27 02:27:45 +00:00
380e548f36
Add read and write tools for Gitea projects, including project and column CRUD and issue-to-project-column management. Bump gitea.dev/sdk to include ProjectsService. Assisted-by: Codet:codet-internal
518 lines
14 KiB
Go
518 lines
14 KiB
Go
package project
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
|
|
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
func TestGetProjectScope(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args map[string]any
|
|
writable bool
|
|
wantKind string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "repo",
|
|
args: map[string]any{"scope": "repo", "owner": "octo", "repo": "demo"},
|
|
wantKind: "repo",
|
|
},
|
|
{
|
|
name: "org",
|
|
args: map[string]any{"scope": "org", "owner": "acme"},
|
|
wantKind: "org",
|
|
},
|
|
{
|
|
name: "user read",
|
|
args: map[string]any{"scope": "user", "owner": "octo"},
|
|
wantKind: "user",
|
|
},
|
|
{
|
|
name: "user write",
|
|
args: map[string]any{"scope": "user", "owner": "octo"},
|
|
writable: true,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "current user",
|
|
args: map[string]any{"scope": "current_user"},
|
|
wantKind: "current_user",
|
|
},
|
|
{
|
|
name: "missing scope",
|
|
args: map[string]any{},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "repo missing owner",
|
|
args: map[string]any{"scope": "repo", "repo": "demo"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "repo missing repo",
|
|
args: map[string]any{"scope": "repo", "owner": "octo"},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
info, err := getProjectScope(tt.args, tt.writable)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected an error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("getProjectScope() error = %v", err)
|
|
}
|
|
if info.kind != tt.wantKind {
|
|
t.Fatalf("scope kind = %q, want %q", info.kind, tt.wantKind)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProjectReadFn(t *testing.T) {
|
|
const (
|
|
owner = "octo"
|
|
repo = "demo"
|
|
projectID = 10
|
|
columnID = 20
|
|
)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
gotReq = struct {
|
|
method string
|
|
path string
|
|
query string
|
|
}{}
|
|
)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
mu.Lock()
|
|
gotReq.method = r.Method
|
|
gotReq.path = r.URL.Path
|
|
gotReq.query = r.URL.RawQuery
|
|
mu.Unlock()
|
|
|
|
switch r.URL.Path {
|
|
case "/api/v1/version":
|
|
_, _ = w.Write([]byte(`{"version":"1.28.0"}`))
|
|
case fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo):
|
|
_, _ = w.Write(fmt.Appendf(nil, `[{"id":%d,"title":"Board","state":"open"}]`, projectID))
|
|
case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID):
|
|
_, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"Board","state":"open"}`, projectID))
|
|
case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID):
|
|
_, _ = w.Write(fmt.Appendf(nil, `[{"id":%d,"title":"Todo","project_id":%d}]`, columnID, projectID))
|
|
case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner, repo, projectID, columnID):
|
|
_, _ = w.Write([]byte(`[{"id":5,"number":1,"title":"An issue","state":"open","html_url":"https://example.com/1","user":{"login":"octo"}}]`))
|
|
case fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID):
|
|
_, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"Todo","project_id":%d}`, columnID, projectID))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
|
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
|
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
|
|
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "project-read-token")
|
|
|
|
tests := []struct {
|
|
name string
|
|
args map[string]any
|
|
wantPath string
|
|
wantID float64
|
|
}{
|
|
{
|
|
name: "list",
|
|
args: map[string]any{
|
|
"method": "list",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"state": "open",
|
|
},
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo),
|
|
wantID: projectID,
|
|
},
|
|
{
|
|
name: "get",
|
|
args: map[string]any{
|
|
"method": "get",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
},
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID),
|
|
wantID: projectID,
|
|
},
|
|
{
|
|
name: "list columns",
|
|
args: map[string]any{
|
|
"method": "list_columns",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
},
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID),
|
|
wantID: columnID,
|
|
},
|
|
{
|
|
name: "get column",
|
|
args: map[string]any{
|
|
"method": "get_column",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"column_id": float64(columnID),
|
|
},
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID),
|
|
wantID: columnID,
|
|
},
|
|
{
|
|
name: "list column issues",
|
|
args: map[string]any{
|
|
"method": "list_column_issues",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"column_id": float64(columnID),
|
|
},
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner, repo, projectID, columnID),
|
|
wantID: 5,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := projectReadFn(ctx, tt.args)
|
|
if err != nil {
|
|
t.Fatalf("projectReadFn() error = %v", err)
|
|
}
|
|
if result.IsError {
|
|
t.Fatalf("projectReadFn() returned error result: %v", result)
|
|
}
|
|
|
|
mu.Lock()
|
|
path := gotReq.path
|
|
query := gotReq.query
|
|
mu.Unlock()
|
|
if path != tt.wantPath {
|
|
t.Fatalf("request path = %q, want %q", path, tt.wantPath)
|
|
}
|
|
if tt.name == "list" && query == "" {
|
|
t.Fatal("expected list query parameters, got none")
|
|
}
|
|
|
|
if len(result.Content) == 0 {
|
|
t.Fatal("expected result content")
|
|
}
|
|
text, ok := result.Content[0].(*mcp.TextContent)
|
|
if !ok {
|
|
t.Fatalf("result content type = %T, want text content", result.Content[0])
|
|
}
|
|
assertFirstID(t, []byte(text.Text), tt.wantID)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProjectWriteFn(t *testing.T) {
|
|
const (
|
|
owner = "octo"
|
|
repo = "demo"
|
|
projectID = 10
|
|
columnID = 20
|
|
issueID = 5
|
|
)
|
|
|
|
type request struct {
|
|
method string
|
|
path string
|
|
body map[string]any
|
|
}
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
requests []request
|
|
)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/api/v1/version" {
|
|
_, _ = w.Write([]byte(`{"version":"1.28.0"}`))
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
if r.Body != nil && r.ContentLength > 0 {
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
}
|
|
mu.Lock()
|
|
requests = append(requests, request{method: r.Method, path: r.URL.Path, body: body})
|
|
mu.Unlock()
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
_, _ = w.Write([]byte(`[]`))
|
|
case http.MethodPost, http.MethodPatch:
|
|
_, _ = w.Write([]byte(`{}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
|
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
|
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
|
|
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "project-write-token")
|
|
|
|
tests := []struct {
|
|
name string
|
|
args map[string]any
|
|
wantMethod string
|
|
wantPath string
|
|
wantBody map[string]any
|
|
}{
|
|
{
|
|
name: "create",
|
|
args: map[string]any{
|
|
"method": "create",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"title": "Board",
|
|
"description": "Project board",
|
|
"template_type": "basic_kanban",
|
|
"card_type": "images_and_text",
|
|
},
|
|
wantMethod: http.MethodPost,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner, repo),
|
|
wantBody: map[string]any{"title": "Board", "description": "Project board", "template_type": "basic_kanban", "card_type": "images_and_text"},
|
|
},
|
|
{
|
|
name: "update",
|
|
args: map[string]any{
|
|
"method": "update",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"title": "Updated Board",
|
|
"state": "closed",
|
|
},
|
|
wantMethod: http.MethodPatch,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID),
|
|
wantBody: map[string]any{"title": "Updated Board", "state": "closed"},
|
|
},
|
|
{
|
|
name: "delete",
|
|
args: map[string]any{
|
|
"method": "delete",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
},
|
|
wantMethod: http.MethodDelete,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner, repo, projectID),
|
|
},
|
|
{
|
|
name: "create column",
|
|
args: map[string]any{
|
|
"method": "create_column",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"title": "Todo",
|
|
"color": "#FF0000",
|
|
},
|
|
wantMethod: http.MethodPost,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner, repo, projectID),
|
|
wantBody: map[string]any{"title": "Todo", "color": "#FF0000"},
|
|
},
|
|
{
|
|
name: "update column",
|
|
args: map[string]any{
|
|
"method": "update_column",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"column_id": float64(columnID),
|
|
"title": "Doing",
|
|
"sorting": float64(2),
|
|
},
|
|
wantMethod: http.MethodPatch,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID),
|
|
wantBody: map[string]any{"title": "Doing", "sorting": float64(2)},
|
|
},
|
|
{
|
|
name: "delete column",
|
|
args: map[string]any{
|
|
"method": "delete_column",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"column_id": float64(columnID),
|
|
},
|
|
wantMethod: http.MethodDelete,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner, repo, projectID, columnID),
|
|
},
|
|
{
|
|
name: "set default column",
|
|
args: map[string]any{
|
|
"method": "set_default_column",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"column_id": float64(columnID),
|
|
},
|
|
wantMethod: http.MethodPost,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/default", owner, repo, projectID, columnID),
|
|
},
|
|
{
|
|
name: "move columns",
|
|
args: map[string]any{
|
|
"method": "move_columns",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"column_ids": []any{float64(20), float64(21)},
|
|
},
|
|
wantMethod: http.MethodPost,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/move", owner, repo, projectID),
|
|
wantBody: map[string]any{"column_ids": []any{float64(20), float64(21)}},
|
|
},
|
|
{
|
|
name: "add issue",
|
|
args: map[string]any{
|
|
"method": "add_issue",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"column_id": float64(columnID),
|
|
"issue_id": float64(issueID),
|
|
},
|
|
wantMethod: http.MethodPost,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner, repo, projectID, columnID, issueID),
|
|
},
|
|
{
|
|
name: "remove issue",
|
|
args: map[string]any{
|
|
"method": "remove_issue",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"column_id": float64(columnID),
|
|
"issue_id": float64(issueID),
|
|
},
|
|
wantMethod: http.MethodDelete,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner, repo, projectID, columnID, issueID),
|
|
},
|
|
{
|
|
name: "move issue",
|
|
args: map[string]any{
|
|
"method": "move_issue",
|
|
"scope": "repo",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"project_id": float64(projectID),
|
|
"issue_id": float64(issueID),
|
|
"column_id": float64(columnID),
|
|
"sorting": float64(3),
|
|
},
|
|
wantMethod: http.MethodPost,
|
|
wantPath: fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner, repo, projectID, issueID),
|
|
wantBody: map[string]any{"column_id": float64(columnID), "sorting": float64(3)},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := projectWriteFn(ctx, tt.args)
|
|
if err != nil {
|
|
t.Fatalf("projectWriteFn() error = %v", err)
|
|
}
|
|
if result.IsError {
|
|
t.Fatalf("projectWriteFn() returned error result: %v", result)
|
|
}
|
|
|
|
mu.Lock()
|
|
request := requests[len(requests)-1]
|
|
mu.Unlock()
|
|
if request.method != tt.wantMethod {
|
|
t.Fatalf("request method = %q, want %q", request.method, tt.wantMethod)
|
|
}
|
|
if request.path != tt.wantPath {
|
|
t.Fatalf("request path = %q, want %q", request.path, tt.wantPath)
|
|
}
|
|
if tt.wantBody != nil {
|
|
if len(request.body) != len(tt.wantBody) {
|
|
t.Fatalf("request body = %v, want %v", request.body, tt.wantBody)
|
|
}
|
|
for key, want := range tt.wantBody {
|
|
if got := request.body[key]; fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Fatalf("request body[%q] = %v, want %v", key, got, want)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertFirstID(t *testing.T, data []byte, want float64) {
|
|
t.Helper()
|
|
|
|
if len(data) > 0 && data[0] == '[' {
|
|
var items []map[string]any
|
|
if err := json.Unmarshal(data, &items); err != nil {
|
|
t.Fatalf("unmarshal result text: %v", err)
|
|
}
|
|
if len(items) == 0 {
|
|
t.Fatal("expected at least one result item")
|
|
}
|
|
if items[0]["id"] != want {
|
|
t.Fatalf("first result id = %v, want %v", items[0]["id"], want)
|
|
}
|
|
return
|
|
}
|
|
|
|
var item map[string]any
|
|
if err := json.Unmarshal(data, &item); err != nil {
|
|
t.Fatalf("unmarshal result text: %v", err)
|
|
}
|
|
if item["id"] != want {
|
|
t.Fatalf("result id = %v, want %v", item["id"], want)
|
|
}
|
|
}
|