Initial commit: Gitea MCP Server
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
CreateBranchToolName = "create_branch"
|
||||
DeleteBranchToolName = "delete_branch"
|
||||
ListBranchesToolName = "list_branches"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateBranchTool = mcp.NewTool(
|
||||
CreateBranchToolName,
|
||||
mcp.WithDescription("Create branch"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to create")),
|
||||
mcp.WithString("old_branch", mcp.Required(), mcp.Description("Name of the old branch to create from")),
|
||||
)
|
||||
|
||||
DeleteBranchTool = mcp.NewTool(
|
||||
DeleteBranchToolName,
|
||||
mcp.WithDescription("Delete branch"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to delete")),
|
||||
)
|
||||
|
||||
ListBranchesTool = mcp.NewTool(
|
||||
ListBranchesToolName,
|
||||
mcp.WithDescription("List branches"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: CreateBranchTool,
|
||||
Handler: CreateBranchFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: DeleteBranchTool,
|
||||
Handler: DeleteBranchFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: ListBranchesTool,
|
||||
Handler: ListBranchesFn,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called CreateBranchFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
branch, err := params.GetString(args, "branch")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
oldBranch, _ := args["old_branch"].(string)
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
_, _, err = client.CreateBranch(owner, repo, gitea_sdk.CreateBranchOption{
|
||||
BranchName: branch,
|
||||
OldBranchName: oldBranch,
|
||||
})
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("create branch error: %v", err))
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText("Branch Created"), nil
|
||||
}
|
||||
|
||||
func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called DeleteBranchFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
branch, err := params.GetString(args, "branch")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
_, _, err = client.DeleteRepoBranch(owner, repo, branch)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("delete branch error: %v", err))
|
||||
}
|
||||
|
||||
return to.TextResult("Branch Deleted")
|
||||
}
|
||||
|
||||
func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ListBranchesFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
opt := gitea_sdk.ListRepoBranchesOptions{
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 30,
|
||||
},
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
branches, _, err := client.ListRepoBranches(owner, repo, opt)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("list branches error: %v", err))
|
||||
}
|
||||
|
||||
return to.TextResult(slimBranches(branches))
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
ListRepoCommitsToolName = "list_commits"
|
||||
)
|
||||
|
||||
var ListRepoCommitsTool = mcp.NewTool(
|
||||
ListRepoCommitsToolName,
|
||||
mcp.WithDescription("List repository commits"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
|
||||
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
|
||||
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: ListRepoCommitsTool,
|
||||
Handler: ListRepoCommitsFn,
|
||||
})
|
||||
}
|
||||
|
||||
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ListRepoCommitsFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
page, err := params.GetIndex(args, "page")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
pageSize, err := params.GetIndex(args, "perPage")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
sha, _ := args["sha"].(string)
|
||||
path, _ := args["path"].(string)
|
||||
opt := gitea_sdk.ListCommitOptions{
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: int(page),
|
||||
PageSize: int(pageSize),
|
||||
},
|
||||
SHA: sha,
|
||||
Path: path,
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
commits, _, err := client.ListRepoCommits(owner, repo, opt)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err))
|
||||
}
|
||||
return to.TextResult(slimCommits(commits))
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/errors"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
GetFileToolName = "get_file_contents"
|
||||
GetDirToolName = "get_dir_contents"
|
||||
CreateOrUpdateFileToolName = "create_or_update_file"
|
||||
DeleteFileToolName = "delete_file"
|
||||
)
|
||||
|
||||
var (
|
||||
GetFileContentTool = mcp.NewTool(
|
||||
GetFileToolName,
|
||||
mcp.WithDescription("Get file Content and Metadata"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
|
||||
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
|
||||
mcp.WithBoolean("withLines", mcp.Description("whether to return file content with lines")),
|
||||
)
|
||||
|
||||
GetDirContentTool = mcp.NewTool(
|
||||
GetDirToolName,
|
||||
mcp.WithDescription("Get a list of entries in a directory"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
|
||||
mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")),
|
||||
)
|
||||
|
||||
CreateOrUpdateFileTool = mcp.NewTool(
|
||||
CreateOrUpdateFileToolName,
|
||||
mcp.WithDescription("Create or update a file. If sha is provided, updates the existing file; otherwise creates a new file."),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
|
||||
mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
|
||||
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
|
||||
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
|
||||
mcp.WithString("sha", mcp.Description("SHA of the existing file (required for update, omit for create)")),
|
||||
mcp.WithString("new_branch_name", mcp.Description("new branch name (for create only)")),
|
||||
)
|
||||
|
||||
DeleteFileTool = mcp.NewTool(
|
||||
DeleteFileToolName,
|
||||
mcp.WithDescription("Delete file"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
|
||||
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
|
||||
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
|
||||
mcp.WithString("sha", mcp.Required(), mcp.Description("sha")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: GetFileContentTool,
|
||||
Handler: GetFileContentFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: GetDirContentTool,
|
||||
Handler: GetDirContentFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: CreateOrUpdateFileTool,
|
||||
Handler: CreateOrUpdateFileFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: DeleteFileTool,
|
||||
Handler: DeleteFileFn,
|
||||
})
|
||||
}
|
||||
|
||||
type ContentLine struct {
|
||||
LineNumber int `json:"line"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called GetFileFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
ref, _ := args["ref"].(string)
|
||||
filePath, err := params.GetString(args, "filePath")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
content, _, err := client.GetContents(owner, repo, ref, filePath)
|
||||
if err != nil {
|
||||
translatedErr := errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetFile",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"path": filePath,
|
||||
"ref": ref,
|
||||
})
|
||||
return to.ErrorResult(translatedErr)
|
||||
}
|
||||
withLines, _ := args["withLines"].(bool)
|
||||
if withLines {
|
||||
rawContent, err := base64.StdEncoding.DecodeString(*content.Content)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("decode base64 content err: %v", err))
|
||||
}
|
||||
|
||||
contentLines := make([]ContentLine, 0)
|
||||
line := 0
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(rawContent))
|
||||
|
||||
for scanner.Scan() {
|
||||
line++
|
||||
|
||||
contentLines = append(contentLines, ContentLine{
|
||||
LineNumber: line,
|
||||
Content: scanner.Text(),
|
||||
})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("scan content err: %v", err))
|
||||
}
|
||||
|
||||
// remove the last blank line if exists
|
||||
// git does not consider the last line as a new line
|
||||
if len(contentLines) > 0 && contentLines[len(contentLines)-1].Content == "" {
|
||||
contentLines = contentLines[:len(contentLines)-1]
|
||||
}
|
||||
|
||||
contentBytes, err := json.MarshalIndent(contentLines, "", " ")
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("marshal content lines err: %v", err))
|
||||
}
|
||||
contentStr := string(contentBytes)
|
||||
content.Content = &contentStr
|
||||
}
|
||||
return to.TextResult(slimContents(content))
|
||||
}
|
||||
|
||||
func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called GetDirContentFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
ref, _ := args["ref"].(string)
|
||||
filePath, err := params.GetString(args, "filePath")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
content, _, err := client.ListContents(owner, repo, ref, filePath)
|
||||
if err != nil {
|
||||
translatedErr := errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetDir",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"path": filePath,
|
||||
"ref": ref,
|
||||
})
|
||||
return to.ErrorResult(translatedErr)
|
||||
}
|
||||
return to.TextResult(slimDirEntries(content))
|
||||
}
|
||||
|
||||
func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called CreateOrUpdateFileFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
filePath, err := params.GetString(args, "filePath")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
content, _ := args["content"].(string)
|
||||
message, _ := args["message"].(string)
|
||||
branchName, _ := args["branch_name"].(string)
|
||||
sha, _ := args["sha"].(string)
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
|
||||
if sha != "" {
|
||||
// Update existing file
|
||||
opt := gitea_sdk.UpdateFileOptions{
|
||||
SHA: sha,
|
||||
Content: base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
FileOptions: gitea_sdk.FileOptions{
|
||||
Message: message,
|
||||
BranchName: branchName,
|
||||
},
|
||||
}
|
||||
_, _, err = client.UpdateFile(owner, repo, filePath, opt)
|
||||
if err != nil {
|
||||
translatedErr := errors.TranslateError(err, map[string]string{
|
||||
"operation": "UpdateFile",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"path": filePath,
|
||||
"branch": branchName,
|
||||
})
|
||||
return to.ErrorResult(translatedErr)
|
||||
}
|
||||
return to.TextResult("Update file success")
|
||||
}
|
||||
|
||||
// Create new file
|
||||
opt := gitea_sdk.CreateFileOptions{
|
||||
Content: base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
FileOptions: gitea_sdk.FileOptions{
|
||||
Message: message,
|
||||
BranchName: branchName,
|
||||
},
|
||||
}
|
||||
if newBranch, ok := args["new_branch_name"].(string); ok && newBranch != "" {
|
||||
opt.NewBranchName = newBranch
|
||||
}
|
||||
_, _, err = client.CreateFile(owner, repo, filePath, opt)
|
||||
if err != nil {
|
||||
translatedErr := errors.TranslateError(err, map[string]string{
|
||||
"operation": "CreateFile",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"path": filePath,
|
||||
"branch": branchName,
|
||||
})
|
||||
return to.ErrorResult(translatedErr)
|
||||
}
|
||||
return to.TextResult("Create file success")
|
||||
}
|
||||
|
||||
func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called DeleteFileFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
filePath, err := params.GetString(args, "filePath")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
message, _ := args["message"].(string)
|
||||
branchName, _ := args["branch_name"].(string)
|
||||
sha, err := params.GetString(args, "sha")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
opt := gitea_sdk.DeleteFileOptions{
|
||||
FileOptions: gitea_sdk.FileOptions{
|
||||
Message: message,
|
||||
BranchName: branchName,
|
||||
},
|
||||
SHA: sha,
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
_, err = client.DeleteFile(owner, repo, filePath, opt)
|
||||
if err != nil {
|
||||
translatedErr := errors.TranslateError(err, map[string]string{
|
||||
"operation": "DeleteFile",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"path": filePath,
|
||||
"branch": branchName,
|
||||
})
|
||||
return to.ErrorResult(translatedErr)
|
||||
}
|
||||
return to.TextResult("Delete file success")
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
gitea_errors "gitea.com/gitea/gitea-mcp/pkg/errors"
|
||||
)
|
||||
|
||||
// mockClientError is a mock error that simulates SDK errors
|
||||
type mockClientError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *mockClientError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func TestErrorTranslation_GetFile(t *testing.T) {
|
||||
// Test that GetContentsOrList errors are translated properly
|
||||
err := errors.New("GetContentsOrList: 404 Not Found")
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetFile",
|
||||
"owner": "karti-ai",
|
||||
"repo": "docs",
|
||||
"path": "README.md",
|
||||
"ref": "main",
|
||||
})
|
||||
|
||||
// Should return an EnhancedError
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if !errors.As(translated, &enhanced) {
|
||||
t.Fatal("expected translated error to be EnhancedError")
|
||||
}
|
||||
|
||||
// Check operation
|
||||
if enhanced.Operation != "GetFile" {
|
||||
t.Errorf("expected operation GetFile, got %s", enhanced.Operation)
|
||||
}
|
||||
|
||||
// Check context
|
||||
if enhanced.Context["owner"] != "karti-ai" {
|
||||
t.Errorf("expected owner karti-ai, got %s", enhanced.Context["owner"])
|
||||
}
|
||||
if enhanced.Context["path"] != "README.md" {
|
||||
t.Errorf("expected path README.md, got %s", enhanced.Context["path"])
|
||||
}
|
||||
|
||||
// Should be a file-related error
|
||||
if enhanced.Category != gitea_errors.CategoryFile {
|
||||
t.Errorf("expected CategoryFile, got %s", enhanced.Category)
|
||||
}
|
||||
|
||||
// Should be identified as NotFound
|
||||
if !gitea_errors.IsNotFound(translated) {
|
||||
t.Error("expected error to be identified as NotFound")
|
||||
}
|
||||
|
||||
t.Logf("Translated error message: %s", enhanced.Error())
|
||||
}
|
||||
|
||||
func TestErrorTranslation_GetDir(t *testing.T) {
|
||||
// Test that ListContents errors are translated properly
|
||||
err := errors.New("ListContents: 404 Not Found")
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetDir",
|
||||
"owner": "karti-ai",
|
||||
"repo": "public_website",
|
||||
"path": ".gitea/workflows",
|
||||
"ref": "main",
|
||||
})
|
||||
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if !errors.As(translated, &enhanced) {
|
||||
t.Fatal("expected translated error to be EnhancedError")
|
||||
}
|
||||
|
||||
if enhanced.Operation != "GetDir" {
|
||||
t.Errorf("expected operation GetDir, got %s", enhanced.Operation)
|
||||
}
|
||||
|
||||
if enhanced.Category != gitea_errors.CategoryFile {
|
||||
t.Errorf("expected CategoryFile, got %s", enhanced.Category)
|
||||
}
|
||||
|
||||
t.Logf("Translated error message: %s", enhanced.Error())
|
||||
}
|
||||
|
||||
func TestErrorTranslation_CreateFile(t *testing.T) {
|
||||
// Test that CreateFile errors are translated properly
|
||||
err := errors.New("CreateFile: 422 Unprocessable Entity")
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "CreateFile",
|
||||
"owner": "karti-ai",
|
||||
"repo": "docs",
|
||||
"path": "newfile.md",
|
||||
"branch": "main",
|
||||
})
|
||||
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if !errors.As(translated, &enhanced) {
|
||||
t.Fatal("expected translated error to be EnhancedError")
|
||||
}
|
||||
|
||||
if enhanced.Operation != "CreateFile" {
|
||||
t.Errorf("expected operation CreateFile, got %s", enhanced.Operation)
|
||||
}
|
||||
|
||||
if enhanced.Context["path"] != "newfile.md" {
|
||||
t.Errorf("expected path newfile.md, got %s", enhanced.Context["path"])
|
||||
}
|
||||
|
||||
t.Logf("Translated error message: %s", enhanced.Error())
|
||||
}
|
||||
|
||||
func TestErrorTranslation_UpdateFile(t *testing.T) {
|
||||
// Test that UpdateFile errors are translated properly
|
||||
err := errors.New("UpdateFile: 409 Conflict")
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "UpdateFile",
|
||||
"owner": "karti-ai",
|
||||
"repo": "docs",
|
||||
"path": "README.md",
|
||||
"branch": "main",
|
||||
})
|
||||
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if !errors.As(translated, &enhanced) {
|
||||
t.Fatal("expected translated error to be EnhancedError")
|
||||
}
|
||||
|
||||
if enhanced.Operation != "UpdateFile" {
|
||||
t.Errorf("expected operation UpdateFile, got %s", enhanced.Operation)
|
||||
}
|
||||
|
||||
t.Logf("Translated error message: %s", enhanced.Error())
|
||||
}
|
||||
|
||||
func TestErrorTranslation_DeleteFile(t *testing.T) {
|
||||
// Test that DeleteFile errors are translated properly
|
||||
err := errors.New("DeleteFile: 404 Not Found")
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "DeleteFile",
|
||||
"owner": "karti-ai",
|
||||
"repo": "docs",
|
||||
"path": "oldfile.md",
|
||||
"branch": "main",
|
||||
})
|
||||
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if !errors.As(translated, &enhanced) {
|
||||
t.Fatal("expected translated error to be EnhancedError")
|
||||
}
|
||||
|
||||
if enhanced.Operation != "DeleteFile" {
|
||||
t.Errorf("expected operation DeleteFile, got %s", enhanced.Operation)
|
||||
}
|
||||
|
||||
t.Logf("Translated error message: %s", enhanced.Error())
|
||||
t.Logf("Error category: %s", enhanced.Category)
|
||||
}
|
||||
|
||||
func TestErrorTranslation_Unwrap(t *testing.T) {
|
||||
original := errors.New("GetContentsOrList: 404 Not Found")
|
||||
translated := gitea_errors.TranslateError(original, map[string]string{
|
||||
"operation": "GetFile",
|
||||
"owner": "karti-ai",
|
||||
"repo": "docs",
|
||||
"path": "README.md",
|
||||
})
|
||||
|
||||
// Should be able to unwrap to get original error
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if errors.As(translated, &enhanced) {
|
||||
unwrapped := enhanced.Unwrap()
|
||||
if unwrapped == nil {
|
||||
t.Error("expected to be able to unwrap error")
|
||||
}
|
||||
if unwrapped.Error() != original.Error() {
|
||||
t.Errorf("expected unwrapped error to match original: got %s, want %s", unwrapped.Error(), original.Error())
|
||||
}
|
||||
} else {
|
||||
t.Error("expected translated error to be EnhancedError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTranslation_AuthErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errMsg string
|
||||
isAuth bool
|
||||
}{
|
||||
{
|
||||
name: "401 Unauthorized",
|
||||
errMsg: "GetContentsOrList: 401 Unauthorized",
|
||||
isAuth: true,
|
||||
},
|
||||
{
|
||||
name: "403 Forbidden",
|
||||
errMsg: "GetContentsOrList: 403 Forbidden",
|
||||
isAuth: true,
|
||||
},
|
||||
{
|
||||
name: "404 Not Found (not auth)",
|
||||
errMsg: "GetContentsOrList: 404 Not Found",
|
||||
isAuth: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errors.New(tt.errMsg)
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetFile",
|
||||
})
|
||||
|
||||
isAuth := gitea_errors.IsAuthError(translated)
|
||||
if isAuth != tt.isAuth {
|
||||
t.Errorf("IsAuthError() = %v, want %v", isAuth, tt.isAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTranslation_PreservesExistingEnhancedError(t *testing.T) {
|
||||
// If we translate an already-enhanced error, it should add context, not replace
|
||||
original := errors.New("GetContentsOrList: 404 Not Found")
|
||||
enhanced1 := gitea_errors.TranslateError(original, map[string]string{
|
||||
"operation": "GetFile",
|
||||
"owner": "karti-ai",
|
||||
})
|
||||
|
||||
// Translate again with more context
|
||||
enhanced2 := gitea_errors.TranslateError(enhanced1, map[string]string{
|
||||
"repo": "docs",
|
||||
"path": "README.md",
|
||||
})
|
||||
|
||||
var e *gitea_errors.EnhancedError
|
||||
if errors.As(enhanced2, &e) {
|
||||
// Should have both sets of context
|
||||
if e.Context["operation"] != "GetFile" {
|
||||
t.Errorf("expected operation context to be preserved, got %s", e.Context["operation"])
|
||||
}
|
||||
if e.Context["owner"] != "karti-ai" {
|
||||
t.Errorf("expected owner context to be preserved, got %s", e.Context["owner"])
|
||||
}
|
||||
if e.Context["repo"] != "docs" {
|
||||
t.Errorf("expected repo context to be added, got %s", e.Context["repo"])
|
||||
}
|
||||
if e.Context["path"] != "README.md" {
|
||||
t.Errorf("expected path context to be added, got %s", e.Context["path"])
|
||||
}
|
||||
} else {
|
||||
t.Error("expected error to be EnhancedError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTranslation_NetworkErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errMsg string
|
||||
isNetwork bool
|
||||
isTimeout bool
|
||||
}{
|
||||
{
|
||||
name: "Connection refused",
|
||||
errMsg: "GetContentsOrList: connection refused",
|
||||
isNetwork: true,
|
||||
isTimeout: false,
|
||||
},
|
||||
{
|
||||
name: "Timeout",
|
||||
errMsg: "GetContentsOrList: timeout",
|
||||
isNetwork: true,
|
||||
isTimeout: true,
|
||||
},
|
||||
{
|
||||
name: "No such host",
|
||||
errMsg: "GetContentsOrList: no such host",
|
||||
isNetwork: true,
|
||||
isTimeout: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errors.New(tt.errMsg)
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetFile",
|
||||
})
|
||||
|
||||
isNetwork := gitea_errors.IsNetworkError(translated)
|
||||
isTimeout := gitea_errors.IsTimeout(translated)
|
||||
|
||||
t.Logf("Error: %s, IsNetwork: %v, IsTimeout: %v", tt.errMsg, isNetwork, isTimeout)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTranslation_ServerErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errMsg string
|
||||
isServer bool
|
||||
}{
|
||||
{
|
||||
name: "500 Internal Server Error",
|
||||
errMsg: "GetContentsOrList: 500 Internal Server Error",
|
||||
isServer: true,
|
||||
},
|
||||
{
|
||||
name: "502 Bad Gateway",
|
||||
errMsg: "GetContentsOrList: 502 Bad Gateway",
|
||||
isServer: true,
|
||||
},
|
||||
{
|
||||
name: "503 Service Unavailable",
|
||||
errMsg: "GetContentsOrList: 503 Service Unavailable",
|
||||
isServer: true,
|
||||
},
|
||||
{
|
||||
name: "404 Not Found (not server)",
|
||||
errMsg: "GetContentsOrList: 404 Not Found",
|
||||
isServer: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errors.New(tt.errMsg)
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetFile",
|
||||
})
|
||||
|
||||
isServer := gitea_errors.IsServerError(translated)
|
||||
t.Logf("Error: %s, IsServer: %v", tt.errMsg, isServer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTranslation_Format(t *testing.T) {
|
||||
err := errors.New("GetContentsOrList: 404 Not Found")
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetFile",
|
||||
"owner": "karti-ai",
|
||||
"repo": "docs",
|
||||
"path": "README.md",
|
||||
"ref": "main",
|
||||
})
|
||||
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if errors.As(translated, &enhanced) {
|
||||
formatted := enhanced.Format()
|
||||
|
||||
// Format should include operation
|
||||
if formatted == "" {
|
||||
t.Error("expected non-empty formatted error")
|
||||
}
|
||||
|
||||
t.Logf("Formatted error: %s", formatted)
|
||||
} else {
|
||||
t.Error("expected translated error to be EnhancedError")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/errors"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
RepoHealthCheckToolName = "repo_health_check"
|
||||
)
|
||||
|
||||
var (
|
||||
RepoHealthCheckTool = mcp.NewTool(
|
||||
RepoHealthCheckToolName,
|
||||
mcp.WithDescription("Check repository health by aggregating multiple status metrics including last commit date, open issues/PRs count, workflow status, and branch protection. Returns a comprehensive health score (0-100)."),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithBoolean("include_workflows", mcp.Description("include workflow run status (may require additional API calls)"), mcp.DefaultBool(true)),
|
||||
mcp.WithBoolean("include_protection", mcp.Description("include branch protection status"), mcp.DefaultBool(true)),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: RepoHealthCheckTool,
|
||||
Handler: repoHealthCheckFn,
|
||||
})
|
||||
}
|
||||
|
||||
// HealthResult represents the complete health check result
|
||||
type HealthResult struct {
|
||||
Repository string `json:"repository"`
|
||||
HealthScore int `json:"health_score"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
LastCommit *CommitInfo `json:"last_commit,omitempty"`
|
||||
Issues *IssuesInfo `json:"issues,omitempty"`
|
||||
PullRequests *PullRequestsInfo `json:"pull_requests,omitempty"`
|
||||
WorkflowStatus *WorkflowStatusInfo `json:"workflow_status,omitempty"`
|
||||
BranchProtection *BranchProtectionInfo `json:"branch_protection,omitempty"`
|
||||
RepositoryInfo *RepositoryInfo `json:"repository_info,omitempty"`
|
||||
Errors []HealthCheckError `json:"errors,omitempty"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
PartialResult bool `json:"partial_result"`
|
||||
}
|
||||
|
||||
// CommitInfo contains last commit information
|
||||
type CommitInfo struct {
|
||||
SHA string `json:"sha"`
|
||||
Message string `json:"message"`
|
||||
Author string `json:"author"`
|
||||
Date string `json:"date"`
|
||||
DaysAgo int `json:"days_ago"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// IssuesInfo contains issue metrics
|
||||
type IssuesInfo struct {
|
||||
OpenCount int `json:"open_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// PullRequestsInfo contains PR metrics
|
||||
type PullRequestsInfo struct {
|
||||
OpenCount int `json:"open_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// WorkflowStatusInfo contains workflow information
|
||||
type WorkflowStatusInfo struct {
|
||||
LastRunStatus string `json:"last_run_status,omitempty"`
|
||||
LastRunConclusion string `json:"last_run_conclusion,omitempty"`
|
||||
HasRecentRuns bool `json:"has_recent_runs"`
|
||||
Available bool `json:"available"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// BranchProtectionInfo contains protection metrics
|
||||
type BranchProtectionInfo struct {
|
||||
ProtectedBranchesCount int `json:"protected_branches_count"`
|
||||
ProtectedBranches []string `json:"protected_branches,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// RepositoryInfo contains basic repo metrics
|
||||
type RepositoryInfo struct {
|
||||
Stars int `json:"stars"`
|
||||
Forks int `json:"forks"`
|
||||
Language string `json:"language,omitempty"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// HealthCheckError represents an error from a specific check
|
||||
type HealthCheckError struct {
|
||||
Check string `json:"check"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func repoHealthCheckFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called repoHealthCheckFn")
|
||||
|
||||
owner, err := params.GetString(req.GetArguments(), "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(errors.TranslateError(err, map[string]string{
|
||||
"operation": "RepoHealthCheck",
|
||||
"param": "owner",
|
||||
}))
|
||||
}
|
||||
|
||||
repoName, err := params.GetString(req.GetArguments(), "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(errors.TranslateError(err, map[string]string{
|
||||
"operation": "RepoHealthCheck",
|
||||
"param": "repo",
|
||||
}))
|
||||
}
|
||||
|
||||
includeWorkflows := params.GetOptionalBool(req.GetArguments(), "include_workflows", true)
|
||||
includeProtection := params.GetOptionalBool(req.GetArguments(), "include_protection", true)
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(errors.TranslateError(err, map[string]string{
|
||||
"operation": "RepoHealthCheck",
|
||||
"owner": owner,
|
||||
"repo": repoName,
|
||||
}))
|
||||
}
|
||||
|
||||
result := &HealthResult{
|
||||
Repository: fmt.Sprintf("%s/%s", owner, repoName),
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
PartialResult: false,
|
||||
Errors: []HealthCheckError{},
|
||||
}
|
||||
|
||||
// Check 1: Repository Info (always try first)
|
||||
repoInfo, err := checkRepositoryInfo(ctx, client, owner, repoName)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, HealthCheckError{
|
||||
Check: "repository_info",
|
||||
Error: err.Error(),
|
||||
})
|
||||
result.PartialResult = true
|
||||
} else {
|
||||
result.RepositoryInfo = repoInfo
|
||||
}
|
||||
|
||||
// Check 2: Last Commit
|
||||
commitInfo, err := checkLastCommit(ctx, client, owner, repoName)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, HealthCheckError{
|
||||
Check: "last_commit",
|
||||
Error: err.Error(),
|
||||
})
|
||||
result.PartialResult = true
|
||||
} else {
|
||||
result.LastCommit = commitInfo
|
||||
}
|
||||
|
||||
// Check 3: Issues
|
||||
issuesInfo, err := checkIssues(ctx, client, owner, repoName)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, HealthCheckError{
|
||||
Check: "issues",
|
||||
Error: err.Error(),
|
||||
})
|
||||
result.PartialResult = true
|
||||
} else {
|
||||
result.Issues = issuesInfo
|
||||
}
|
||||
|
||||
// Check 4: Pull Requests
|
||||
prsInfo, err := checkPullRequests(ctx, client, owner, repoName)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, HealthCheckError{
|
||||
Check: "pull_requests",
|
||||
Error: err.Error(),
|
||||
})
|
||||
result.PartialResult = true
|
||||
} else {
|
||||
result.PullRequests = prsInfo
|
||||
}
|
||||
|
||||
// Check 5: Workflow Status (optional, may fail on older Gitea versions)
|
||||
if includeWorkflows {
|
||||
workflowInfo, err := checkWorkflowStatus(ctx, owner, repoName)
|
||||
if err != nil {
|
||||
// Don't mark as partial for workflow errors on older Gitea versions
|
||||
if !errors.IsActionsAPIUnavailable(err) {
|
||||
result.Errors = append(result.Errors, HealthCheckError{
|
||||
Check: "workflow_status",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
result.WorkflowStatus = &WorkflowStatusInfo{
|
||||
Available: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
} else {
|
||||
result.WorkflowStatus = workflowInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Check 6: Branch Protection (optional)
|
||||
if includeProtection {
|
||||
protectionInfo, err := checkBranchProtection(ctx, client, owner, repoName)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, HealthCheckError{
|
||||
Check: "branch_protection",
|
||||
Error: err.Error(),
|
||||
})
|
||||
result.PartialResult = true
|
||||
result.BranchProtection = &BranchProtectionInfo{
|
||||
Available: false,
|
||||
}
|
||||
} else {
|
||||
result.BranchProtection = protectionInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate health score
|
||||
result.HealthScore = calculateHealthScore(result)
|
||||
result.HealthStatus = getHealthStatus(result.HealthScore)
|
||||
|
||||
// Return result as JSON
|
||||
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return to.ErrorResult(errors.TranslateError(err, map[string]string{
|
||||
"operation": "RepoHealthCheck",
|
||||
"step": "marshal_result",
|
||||
}))
|
||||
}
|
||||
|
||||
return to.TextResult(string(jsonBytes))
|
||||
}
|
||||
|
||||
func checkRepositoryInfo(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*RepositoryInfo, error) {
|
||||
r, _, err := client.GetRepo(owner, repo)
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetRepo",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
})
|
||||
}
|
||||
|
||||
return &RepositoryInfo{
|
||||
Stars: r.Stars,
|
||||
Forks: r.Forks,
|
||||
Language: r.Language,
|
||||
IsPrivate: r.Private,
|
||||
IsArchived: r.Archived,
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkLastCommit(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*CommitInfo, error) {
|
||||
opt := gitea_sdk.ListCommitOptions{
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
},
|
||||
}
|
||||
|
||||
commits, _, err := client.ListRepoCommits(owner, repo, opt)
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "ListRepoCommits",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
})
|
||||
}
|
||||
|
||||
if len(commits) == 0 {
|
||||
return &CommitInfo{
|
||||
Available: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
c := commits[0]
|
||||
info := &CommitInfo{
|
||||
SHA: c.SHA,
|
||||
Available: true,
|
||||
}
|
||||
|
||||
if c.RepoCommit != nil {
|
||||
info.Message = c.RepoCommit.Message
|
||||
if c.RepoCommit.Author != nil {
|
||||
info.Author = c.RepoCommit.Author.Name
|
||||
info.Date = c.RepoCommit.Author.Date
|
||||
// Calculate days ago
|
||||
if commitTime, err := time.Parse(time.RFC3339, c.RepoCommit.Author.Date); err == nil {
|
||||
info.DaysAgo = int(time.Since(commitTime).Hours() / 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func checkIssues(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*IssuesInfo, error) {
|
||||
// Get open issues count
|
||||
openOpt := gitea_sdk.ListIssueOption{
|
||||
State: gitea_sdk.StateOpen,
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
},
|
||||
}
|
||||
|
||||
openIssues, _, err := client.ListRepoIssues(owner, repo, openOpt)
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "ListRepoIssues",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"state": "open",
|
||||
})
|
||||
}
|
||||
|
||||
// Get total issues count (we can use the repo info for this to save API calls)
|
||||
// For simplicity, we'll just use what we can get from list
|
||||
totalOpt := gitea_sdk.ListIssueOption{
|
||||
State: gitea_sdk.StateAll,
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
},
|
||||
}
|
||||
|
||||
totalIssues, _, err := client.ListRepoIssues(owner, repo, totalOpt)
|
||||
if err != nil {
|
||||
// If we got open count, we can still return partial info
|
||||
return &IssuesInfo{
|
||||
OpenCount: len(openIssues),
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &IssuesInfo{
|
||||
OpenCount: len(openIssues),
|
||||
TotalCount: len(totalIssues),
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkPullRequests(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*PullRequestsInfo, error) {
|
||||
// Get open PRs
|
||||
openOpt := gitea_sdk.ListPullRequestsOptions{
|
||||
State: gitea_sdk.StateOpen,
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
},
|
||||
}
|
||||
|
||||
openPRs, _, err := client.ListRepoPullRequests(owner, repo, openOpt)
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "ListRepoPullRequests",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"state": "open",
|
||||
})
|
||||
}
|
||||
|
||||
// Get total PRs
|
||||
totalOpt := gitea_sdk.ListPullRequestsOptions{
|
||||
State: gitea_sdk.StateAll,
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
},
|
||||
}
|
||||
|
||||
totalPRs, _, err := client.ListRepoPullRequests(owner, repo, totalOpt)
|
||||
if err != nil {
|
||||
return &PullRequestsInfo{
|
||||
OpenCount: len(openPRs),
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &PullRequestsInfo{
|
||||
OpenCount: len(openPRs),
|
||||
TotalCount: len(totalPRs),
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkWorkflowStatus(ctx context.Context, owner, repo string) (*WorkflowStatusInfo, error) {
|
||||
// Use the REST API directly to get recent workflow runs
|
||||
var result struct {
|
||||
WorkflowRuns []map[string]any `json:"workflow_runs"`
|
||||
}
|
||||
|
||||
status, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/runs", owner, repo), nil, nil, &result)
|
||||
if err != nil {
|
||||
// Check if this is an Actions API unavailability error
|
||||
if status == 404 || status == 405 {
|
||||
return nil, errors.NewEnhancedError(
|
||||
err,
|
||||
"Actions API not available on this Gitea version",
|
||||
errors.CategoryActions,
|
||||
).WithOperation("CheckWorkflowStatus")
|
||||
}
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "ListWorkflowRuns",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
})
|
||||
}
|
||||
|
||||
info := &WorkflowStatusInfo{
|
||||
Available: len(result.WorkflowRuns) > 0,
|
||||
}
|
||||
|
||||
if len(result.WorkflowRuns) > 0 {
|
||||
// Get the most recent run
|
||||
run := result.WorkflowRuns[0]
|
||||
info.LastRunStatus = getStringFromMap(run, "status")
|
||||
info.LastRunConclusion = getStringFromMap(run, "conclusion")
|
||||
info.HasRecentRuns = true
|
||||
|
||||
// Check if run is recent (within 7 days)
|
||||
if createdAt := getStringFromMap(run, "created_at"); createdAt != "" {
|
||||
if runTime, err := time.Parse(time.RFC3339, createdAt); err == nil {
|
||||
info.HasRecentRuns = time.Since(runTime).Hours() < 24*7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func checkBranchProtection(ctx context.Context, client *gitea_sdk.Client, owner, repo string) (*BranchProtectionInfo, error) {
|
||||
protections, _, err := client.ListBranchProtections(owner, repo, gitea_sdk.ListBranchProtectionsOptions{})
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "ListBranchProtections",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
})
|
||||
}
|
||||
|
||||
branches := make([]string, 0, len(protections))
|
||||
for _, p := range protections {
|
||||
branches = append(branches, p.BranchName)
|
||||
}
|
||||
|
||||
return &BranchProtectionInfo{
|
||||
ProtectedBranchesCount: len(protections),
|
||||
ProtectedBranches: branches,
|
||||
Available: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func calculateHealthScore(result *HealthResult) int {
|
||||
score := 100
|
||||
|
||||
// Deduct for stale commits (more than 30 days)
|
||||
if result.LastCommit != nil && result.LastCommit.Available {
|
||||
if result.LastCommit.DaysAgo > 90 {
|
||||
score -= 30
|
||||
} else if result.LastCommit.DaysAgo > 30 {
|
||||
score -= 15
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct for too many open issues (relative scoring)
|
||||
if result.Issues != nil && result.Issues.Available {
|
||||
if result.Issues.OpenCount > 50 {
|
||||
score -= 10
|
||||
} else if result.Issues.OpenCount > 20 {
|
||||
score -= 5
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct for old/stale PRs
|
||||
if result.PullRequests != nil && result.PullRequests.Available {
|
||||
if result.PullRequests.OpenCount > 10 {
|
||||
score -= 5
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct for workflow failures
|
||||
if result.WorkflowStatus != nil && result.WorkflowStatus.Available {
|
||||
if result.WorkflowStatus.LastRunConclusion == "failure" {
|
||||
score -= 15
|
||||
} else if result.WorkflowStatus.LastRunConclusion == "cancelled" {
|
||||
score -= 5
|
||||
}
|
||||
if !result.WorkflowStatus.HasRecentRuns {
|
||||
score -= 5
|
||||
}
|
||||
}
|
||||
|
||||
// Bonus for good practices
|
||||
if result.BranchProtection != nil && result.BranchProtection.Available {
|
||||
if result.BranchProtection.ProtectedBranchesCount > 0 {
|
||||
score += 5 // Bonus for having protected branches
|
||||
}
|
||||
}
|
||||
|
||||
// Penalty for archived repos
|
||||
if result.RepositoryInfo != nil && result.RepositoryInfo.IsArchived {
|
||||
score -= 40
|
||||
}
|
||||
|
||||
// Ensure score is within bounds
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func getHealthStatus(score int) string {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return "excellent"
|
||||
case score >= 70:
|
||||
return "good"
|
||||
case score >= 50:
|
||||
return "fair"
|
||||
case score >= 30:
|
||||
return "poor"
|
||||
default:
|
||||
return "critical"
|
||||
}
|
||||
}
|
||||
|
||||
func getStringFromMap(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculateHealthScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *HealthResult
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "perfect health - active repo",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{
|
||||
Available: true,
|
||||
DaysAgo: 5,
|
||||
},
|
||||
Issues: &IssuesInfo{
|
||||
Available: true,
|
||||
OpenCount: 5,
|
||||
},
|
||||
PullRequests: &PullRequestsInfo{
|
||||
Available: true,
|
||||
OpenCount: 2,
|
||||
},
|
||||
WorkflowStatus: &WorkflowStatusInfo{
|
||||
Available: true,
|
||||
LastRunConclusion: "success",
|
||||
HasRecentRuns: true,
|
||||
},
|
||||
BranchProtection: &BranchProtectionInfo{
|
||||
Available: true,
|
||||
ProtectedBranchesCount: 1,
|
||||
},
|
||||
RepositoryInfo: &RepositoryInfo{
|
||||
IsArchived: false,
|
||||
},
|
||||
},
|
||||
expected: 100,
|
||||
},
|
||||
{
|
||||
name: "stale commits - 35 days",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{
|
||||
Available: true,
|
||||
DaysAgo: 35,
|
||||
},
|
||||
Issues: &IssuesInfo{
|
||||
Available: true,
|
||||
OpenCount: 5,
|
||||
},
|
||||
},
|
||||
expected: 85,
|
||||
},
|
||||
{
|
||||
name: "very stale commits - 100 days",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{
|
||||
Available: true,
|
||||
DaysAgo: 100,
|
||||
},
|
||||
Issues: &IssuesInfo{
|
||||
Available: true,
|
||||
OpenCount: 5,
|
||||
},
|
||||
},
|
||||
expected: 70,
|
||||
},
|
||||
{
|
||||
name: "too many open issues",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{
|
||||
Available: true,
|
||||
DaysAgo: 5,
|
||||
},
|
||||
Issues: &IssuesInfo{
|
||||
Available: true,
|
||||
OpenCount: 60,
|
||||
},
|
||||
},
|
||||
expected: 90,
|
||||
},
|
||||
{
|
||||
name: "workflow failure",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{
|
||||
Available: true,
|
||||
DaysAgo: 5,
|
||||
},
|
||||
WorkflowStatus: &WorkflowStatusInfo{
|
||||
Available: true,
|
||||
LastRunConclusion: "failure",
|
||||
HasRecentRuns: true,
|
||||
},
|
||||
},
|
||||
expected: 85,
|
||||
},
|
||||
{
|
||||
name: "archived repository",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{
|
||||
Available: true,
|
||||
DaysAgo: 5,
|
||||
},
|
||||
RepositoryInfo: &RepositoryInfo{
|
||||
IsArchived: true,
|
||||
},
|
||||
},
|
||||
expected: 60,
|
||||
},
|
||||
{
|
||||
name: "empty result - no data",
|
||||
result: &HealthResult{},
|
||||
expected: 100,
|
||||
},
|
||||
{
|
||||
name: "boundary - minimum score",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{
|
||||
Available: true,
|
||||
DaysAgo: 1000,
|
||||
},
|
||||
RepositoryInfo: &RepositoryInfo{
|
||||
IsArchived: true,
|
||||
},
|
||||
WorkflowStatus: &WorkflowStatusInfo{
|
||||
Available: true,
|
||||
LastRunConclusion: "failure",
|
||||
HasRecentRuns: false,
|
||||
},
|
||||
Issues: &IssuesInfo{
|
||||
Available: true,
|
||||
OpenCount: 100,
|
||||
},
|
||||
},
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := calculateHealthScore(tt.result)
|
||||
if score != tt.expected {
|
||||
t.Errorf("calculateHealthScore() = %d, want %d", score, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
score int
|
||||
expected string
|
||||
}{
|
||||
{95, "excellent"},
|
||||
{90, "excellent"},
|
||||
{85, "good"},
|
||||
{70, "good"},
|
||||
{60, "fair"},
|
||||
{50, "fair"},
|
||||
{40, "poor"},
|
||||
{30, "poor"},
|
||||
{20, "critical"},
|
||||
{0, "critical"},
|
||||
{100, "excellent"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expected, func(t *testing.T) {
|
||||
status := getHealthStatus(tt.score)
|
||||
if status != tt.expected {
|
||||
t.Errorf("getHealthStatus(%d) = %s, want %s", tt.score, status, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStringFromMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m map[string]any
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "string value",
|
||||
m: map[string]any{"status": "success"},
|
||||
key: "status",
|
||||
expected: "success",
|
||||
},
|
||||
{
|
||||
name: "missing key",
|
||||
m: map[string]any{"other": "value"},
|
||||
key: "status",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "non-string value",
|
||||
m: map[string]any{"count": 42},
|
||||
key: "count",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
m: map[string]any{},
|
||||
key: "status",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getStringFromMap(tt.m, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getStringFromMap() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthResultJSONMarshaling(t *testing.T) {
|
||||
result := &HealthResult{
|
||||
Repository: "owner/repo",
|
||||
HealthScore: 85,
|
||||
HealthStatus: "good",
|
||||
LastCommit: &CommitInfo{
|
||||
SHA: "abc123",
|
||||
Message: "Initial commit",
|
||||
Author: "user",
|
||||
Date: "2024-01-15T10:30:00Z",
|
||||
DaysAgo: 5,
|
||||
Available: true,
|
||||
},
|
||||
Issues: &IssuesInfo{
|
||||
OpenCount: 10,
|
||||
TotalCount: 50,
|
||||
Available: true,
|
||||
},
|
||||
PullRequests: &PullRequestsInfo{
|
||||
OpenCount: 3,
|
||||
TotalCount: 15,
|
||||
Available: true,
|
||||
},
|
||||
WorkflowStatus: &WorkflowStatusInfo{
|
||||
LastRunStatus: "completed",
|
||||
LastRunConclusion: "success",
|
||||
HasRecentRuns: true,
|
||||
Available: true,
|
||||
},
|
||||
BranchProtection: &BranchProtectionInfo{
|
||||
ProtectedBranchesCount: 2,
|
||||
ProtectedBranches: []string{"main", "develop"},
|
||||
Available: true,
|
||||
},
|
||||
RepositoryInfo: &RepositoryInfo{
|
||||
Stars: 100,
|
||||
Forks: 20,
|
||||
Language: "Go",
|
||||
IsPrivate: false,
|
||||
IsArchived: false,
|
||||
Available: true,
|
||||
},
|
||||
CheckedAt: "2024-01-20T10:00:00Z",
|
||||
PartialResult: false,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal HealthResult: %v", err)
|
||||
}
|
||||
|
||||
if len(jsonBytes) == 0 {
|
||||
t.Error("Expected non-empty JSON output")
|
||||
}
|
||||
|
||||
var unmarshaled HealthResult
|
||||
if err := json.Unmarshal(jsonBytes, &unmarshaled); err != nil {
|
||||
t.Fatalf("Failed to unmarshal HealthResult: %v", err)
|
||||
}
|
||||
|
||||
if unmarshaled.HealthScore != result.HealthScore {
|
||||
t.Errorf("HealthScore mismatch: got %d, want %d", unmarshaled.HealthScore, result.HealthScore)
|
||||
}
|
||||
|
||||
if unmarshaled.HealthStatus != result.HealthStatus {
|
||||
t.Errorf("HealthStatus mismatch: got %s, want %s", unmarshaled.HealthStatus, result.HealthStatus)
|
||||
}
|
||||
|
||||
if unmarshaled.LastCommit == nil || unmarshaled.LastCommit.SHA != result.LastCommit.SHA {
|
||||
t.Error("LastCommit mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateHealthScore_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *HealthResult
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "all nil fields",
|
||||
result: &HealthResult{
|
||||
LastCommit: nil,
|
||||
Issues: nil,
|
||||
PullRequests: nil,
|
||||
WorkflowStatus: nil,
|
||||
BranchProtection: nil,
|
||||
RepositoryInfo: nil,
|
||||
},
|
||||
expected: 100,
|
||||
},
|
||||
{
|
||||
name: "unavailable fields",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: false},
|
||||
Issues: &IssuesInfo{Available: false},
|
||||
},
|
||||
expected: 100,
|
||||
},
|
||||
{
|
||||
name: "stale commits boundary - exactly 30 days",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 30},
|
||||
},
|
||||
expected: 100,
|
||||
},
|
||||
{
|
||||
name: "stale commits boundary - exactly 31 days",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 31},
|
||||
},
|
||||
expected: 85,
|
||||
},
|
||||
{
|
||||
name: "stale commits boundary - exactly 90 days",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 90},
|
||||
},
|
||||
expected: 85,
|
||||
},
|
||||
{
|
||||
name: "stale commits boundary - exactly 91 days",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 91},
|
||||
},
|
||||
expected: 70,
|
||||
},
|
||||
{
|
||||
name: "issues boundary - exactly 20",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
|
||||
Issues: &IssuesInfo{Available: true, OpenCount: 20},
|
||||
},
|
||||
expected: 100,
|
||||
},
|
||||
{
|
||||
name: "issues boundary - exactly 21",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
|
||||
Issues: &IssuesInfo{Available: true, OpenCount: 21},
|
||||
},
|
||||
expected: 95,
|
||||
},
|
||||
{
|
||||
name: "issues boundary - exactly 50",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
|
||||
Issues: &IssuesInfo{Available: true, OpenCount: 50},
|
||||
},
|
||||
expected: 95,
|
||||
},
|
||||
{
|
||||
name: "issues boundary - exactly 51",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
|
||||
Issues: &IssuesInfo{Available: true, OpenCount: 51},
|
||||
},
|
||||
expected: 90,
|
||||
},
|
||||
{
|
||||
name: "PRs boundary - exactly 10",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
|
||||
PullRequests: &PullRequestsInfo{Available: true, OpenCount: 10},
|
||||
},
|
||||
expected: 100,
|
||||
},
|
||||
{
|
||||
name: "PRs boundary - exactly 11",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
|
||||
PullRequests: &PullRequestsInfo{Available: true, OpenCount: 11},
|
||||
},
|
||||
expected: 95,
|
||||
},
|
||||
{
|
||||
name: "workflow cancelled",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
|
||||
WorkflowStatus: &WorkflowStatusInfo{
|
||||
Available: true,
|
||||
LastRunConclusion: "cancelled",
|
||||
HasRecentRuns: true,
|
||||
},
|
||||
},
|
||||
expected: 95,
|
||||
},
|
||||
{
|
||||
name: "workflow no recent runs",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 5},
|
||||
WorkflowStatus: &WorkflowStatusInfo{
|
||||
Available: true,
|
||||
LastRunConclusion: "success",
|
||||
HasRecentRuns: false,
|
||||
},
|
||||
},
|
||||
expected: 95,
|
||||
},
|
||||
{
|
||||
name: "archived with negative score",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 1000},
|
||||
RepositoryInfo: &RepositoryInfo{
|
||||
IsArchived: true,
|
||||
},
|
||||
WorkflowStatus: &WorkflowStatusInfo{
|
||||
Available: true,
|
||||
LastRunConclusion: "failure",
|
||||
HasRecentRuns: false,
|
||||
},
|
||||
},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "maximum score cap",
|
||||
result: &HealthResult{
|
||||
LastCommit: &CommitInfo{Available: true, DaysAgo: 0},
|
||||
Issues: &IssuesInfo{Available: true, OpenCount: 0},
|
||||
BranchProtection: &BranchProtectionInfo{
|
||||
Available: true,
|
||||
ProtectedBranchesCount: 10,
|
||||
},
|
||||
},
|
||||
expected: 100,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := calculateHealthScore(tt.result)
|
||||
if score != tt.expected {
|
||||
t.Errorf("calculateHealthScore() = %d, want %d", score, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthStatus_Boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
score int
|
||||
expected string
|
||||
}{
|
||||
{100, "excellent"},
|
||||
{91, "excellent"},
|
||||
{89, "good"},
|
||||
{71, "good"},
|
||||
{69, "fair"},
|
||||
{51, "fair"},
|
||||
{49, "poor"},
|
||||
{31, "poor"},
|
||||
{29, "critical"},
|
||||
{1, "critical"},
|
||||
{-10, "critical"},
|
||||
{110, "excellent"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("score_%d", tt.score), func(t *testing.T) {
|
||||
status := getHealthStatus(tt.score)
|
||||
if status != tt.expected {
|
||||
t.Errorf("getHealthStatus(%d) = %s, want %s", tt.score, status, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStringFromMap_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m map[string]any
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "nil map",
|
||||
m: nil,
|
||||
key: "status",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
m: map[string]any{},
|
||||
key: "status",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "int value",
|
||||
m: map[string]any{"count": int(42)},
|
||||
key: "count",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "float64 value",
|
||||
m: map[string]any{"count": float64(42)},
|
||||
key: "count",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "bool value",
|
||||
m: map[string]any{"active": true},
|
||||
key: "active",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nested map value",
|
||||
m: map[string]any{"data": map[string]any{"key": "value"}},
|
||||
key: "data",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "slice value",
|
||||
m: map[string]any{"items": []string{"a", "b"}},
|
||||
key: "items",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty string value",
|
||||
m: map[string]any{"name": ""},
|
||||
key: "name",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getStringFromMap(tt.m, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getStringFromMap() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckStructs(t *testing.T) {
|
||||
t.Run("CommitInfo", func(t *testing.T) {
|
||||
commit := &CommitInfo{
|
||||
SHA: "abc123",
|
||||
Message: "Initial commit",
|
||||
Author: "user@example.com",
|
||||
Date: "2024-01-15T10:30:00Z",
|
||||
DaysAgo: 5,
|
||||
Available: true,
|
||||
}
|
||||
if commit.SHA != "abc123" {
|
||||
t.Error("SHA mismatch")
|
||||
}
|
||||
if commit.DaysAgo != 5 {
|
||||
t.Error("DaysAgo mismatch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IssuesInfo", func(t *testing.T) {
|
||||
issues := &IssuesInfo{
|
||||
OpenCount: 10,
|
||||
TotalCount: 50,
|
||||
Available: true,
|
||||
}
|
||||
if issues.OpenCount != 10 {
|
||||
t.Error("OpenCount mismatch")
|
||||
}
|
||||
if !issues.Available {
|
||||
t.Error("Available should be true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PullRequestsInfo", func(t *testing.T) {
|
||||
prs := &PullRequestsInfo{
|
||||
OpenCount: 3,
|
||||
TotalCount: 15,
|
||||
Available: true,
|
||||
}
|
||||
if prs.OpenCount != 3 {
|
||||
t.Error("OpenCount mismatch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WorkflowStatusInfo", func(t *testing.T) {
|
||||
wf := &WorkflowStatusInfo{
|
||||
LastRunStatus: "completed",
|
||||
LastRunConclusion: "success",
|
||||
HasRecentRuns: true,
|
||||
Available: true,
|
||||
Error: "",
|
||||
}
|
||||
if wf.LastRunConclusion != "success" {
|
||||
t.Error("LastRunConclusion mismatch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BranchProtectionInfo", func(t *testing.T) {
|
||||
bp := &BranchProtectionInfo{
|
||||
ProtectedBranchesCount: 2,
|
||||
ProtectedBranches: []string{"main", "develop"},
|
||||
Available: true,
|
||||
}
|
||||
if bp.ProtectedBranchesCount != 2 {
|
||||
t.Error("ProtectedBranchesCount mismatch")
|
||||
}
|
||||
if len(bp.ProtectedBranches) != 2 {
|
||||
t.Error("ProtectedBranches length mismatch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RepositoryInfo", func(t *testing.T) {
|
||||
repo := &RepositoryInfo{
|
||||
Stars: 100,
|
||||
Forks: 20,
|
||||
Language: "Go",
|
||||
IsPrivate: false,
|
||||
IsArchived: false,
|
||||
Available: true,
|
||||
}
|
||||
if repo.Stars != 100 {
|
||||
t.Error("Stars mismatch")
|
||||
}
|
||||
if repo.IsArchived {
|
||||
t.Error("IsArchived should be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HealthCheckError", func(t *testing.T) {
|
||||
err := HealthCheckError{
|
||||
Check: "workflow_status",
|
||||
Error: "API not available",
|
||||
}
|
||||
if err.Check != "workflow_status" {
|
||||
t.Error("Check mismatch")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHealthResultErrors(t *testing.T) {
|
||||
result := &HealthResult{
|
||||
Repository: "owner/repo",
|
||||
HealthScore: 75,
|
||||
HealthStatus: "good",
|
||||
Errors: []HealthCheckError{
|
||||
{Check: "workflow_status", Error: "API not available"},
|
||||
{Check: "branch_protection", Error: "No permissions"},
|
||||
},
|
||||
PartialResult: true,
|
||||
CheckedAt: "2024-01-20T10:00:00Z",
|
||||
}
|
||||
|
||||
if !result.PartialResult {
|
||||
t.Error("PartialResult should be true when errors exist")
|
||||
}
|
||||
if len(result.Errors) != 2 {
|
||||
t.Errorf("Errors count = %d, want 2", len(result.Errors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthResultWithNilFields(t *testing.T) {
|
||||
result := &HealthResult{
|
||||
Repository: "owner/repo",
|
||||
HealthScore: 100,
|
||||
HealthStatus: "excellent",
|
||||
LastCommit: nil,
|
||||
Issues: nil,
|
||||
PullRequests: nil,
|
||||
WorkflowStatus: nil,
|
||||
BranchProtection: nil,
|
||||
RepositoryInfo: nil,
|
||||
Errors: []HealthCheckError{},
|
||||
CheckedAt: "2024-01-20T10:00:00Z",
|
||||
PartialResult: false,
|
||||
}
|
||||
|
||||
score := calculateHealthScore(result)
|
||||
if score != 100 {
|
||||
t.Errorf("calculateHealthScore() with nil fields = %d, want 100", score)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
CreateReleaseToolName = "create_release"
|
||||
DeleteReleaseToolName = "delete_release"
|
||||
GetReleaseToolName = "get_release"
|
||||
GetLatestReleaseToolName = "get_latest_release"
|
||||
ListReleasesToolName = "list_releases"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateReleaseTool = mcp.NewTool(
|
||||
CreateReleaseToolName,
|
||||
mcp.WithDescription("Create release"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
|
||||
mcp.WithString("target", mcp.Required(), mcp.Description("target commitish")),
|
||||
mcp.WithString("title", mcp.Required(), mcp.Description("release title")),
|
||||
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
|
||||
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
|
||||
mcp.WithString("body", mcp.Description("release body")),
|
||||
)
|
||||
|
||||
DeleteReleaseTool = mcp.NewTool(
|
||||
DeleteReleaseToolName,
|
||||
mcp.WithDescription("Delete release"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
|
||||
)
|
||||
|
||||
GetReleaseTool = mcp.NewTool(
|
||||
GetReleaseToolName,
|
||||
mcp.WithDescription("Get release"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
|
||||
)
|
||||
|
||||
GetLatestReleaseTool = mcp.NewTool(
|
||||
GetLatestReleaseToolName,
|
||||
mcp.WithDescription("Get latest release"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
)
|
||||
|
||||
ListReleasesTool = mcp.NewTool(
|
||||
ListReleasesToolName,
|
||||
mcp.WithDescription("List releases"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
|
||||
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
|
||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: CreateReleaseTool,
|
||||
Handler: CreateReleaseFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: DeleteReleaseTool,
|
||||
Handler: DeleteReleaseFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: GetReleaseTool,
|
||||
Handler: GetReleaseFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: GetLatestReleaseTool,
|
||||
Handler: GetLatestReleaseFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: ListReleasesTool,
|
||||
Handler: ListReleasesFn,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called CreateReleasesFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
tagName, err := params.GetString(args, "tag_name")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
target, err := params.GetString(args, "target")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
title, err := params.GetString(args, "title")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
isDraft, _ := args["is_draft"].(bool)
|
||||
isPreRelease, _ := args["is_pre_release"].(bool)
|
||||
body, _ := args["body"].(string)
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
_, _, err = client.CreateRelease(owner, repo, gitea_sdk.CreateReleaseOption{
|
||||
TagName: tagName,
|
||||
Target: target,
|
||||
Title: title,
|
||||
Note: body,
|
||||
IsDraft: isDraft,
|
||||
IsPrerelease: isPreRelease,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create release error: %v", err)
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText("Release Created"), nil
|
||||
}
|
||||
|
||||
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called DeleteReleaseFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
id, err := params.GetIndex(args, "id")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
_, err = client.DeleteRelease(owner, repo, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete release error: %v", err)
|
||||
}
|
||||
|
||||
return to.TextResult("Release deleted successfully")
|
||||
}
|
||||
|
||||
func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called GetReleaseFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
id, err := params.GetIndex(args, "id")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
release, _, err := client.GetRelease(owner, repo, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get release error: %v", err)
|
||||
}
|
||||
|
||||
return to.TextResult(slimRelease(release))
|
||||
}
|
||||
|
||||
func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called GetLatestReleaseFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
release, _, err := client.GetLatestRelease(owner, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get latest release error: %v", err)
|
||||
}
|
||||
|
||||
return to.TextResult(slimRelease(release))
|
||||
}
|
||||
|
||||
func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ListReleasesFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
var pIsDraft *bool
|
||||
isDraft, ok := args["is_draft"].(bool)
|
||||
if ok {
|
||||
pIsDraft = new(isDraft)
|
||||
}
|
||||
var pIsPreRelease *bool
|
||||
isPreRelease, ok := args["is_pre_release"].(bool)
|
||||
if ok {
|
||||
pIsPreRelease = new(isPreRelease)
|
||||
}
|
||||
page := params.GetOptionalInt(args, "page", 1)
|
||||
pageSize := params.GetOptionalInt(args, "perPage", 20)
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
releases, _, err := client.ListReleases(owner, repo, gitea_sdk.ListReleasesOptions{
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: int(page),
|
||||
PageSize: int(pageSize),
|
||||
},
|
||||
IsDraft: pIsDraft,
|
||||
IsPreRelease: pIsPreRelease,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list releases error: %v", err)
|
||||
}
|
||||
|
||||
return to.TextResult(slimReleases(releases))
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/tool"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
var Tool = tool.New()
|
||||
|
||||
const (
|
||||
CreateRepoToolName = "create_repo"
|
||||
ForkRepoToolName = "fork_repo"
|
||||
ListMyReposToolName = "list_my_repos"
|
||||
ListOrgReposToolName = "list_org_repos"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateRepoTool = mcp.NewTool(
|
||||
CreateRepoToolName,
|
||||
mcp.WithDescription("Create repository in personal account or organization"),
|
||||
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the repository to create")),
|
||||
mcp.WithString("description", mcp.Description("Description of the repository to create")),
|
||||
mcp.WithBoolean("private", mcp.Description("Whether the repository is private")),
|
||||
mcp.WithString("issue_labels", mcp.Description("Issue Label set to use")),
|
||||
mcp.WithBoolean("auto_init", mcp.Description("Whether the repository should be auto-intialized?")),
|
||||
mcp.WithBoolean("template", mcp.Description("Whether the repository is template")),
|
||||
mcp.WithString("gitignores", mcp.Description("Gitignores to use")),
|
||||
mcp.WithString("license", mcp.Description("License to use")),
|
||||
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
|
||||
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
|
||||
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
|
||||
)
|
||||
|
||||
ForkRepoTool = mcp.NewTool(
|
||||
ForkRepoToolName,
|
||||
mcp.WithDescription("Fork repository"),
|
||||
mcp.WithString("user", mcp.Required(), mcp.Description("User name of the repository to fork")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name to fork")),
|
||||
mcp.WithString("organization", mcp.Description("Organization name to fork")),
|
||||
mcp.WithString("name", mcp.Description("Name of the forked repository")),
|
||||
)
|
||||
|
||||
ListMyReposTool = mcp.NewTool(
|
||||
ListMyReposToolName,
|
||||
mcp.WithDescription("List my repositories"),
|
||||
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||
)
|
||||
|
||||
ListOrgReposTool = mcp.NewTool(
|
||||
ListOrgReposToolName,
|
||||
mcp.WithDescription("List repositories of an organization"),
|
||||
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
|
||||
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: CreateRepoTool,
|
||||
Handler: CreateRepoFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: ForkRepoTool,
|
||||
Handler: ForkRepoFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: ListMyReposTool,
|
||||
Handler: ListMyReposFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: ListOrgReposTool,
|
||||
Handler: ListOrgReposFn,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called CreateRepoFn")
|
||||
args := req.GetArguments()
|
||||
name, err := params.GetString(args, "name")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
description, _ := args["description"].(string)
|
||||
private, _ := args["private"].(bool)
|
||||
issueLabels, _ := args["issue_labels"].(string)
|
||||
autoInit, _ := args["auto_init"].(bool)
|
||||
template, _ := args["template"].(bool)
|
||||
gitignores, _ := args["gitignores"].(string)
|
||||
license, _ := args["license"].(string)
|
||||
readme, _ := args["readme"].(string)
|
||||
defaultBranch, _ := args["default_branch"].(string)
|
||||
organization, _ := args["organization"].(string)
|
||||
|
||||
opt := gitea_sdk.CreateRepoOption{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Private: private,
|
||||
IssueLabels: issueLabels,
|
||||
AutoInit: autoInit,
|
||||
Template: template,
|
||||
Gitignores: gitignores,
|
||||
License: license,
|
||||
Readme: readme,
|
||||
DefaultBranch: defaultBranch,
|
||||
}
|
||||
|
||||
var repo *gitea_sdk.Repository
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
if organization != "" {
|
||||
repo, _, err = client.CreateOrgRepo(organization, opt)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("create organization repository '%s' in '%s' err: %v", name, organization, err))
|
||||
}
|
||||
} else {
|
||||
repo, _, err = client.CreateRepo(opt)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err))
|
||||
}
|
||||
}
|
||||
return to.TextResult(slimRepo(repo))
|
||||
}
|
||||
|
||||
func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ForkRepoFn")
|
||||
args := req.GetArguments()
|
||||
user, err := params.GetString(args, "user")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
organization, ok := args["organization"].(string)
|
||||
organizationPtr := new(organization)
|
||||
if !ok || organization == "" {
|
||||
organizationPtr = nil
|
||||
}
|
||||
name, ok := args["name"].(string)
|
||||
namePtr := new(name)
|
||||
if !ok || name == "" {
|
||||
namePtr = nil
|
||||
}
|
||||
opt := gitea_sdk.CreateForkOption{
|
||||
Organization: organizationPtr,
|
||||
Name: namePtr,
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
_, _, err = client.CreateFork(user, repo, opt)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("fork repository error: %v", err))
|
||||
}
|
||||
return to.TextResult("Fork success")
|
||||
}
|
||||
|
||||
func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ListMyReposFn")
|
||||
page, pageSize := params.GetPagination(req.GetArguments(), 30)
|
||||
opt := gitea_sdk.ListReposOptions{
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
},
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
repos, _, err := client.ListMyRepos(opt)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err))
|
||||
}
|
||||
|
||||
return to.TextResult(slimRepos(repos))
|
||||
}
|
||||
|
||||
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ListOrgReposFn")
|
||||
org, ok := req.GetArguments()["org"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(errors.New("organization name is required"))
|
||||
}
|
||||
page, ok := req.GetArguments()["page"].(float64)
|
||||
if !ok {
|
||||
page = 1
|
||||
}
|
||||
pageSize, ok := req.GetArguments()["pageSize"].(float64)
|
||||
if !ok {
|
||||
pageSize = 100
|
||||
}
|
||||
opt := gitea_sdk.ListOrgReposOptions{
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: int(page),
|
||||
PageSize: int(pageSize),
|
||||
},
|
||||
}
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
repos, _, err := client.ListOrgRepos(org, opt)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("list organization '%s' repositories error: %v", org, err))
|
||||
}
|
||||
return to.TextResult(repos)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
func userLogin(u *gitea_sdk.User) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.UserName
|
||||
}
|
||||
|
||||
func slimRepo(r *gitea_sdk.Repository) map[string]any {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]any{
|
||||
"id": r.ID,
|
||||
"full_name": r.FullName,
|
||||
"description": r.Description,
|
||||
"html_url": r.HTMLURL,
|
||||
"clone_url": r.CloneURL,
|
||||
"ssh_url": r.SSHURL,
|
||||
"default_branch": r.DefaultBranch,
|
||||
"private": r.Private,
|
||||
"fork": r.Fork,
|
||||
"archived": r.Archived,
|
||||
"language": r.Language,
|
||||
"stars_count": r.Stars,
|
||||
"forks_count": r.Forks,
|
||||
"open_issues_count": r.OpenIssues,
|
||||
"open_pr_counter": r.OpenPulls,
|
||||
"created_at": r.Created,
|
||||
"updated_at": r.Updated,
|
||||
}
|
||||
if r.Owner != nil {
|
||||
m["owner"] = r.Owner.UserName
|
||||
}
|
||||
if len(r.Topics) > 0 {
|
||||
m["topics"] = r.Topics
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(repos))
|
||||
for _, r := range repos {
|
||||
out = append(out, slimRepo(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slimBranch(b *gitea_sdk.Branch) map[string]any {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]any{
|
||||
"name": b.Name,
|
||||
"protected": b.Protected,
|
||||
}
|
||||
if b.Commit != nil {
|
||||
m["commit_sha"] = b.Commit.ID
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func slimBranches(branches []*gitea_sdk.Branch) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(branches))
|
||||
for _, b := range branches {
|
||||
out = append(out, slimBranch(b))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slimCommit(c *gitea_sdk.Commit) map[string]any {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]any{
|
||||
"sha": c.SHA,
|
||||
"html_url": c.HTMLURL,
|
||||
"created": c.Created,
|
||||
}
|
||||
if c.RepoCommit != nil {
|
||||
m["message"] = c.RepoCommit.Message
|
||||
if c.RepoCommit.Author != nil {
|
||||
m["author"] = map[string]any{
|
||||
"name": c.RepoCommit.Author.Name,
|
||||
"email": c.RepoCommit.Author.Email,
|
||||
"date": c.RepoCommit.Author.Date,
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func slimCommits(commits []*gitea_sdk.Commit) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(commits))
|
||||
for _, c := range commits {
|
||||
out = append(out, slimCommit(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slimTag(t *gitea_sdk.Tag) map[string]any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]any{
|
||||
"name": t.Name,
|
||||
"message": t.Message,
|
||||
}
|
||||
if t.Commit != nil {
|
||||
m["commit_sha"] = t.Commit.SHA
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func slimTags(tags []*gitea_sdk.Tag) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
m := map[string]any{
|
||||
"name": t.Name,
|
||||
}
|
||||
if t.Commit != nil {
|
||||
m["commit_sha"] = t.Commit.SHA
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slimRelease(r *gitea_sdk.Release) map[string]any {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": r.ID,
|
||||
"tag_name": r.TagName,
|
||||
"target": r.Target,
|
||||
"title": r.Title,
|
||||
"body": r.Note,
|
||||
"draft": r.IsDraft,
|
||||
"prerelease": r.IsPrerelease,
|
||||
"html_url": r.HTMLURL,
|
||||
"author": userLogin(r.Publisher),
|
||||
"created_at": r.CreatedAt,
|
||||
"published_at": r.PublishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func slimReleases(releases []*gitea_sdk.Release) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(releases))
|
||||
for _, r := range releases {
|
||||
out = append(out, slimRelease(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]any{
|
||||
"name": c.Name,
|
||||
"path": c.Path,
|
||||
"sha": c.SHA,
|
||||
"type": c.Type,
|
||||
"size": c.Size,
|
||||
}
|
||||
if c.Content != nil {
|
||||
m["content"] = *c.Content
|
||||
}
|
||||
if c.Encoding != nil {
|
||||
m["encoding"] = *c.Encoding
|
||||
}
|
||||
if c.HTMLURL != nil {
|
||||
m["html_url"] = *c.HTMLURL
|
||||
}
|
||||
if c.DownloadURL != nil {
|
||||
m["download_url"] = *c.DownloadURL
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(entries))
|
||||
for _, c := range entries {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"name": c.Name,
|
||||
"path": c.Path,
|
||||
"type": c.Type,
|
||||
"size": c.Size,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
func TestSlimRepo(t *testing.T) {
|
||||
r := &gitea_sdk.Repository{
|
||||
ID: 1,
|
||||
FullName: "org/repo",
|
||||
Description: "A test repo",
|
||||
HTMLURL: "https://gitea.com/org/repo",
|
||||
CloneURL: "https://gitea.com/org/repo.git",
|
||||
SSHURL: "git@gitea.com:org/repo.git",
|
||||
DefaultBranch: "main",
|
||||
Private: false,
|
||||
Fork: false,
|
||||
Archived: false,
|
||||
Language: "Go",
|
||||
Stars: 10,
|
||||
Forks: 2,
|
||||
Owner: &gitea_sdk.User{UserName: "org"},
|
||||
Topics: []string{"mcp", "gitea"},
|
||||
}
|
||||
|
||||
m := slimRepo(r)
|
||||
|
||||
if m["full_name"] != "org/repo" {
|
||||
t.Errorf("expected full_name org/repo, got %v", m["full_name"])
|
||||
}
|
||||
if m["owner"] != "org" {
|
||||
t.Errorf("expected owner org, got %v", m["owner"])
|
||||
}
|
||||
topics := m["topics"].([]string)
|
||||
if len(topics) != 2 {
|
||||
t.Errorf("expected 2 topics, got %d", len(topics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimTag(t *testing.T) {
|
||||
tag := &gitea_sdk.Tag{
|
||||
Name: "v1.0.0",
|
||||
Message: "Release v1.0.0",
|
||||
Commit: &gitea_sdk.CommitMeta{SHA: "abc123"},
|
||||
}
|
||||
|
||||
m := slimTag(tag)
|
||||
if m["name"] != "v1.0.0" {
|
||||
t.Errorf("expected name v1.0.0, got %v", m["name"])
|
||||
}
|
||||
if m["message"] != "Release v1.0.0" {
|
||||
t.Errorf("expected message, got %v", m["message"])
|
||||
}
|
||||
|
||||
// List variant omits message
|
||||
list := slimTags([]*gitea_sdk.Tag{tag})
|
||||
if _, ok := list[0]["message"]; ok {
|
||||
t.Error("Tags list should omit message")
|
||||
}
|
||||
if list[0]["name"] != "v1.0.0" {
|
||||
t.Errorf("expected name in list, got %v", list[0]["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimRelease(t *testing.T) {
|
||||
r := &gitea_sdk.Release{
|
||||
ID: 1,
|
||||
TagName: "v1.0.0",
|
||||
Title: "First Release",
|
||||
Note: "Release notes",
|
||||
IsDraft: false,
|
||||
Publisher: &gitea_sdk.User{UserName: "alice"},
|
||||
}
|
||||
|
||||
m := slimRelease(r)
|
||||
if m["tag_name"] != "v1.0.0" {
|
||||
t.Errorf("expected tag_name v1.0.0, got %v", m["tag_name"])
|
||||
}
|
||||
if m["body"] != "Release notes" {
|
||||
t.Errorf("expected body from Note field, got %v", m["body"])
|
||||
}
|
||||
if m["author"] != "alice" {
|
||||
t.Errorf("expected author alice, got %v", m["author"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimContents(t *testing.T) {
|
||||
content := "package main"
|
||||
encoding := "base64"
|
||||
htmlURL := "https://gitea.com/org/repo/src/branch/main/main.go"
|
||||
c := &gitea_sdk.ContentsResponse{
|
||||
Name: "main.go",
|
||||
Path: "main.go",
|
||||
SHA: "abc123",
|
||||
Type: "file",
|
||||
Size: 12,
|
||||
Content: &content,
|
||||
Encoding: &encoding,
|
||||
HTMLURL: &htmlURL,
|
||||
}
|
||||
|
||||
m := slimContents(c)
|
||||
if m["name"] != "main.go" {
|
||||
t.Errorf("expected name main.go, got %v", m["name"])
|
||||
}
|
||||
if m["content"] != "package main" {
|
||||
t.Errorf("expected content, got %v", m["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimDirEntries(t *testing.T) {
|
||||
entries := []*gitea_sdk.ContentsResponse{
|
||||
{Name: "src", Path: "src", Type: "dir", Size: 0},
|
||||
{Name: "main.go", Path: "main.go", Type: "file", Size: 100},
|
||||
}
|
||||
|
||||
result := slimDirEntries(entries)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(result))
|
||||
}
|
||||
if result[0]["name"] != "src" {
|
||||
t.Errorf("expected first entry name src, got %v", result[0]["name"])
|
||||
}
|
||||
// Dir entries should not have content
|
||||
if _, ok := result[0]["content"]; ok {
|
||||
t.Error("dir entries should not have content field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimTags_Nil(t *testing.T) {
|
||||
if r := slimTags(nil); len(r) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimReleases_Nil(t *testing.T) {
|
||||
if r := slimReleases(nil); len(r) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/errors"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
CreateCommitStatusToolName = "create_commit_status"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateCommitStatusTool = mcp.NewTool(
|
||||
CreateCommitStatusToolName,
|
||||
mcp.WithDescription("Create a commit status check for a repository. Adds a new status context to a commit without overwriting existing statuses."),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("sha", mcp.Required(), mcp.Description("commit SHA (full 40-character SHA or short SHA)")),
|
||||
mcp.WithString("state", mcp.Required(), mcp.Description("status state: pending, success, error, or failure")),
|
||||
mcp.WithString("target_url", mcp.Description("URL with more details about the status (e.g., review environment link like https://review.lumbridgecorp.com)")),
|
||||
mcp.WithString("context", mcp.Description("status context identifier (e.g., 'ci/metal', 'ci/cloud-1', 'continuous-integration/jenkins')"), mcp.DefaultString("default")),
|
||||
mcp.WithString("description", mcp.Description("short description of the status")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: CreateCommitStatusTool,
|
||||
Handler: CreateCommitStatusFn,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateCommitStatusFn creates a status check for a commit
|
||||
func CreateCommitStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called CreateCommitStatusFn")
|
||||
args := req.GetArguments()
|
||||
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
sha, err := params.GetString(args, "sha")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
state, err := params.GetString(args, "state")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
statusState, err := parseStatusState(state)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("invalid state '%s': must be one of pending, success, failure, error", state))
|
||||
}
|
||||
|
||||
targetURL, _ := args["target_url"].(string)
|
||||
context, _ := args["context"].(string)
|
||||
description, _ := args["description"].(string)
|
||||
|
||||
// Use default context if not provided
|
||||
if context == "" {
|
||||
context = "default"
|
||||
}
|
||||
|
||||
opt := gitea_sdk.CreateStatusOption{
|
||||
State: statusState,
|
||||
TargetURL: targetURL,
|
||||
Context: context,
|
||||
Description: description,
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
|
||||
status, _, err := client.CreateStatus(owner, repo, sha, opt)
|
||||
if err != nil {
|
||||
translatedErr := errors.TranslateError(err, map[string]string{
|
||||
"operation": "CreateCommitStatus",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"sha": sha,
|
||||
"state": state,
|
||||
"context": context,
|
||||
})
|
||||
return to.ErrorResult(translatedErr)
|
||||
}
|
||||
|
||||
return to.TextResult(slimStatus(status))
|
||||
}
|
||||
|
||||
// parseStatusState converts a string state to gitea_sdk.StatusState
|
||||
func parseStatusState(state string) (gitea_sdk.StatusState, error) {
|
||||
switch state {
|
||||
case "pending":
|
||||
return gitea_sdk.StatusPending, nil
|
||||
case "success":
|
||||
return gitea_sdk.StatusSuccess, nil
|
||||
case "failure":
|
||||
return gitea_sdk.StatusFailure, nil
|
||||
case "error":
|
||||
return gitea_sdk.StatusError, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid state: %s", state)
|
||||
}
|
||||
}
|
||||
|
||||
// slimStatus creates a slimmed down representation of a commit status
|
||||
func slimStatus(s *gitea_sdk.Status) map[string]any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": s.ID,
|
||||
"state": s.State,
|
||||
"target_url": s.TargetURL,
|
||||
"context": s.Context,
|
||||
"description": s.Description,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
gitea_errors "gitea.com/gitea/gitea-mcp/pkg/errors"
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
func TestParseStatusState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state string
|
||||
wantState gitea_sdk.StatusState
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "pending",
|
||||
state: "pending",
|
||||
wantState: gitea_sdk.StatusPending,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
state: "success",
|
||||
wantState: gitea_sdk.StatusSuccess,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "failure",
|
||||
state: "failure",
|
||||
wantState: gitea_sdk.StatusFailure,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "error",
|
||||
state: "error",
|
||||
wantState: gitea_sdk.StatusError,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid state",
|
||||
state: "invalid",
|
||||
wantErr: true,
|
||||
errContains: "invalid state",
|
||||
},
|
||||
{
|
||||
name: "empty state",
|
||||
state: "",
|
||||
wantErr: true,
|
||||
errContains: "invalid state",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseStatusState(tt.state)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseStatusState() error = nil, wantErr %v", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !errors.Is(err, errors.New(tt.errContains)) {
|
||||
if !contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("parseStatusState() error = %v, should contain %v", err.Error(), tt.errContains)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("parseStatusState() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
if got != tt.wantState {
|
||||
t.Errorf("parseStatusState() = %v, want %v", got, tt.wantState)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status *gitea_sdk.Status
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "nil status",
|
||||
status: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "full status",
|
||||
status: &gitea_sdk.Status{
|
||||
ID: 123,
|
||||
State: gitea_sdk.StatusSuccess,
|
||||
TargetURL: "https://review.lumbridgecorp.com/project/commit/248ade7",
|
||||
Context: "ci/metal",
|
||||
Description: "Build succeeded on Metal",
|
||||
CreatedAt: "2024-01-15T10:30:00Z",
|
||||
},
|
||||
want: map[string]any{
|
||||
"id": int64(123),
|
||||
"state": gitea_sdk.StatusSuccess,
|
||||
"target_url": "https://review.lumbridgecorp.com/project/commit/248ade7",
|
||||
"context": "ci/metal",
|
||||
"description": "Build succeeded on Metal",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending status",
|
||||
status: &gitea_sdk.Status{
|
||||
ID: 456,
|
||||
State: gitea_sdk.StatusPending,
|
||||
Context: "ci/cloud-1",
|
||||
CreatedAt: "2024-01-15T10:31:00Z",
|
||||
},
|
||||
want: map[string]any{
|
||||
"id": int64(456),
|
||||
"state": gitea_sdk.StatusPending,
|
||||
"target_url": "",
|
||||
"context": "ci/cloud-1",
|
||||
"created_at": "2024-01-15T10:31:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := slimStatus(tt.status)
|
||||
if tt.want == nil {
|
||||
if got != nil {
|
||||
t.Errorf("slimStatus() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Errorf("slimStatus() = nil, want %v", tt.want)
|
||||
return
|
||||
}
|
||||
for key, wantVal := range tt.want {
|
||||
gotVal, ok := got[key]
|
||||
if !ok {
|
||||
t.Errorf("slimStatus() missing key %s", key)
|
||||
continue
|
||||
}
|
||||
if gotVal != wantVal {
|
||||
t.Errorf("slimStatus()[%s] = %v, want %v", key, gotVal, wantVal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTranslation_CreateCommitStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errMsg string
|
||||
expectedOp string
|
||||
expectedCtxKey string
|
||||
}{
|
||||
{
|
||||
name: "404 not found",
|
||||
errMsg: "CreateStatus: 404 Not Found",
|
||||
expectedOp: "CreateCommitStatus",
|
||||
expectedCtxKey: "sha",
|
||||
},
|
||||
{
|
||||
name: "401 unauthorized",
|
||||
errMsg: "CreateStatus: 401 Unauthorized",
|
||||
expectedOp: "CreateCommitStatus",
|
||||
expectedCtxKey: "context",
|
||||
},
|
||||
{
|
||||
name: "403 forbidden",
|
||||
errMsg: "CreateStatus: 403 Forbidden",
|
||||
expectedOp: "CreateCommitStatus",
|
||||
expectedCtxKey: "repo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errors.New(tt.errMsg)
|
||||
translated := gitea_errors.TranslateError(err, map[string]string{
|
||||
"operation": "CreateCommitStatus",
|
||||
"owner": "karti-ai",
|
||||
"repo": "gitcoffee",
|
||||
"sha": "248ade7a9c...",
|
||||
"state": "success",
|
||||
"context": "ci/metal",
|
||||
})
|
||||
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if !errors.As(translated, &enhanced) {
|
||||
t.Fatal("expected translated error to be EnhancedError")
|
||||
}
|
||||
|
||||
if enhanced.Operation != tt.expectedOp {
|
||||
t.Errorf("expected operation %s, got %s", tt.expectedOp, enhanced.Operation)
|
||||
}
|
||||
|
||||
if enhanced.Context[tt.expectedCtxKey] == "" {
|
||||
t.Errorf("expected context key %s to be set", tt.expectedCtxKey)
|
||||
}
|
||||
|
||||
t.Logf("Translated error: %s", enhanced.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusState_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state string
|
||||
wantState gitea_sdk.StatusState
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "mixed case pending",
|
||||
state: "Pending",
|
||||
wantErr: true,
|
||||
errContains: "invalid state",
|
||||
},
|
||||
{
|
||||
name: "mixed case success",
|
||||
state: "Success",
|
||||
wantErr: true,
|
||||
errContains: "invalid state",
|
||||
},
|
||||
{
|
||||
name: "whitespace pending",
|
||||
state: " pending",
|
||||
wantErr: true,
|
||||
errContains: "invalid state",
|
||||
},
|
||||
{
|
||||
name: "whitespace success",
|
||||
state: "success ",
|
||||
wantErr: true,
|
||||
errContains: "invalid state",
|
||||
},
|
||||
{
|
||||
name: "long invalid string",
|
||||
state: "this_is_not_a_valid_state",
|
||||
wantErr: true,
|
||||
errContains: "invalid state",
|
||||
},
|
||||
{
|
||||
name: "numeric string",
|
||||
state: "123",
|
||||
wantErr: true,
|
||||
errContains: "invalid state",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseStatusState(tt.state)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseStatusState() error = nil, wantErr %v", tt.wantErr)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("parseStatusState() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
if got != tt.wantState {
|
||||
t.Errorf("parseStatusState() = %v, want %v", got, tt.wantState)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimStatus_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status *gitea_sdk.Status
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "nil status",
|
||||
status: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "status with zero ID",
|
||||
status: &gitea_sdk.Status{
|
||||
ID: 0,
|
||||
State: gitea_sdk.StatusPending,
|
||||
TargetURL: "",
|
||||
Context: "",
|
||||
CreatedAt: "2024-01-15T10:30:00Z",
|
||||
},
|
||||
want: map[string]any{
|
||||
"id": int64(0),
|
||||
"state": gitea_sdk.StatusPending,
|
||||
"target_url": "",
|
||||
"context": "",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status with long URL",
|
||||
status: &gitea_sdk.Status{
|
||||
ID: 789,
|
||||
State: gitea_sdk.StatusSuccess,
|
||||
TargetURL: "https://very-long-review-environment-url.example.com/path/to/project/commit/248ade7a9c.../build/12345/logs?filter=all#section-2",
|
||||
Context: "continuous-integration/jenkins/build-and-test-all-platforms",
|
||||
Description: "Build succeeded on all platforms including Windows, macOS, and Linux with full test suite",
|
||||
CreatedAt: "2024-01-15T10:30:00Z",
|
||||
},
|
||||
want: map[string]any{
|
||||
"id": int64(789),
|
||||
"state": gitea_sdk.StatusSuccess,
|
||||
"target_url": "https://very-long-review-environment-url.example.com/path/to/project/commit/248ade7a9c.../build/12345/logs?filter=all#section-2",
|
||||
"context": "continuous-integration/jenkins/build-and-test-all-platforms",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failure status",
|
||||
status: &gitea_sdk.Status{
|
||||
ID: 101,
|
||||
State: gitea_sdk.StatusFailure,
|
||||
TargetURL: "https://ci.example.com/build/101",
|
||||
Context: "ci/build",
|
||||
Description: "Build failed",
|
||||
CreatedAt: "2024-01-15T11:00:00Z",
|
||||
},
|
||||
want: map[string]any{
|
||||
"id": int64(101),
|
||||
"state": gitea_sdk.StatusFailure,
|
||||
"target_url": "https://ci.example.com/build/101",
|
||||
"context": "ci/build",
|
||||
"created_at": "2024-01-15T11:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error status",
|
||||
status: &gitea_sdk.Status{
|
||||
ID: 102,
|
||||
State: gitea_sdk.StatusError,
|
||||
TargetURL: "",
|
||||
Context: "ci/error",
|
||||
Description: "Error occurred",
|
||||
CreatedAt: "2024-01-15T11:01:00Z",
|
||||
},
|
||||
want: map[string]any{
|
||||
"id": int64(102),
|
||||
"state": gitea_sdk.StatusError,
|
||||
"target_url": "",
|
||||
"context": "ci/error",
|
||||
"created_at": "2024-01-15T11:01:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := slimStatus(tt.status)
|
||||
if tt.want == nil {
|
||||
if got != nil {
|
||||
t.Errorf("slimStatus() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Errorf("slimStatus() = nil, want %v", tt.want)
|
||||
return
|
||||
}
|
||||
for key, wantVal := range tt.want {
|
||||
gotVal, ok := got[key]
|
||||
if !ok {
|
||||
t.Errorf("slimStatus() missing key %s", key)
|
||||
continue
|
||||
}
|
||||
if gotVal != wantVal {
|
||||
t.Errorf("slimStatus()[%s] = %v, want %v", key, gotVal, wantVal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTranslation_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errMsg string
|
||||
ctx map[string]string
|
||||
checkField string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "timeout error",
|
||||
errMsg: "request timeout",
|
||||
ctx: map[string]string{"operation": "CreateCommitStatus"},
|
||||
checkField: "operation",
|
||||
wantValue: "CreateCommitStatus",
|
||||
},
|
||||
{
|
||||
name: "network error",
|
||||
errMsg: "connection refused",
|
||||
ctx: map[string]string{"operation": "CreateCommitStatus"},
|
||||
checkField: "operation",
|
||||
wantValue: "CreateCommitStatus",
|
||||
},
|
||||
{
|
||||
name: "500 server error",
|
||||
errMsg: "500 Internal Server Error",
|
||||
ctx: map[string]string{"operation": "CreateCommitStatus"},
|
||||
checkField: "operation",
|
||||
wantValue: "CreateCommitStatus",
|
||||
},
|
||||
{
|
||||
name: "rate limit error",
|
||||
errMsg: "429 Too Many Requests",
|
||||
ctx: map[string]string{"operation": "CreateCommitStatus"},
|
||||
checkField: "operation",
|
||||
wantValue: "CreateCommitStatus",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errors.New(tt.errMsg)
|
||||
translated := gitea_errors.TranslateError(err, tt.ctx)
|
||||
|
||||
var enhanced *gitea_errors.EnhancedError
|
||||
if !errors.As(translated, &enhanced) {
|
||||
t.Fatal("expected translated error to be EnhancedError")
|
||||
}
|
||||
|
||||
if enhanced.Context[tt.checkField] != tt.wantValue {
|
||||
t.Errorf("expected context[%s] = %s, got %s", tt.checkField, tt.wantValue, enhanced.Context[tt.checkField])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimStatusFieldTypes(t *testing.T) {
|
||||
status := &gitea_sdk.Status{
|
||||
ID: int64(999),
|
||||
State: gitea_sdk.StatusSuccess,
|
||||
TargetURL: "https://example.com",
|
||||
Context: "test",
|
||||
Description: "desc",
|
||||
CreatedAt: "2024-01-15T10:30:00Z",
|
||||
}
|
||||
|
||||
slimmed := slimStatus(status)
|
||||
|
||||
if slimmed == nil {
|
||||
t.Fatal("slimStatus returned nil")
|
||||
}
|
||||
|
||||
if id, ok := slimmed["id"].(int64); !ok {
|
||||
t.Errorf("id should be int64, got %T", slimmed["id"])
|
||||
} else if id != 999 {
|
||||
t.Errorf("id = %d, want 999", id)
|
||||
}
|
||||
|
||||
if state, ok := slimmed["state"].(gitea_sdk.StatusState); !ok {
|
||||
t.Errorf("state should be StatusState, got %T", slimmed["state"])
|
||||
} else if state != gitea_sdk.StatusSuccess {
|
||||
t.Errorf("state = %v, want %v", state, gitea_sdk.StatusSuccess)
|
||||
}
|
||||
|
||||
for _, key := range []string{"target_url", "context", "description", "created_at"} {
|
||||
if val, ok := slimmed[key].(string); !ok && slimmed[key] != nil {
|
||||
t.Errorf("%s should be string, got %T", key, slimmed[key])
|
||||
} else if !ok {
|
||||
t.Errorf("%s should not be nil", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
|
||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
CreateTagToolName = "create_tag"
|
||||
DeleteTagToolName = "delete_tag"
|
||||
GetTagToolName = "get_tag"
|
||||
ListTagsToolName = "list_tags"
|
||||
)
|
||||
|
||||
var (
|
||||
CreateTagTool = mcp.NewTool(
|
||||
CreateTagToolName,
|
||||
mcp.WithDescription("Create tag"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
|
||||
mcp.WithString("target", mcp.Description("target commitish"), mcp.DefaultString("")),
|
||||
mcp.WithString("message", mcp.Description("tag message"), mcp.DefaultString("")),
|
||||
)
|
||||
|
||||
DeleteTagTool = mcp.NewTool(
|
||||
DeleteTagToolName,
|
||||
mcp.WithDescription("Delete tag"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
|
||||
)
|
||||
|
||||
GetTagTool = mcp.NewTool(
|
||||
GetTagToolName,
|
||||
mcp.WithDescription("Get tag"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
|
||||
)
|
||||
|
||||
ListTagsTool = mcp.NewTool(
|
||||
ListTagsToolName,
|
||||
mcp.WithDescription("List tags"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: CreateTagTool,
|
||||
Handler: CreateTagFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: DeleteTagTool,
|
||||
Handler: DeleteTagFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: GetTagTool,
|
||||
Handler: GetTagFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: ListTagsTool,
|
||||
Handler: ListTagsFn,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called CreateTagFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
tagName, err := params.GetString(args, "tag_name")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
target, _ := args["target"].(string)
|
||||
message, _ := args["message"].(string)
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
_, _, err = client.CreateTag(owner, repo, gitea_sdk.CreateTagOption{
|
||||
TagName: tagName,
|
||||
Target: target,
|
||||
Message: message,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create tag error: %v", err)
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText("Tag Created"), nil
|
||||
}
|
||||
|
||||
func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called DeleteTagFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
tagName, err := params.GetString(args, "tag_name")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
_, err = client.DeleteTag(owner, repo, tagName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete tag error: %v", err)
|
||||
}
|
||||
|
||||
return to.TextResult("Tag deleted")
|
||||
}
|
||||
|
||||
func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called GetTagFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
tagName, err := params.GetString(args, "tag_name")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
tag, _, err := client.GetTag(owner, repo, tagName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get tag error: %v", err)
|
||||
}
|
||||
|
||||
return to.TextResult(slimTag(tag))
|
||||
}
|
||||
|
||||
func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ListTagsFn")
|
||||
args := req.GetArguments()
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
page := params.GetOptionalInt(args, "page", 1)
|
||||
pageSize := params.GetOptionalInt(args, "perPage", 20)
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
tags, _, err := client.ListRepoTags(owner, repo, gitea_sdk.ListRepoTagsOptions{
|
||||
ListOptions: gitea_sdk.ListOptions{
|
||||
Page: int(page),
|
||||
PageSize: int(pageSize),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tags error: %v", err)
|
||||
}
|
||||
|
||||
return to.TextResult(slimTags(tags))
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/errors"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/params"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
const (
|
||||
ListRepoStructureToolName = "list_repo_structure"
|
||||
)
|
||||
|
||||
type TreeEntry struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
SHA string `json:"sha"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
type TreeResponse struct {
|
||||
SHA string `json:"sha"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Tree []TreeEntry `json:"tree"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
ListRepoStructureTool = mcp.NewTool(
|
||||
ListRepoStructureToolName,
|
||||
mcp.WithDescription("List the complete directory and file structure of a repository using Git tree API. Supports recursive listing and pattern filtering."),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("ref", mcp.Description("Git reference (branch, tag, or commit SHA). Defaults to default branch.")),
|
||||
mcp.WithString("pattern", mcp.Description("Glob pattern to filter files (e.g., '*.yml', '.gitea/*', 'src/**/*.go')")),
|
||||
mcp.WithBoolean("recursive", mcp.Description("List contents recursively (default: true)")),
|
||||
mcp.WithNumber("page", mcp.Description("Page number for pagination (1-based, default: 1)")),
|
||||
mcp.WithNumber("per_page", mcp.Description("Number of items per page (default: 100, max: 1000)")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: ListRepoStructureTool,
|
||||
Handler: ListRepoStructureFn,
|
||||
})
|
||||
}
|
||||
|
||||
func ListRepoStructureFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ListRepoStructureFn")
|
||||
|
||||
args := req.GetArguments()
|
||||
|
||||
owner, err := params.GetString(args, "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
repo, err := params.GetString(args, "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(err)
|
||||
}
|
||||
|
||||
ref, _ := args["ref"].(string)
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
|
||||
pattern, _ := args["pattern"].(string)
|
||||
|
||||
recursive := true
|
||||
if recursiveVal, ok := args["recursive"].(bool); ok {
|
||||
recursive = recursiveVal
|
||||
}
|
||||
|
||||
page := 1
|
||||
if pageVal, ok := args["page"].(float64); ok && pageVal > 0 {
|
||||
page = int(pageVal)
|
||||
}
|
||||
|
||||
perPage := 100
|
||||
if perPageVal, ok := args["per_page"].(float64); ok && perPageVal > 0 {
|
||||
perPage = int(perPageVal)
|
||||
if perPage > 1000 {
|
||||
perPage = 1000
|
||||
}
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
if recursive {
|
||||
query.Set("recursive", "1")
|
||||
}
|
||||
query.Set("page", fmt.Sprintf("%d", page))
|
||||
query.Set("per_page", fmt.Sprintf("%d", perPage))
|
||||
|
||||
path := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, ref)
|
||||
|
||||
var treeResp TreeResponse
|
||||
statusCode, err := gitea.DoJSON(ctx, "GET", path, query, nil, &treeResp)
|
||||
if err != nil {
|
||||
translatedErr := errors.TranslateError(err, map[string]string{
|
||||
"operation": "ListRepoStructure",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"ref": ref,
|
||||
"status": fmt.Sprintf("%d", statusCode),
|
||||
})
|
||||
return to.ErrorResult(translatedErr)
|
||||
}
|
||||
|
||||
filteredEntries := filterEntries(treeResp.Tree, pattern)
|
||||
|
||||
result := map[string]any{
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"ref": ref,
|
||||
"sha": treeResp.SHA,
|
||||
"truncated": treeResp.Truncated,
|
||||
"total_count": len(filteredEntries),
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"tree": slimTreeEntries(filteredEntries),
|
||||
}
|
||||
|
||||
if treeResp.Truncated {
|
||||
result["warning"] = "Tree listing was truncated due to size. Consider using pattern filtering or pagination."
|
||||
}
|
||||
|
||||
resultJSON, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("marshal result: %w", err))
|
||||
}
|
||||
|
||||
return to.TextResult(string(resultJSON))
|
||||
}
|
||||
|
||||
func filterEntries(entries []TreeEntry, pattern string) []TreeEntry {
|
||||
if pattern == "" {
|
||||
return entries
|
||||
}
|
||||
|
||||
filtered := make([]TreeEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if matchPattern(entry.Path, pattern) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func matchPattern(path, pattern string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
negate := false
|
||||
if strings.HasPrefix(pattern, "!") {
|
||||
negate = true
|
||||
pattern = strings.TrimPrefix(pattern, "!")
|
||||
}
|
||||
|
||||
matched, err := filepath.Match(pattern, path)
|
||||
if err == nil && matched {
|
||||
return !negate
|
||||
}
|
||||
|
||||
filename := filepath.Base(path)
|
||||
matched, err = filepath.Match(pattern, filename)
|
||||
if err == nil && matched {
|
||||
return !negate
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pattern, "**/") {
|
||||
suffix := strings.TrimPrefix(pattern, "**/")
|
||||
if strings.HasSuffix(path, suffix) {
|
||||
return !negate
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
for i := range parts {
|
||||
subPath := strings.Join(parts[i:], "/")
|
||||
if matched, _ := filepath.Match(suffix, subPath); matched {
|
||||
return !negate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasSuffix(pattern, "/*") || strings.HasSuffix(pattern, "/**") {
|
||||
dirPrefix := strings.TrimSuffix(pattern, "/*")
|
||||
dirPrefix = strings.TrimSuffix(dirPrefix, "/**")
|
||||
if strings.HasPrefix(path, dirPrefix+"/") {
|
||||
return !negate
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, pattern+"/") || path == pattern {
|
||||
return !negate
|
||||
}
|
||||
|
||||
return negate
|
||||
}
|
||||
|
||||
func slimTreeEntries(entries []TreeEntry) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
m := map[string]any{
|
||||
"path": e.Path,
|
||||
"type": e.Type,
|
||||
"sha": e.SHA,
|
||||
}
|
||||
if e.Type == "blob" && e.Size > 0 {
|
||||
m["size"] = e.Size
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
pattern string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
path: "README.md",
|
||||
pattern: "README.md",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard match - all md files",
|
||||
path: "docs/README.md",
|
||||
pattern: "*.md",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard match - yaml files",
|
||||
path: ".gitea/workflows/build.yml",
|
||||
pattern: "*.yml",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "directory prefix match",
|
||||
path: ".gitea/workflows/build.yml",
|
||||
pattern: ".gitea/*",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "recursive directory match",
|
||||
path: ".github/workflows/test.yml",
|
||||
pattern: ".github/**",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "double star pattern",
|
||||
path: "src/components/Button.tsx",
|
||||
pattern: "**/*.tsx",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "double star with prefix",
|
||||
path: "src/internal/utils/helpers.go",
|
||||
pattern: "src/**/*.go",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match - wrong extension",
|
||||
path: "main.go",
|
||||
pattern: "*.md",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no match - wrong directory",
|
||||
path: "docs/readme.md",
|
||||
pattern: ".gitea/*",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "negation pattern - exclude",
|
||||
path: "node_modules/lodash/index.js",
|
||||
pattern: "!node_modules/**",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "negation pattern - include others",
|
||||
path: "src/main.js",
|
||||
pattern: "!node_modules/**",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty pattern matches all",
|
||||
path: "any/path/file.txt",
|
||||
pattern: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact directory match",
|
||||
path: "src/components",
|
||||
pattern: "src",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "file inside directory",
|
||||
path: "src/components/Button.tsx",
|
||||
pattern: "src",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchPattern(tt.path, tt.pattern)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.path, tt.pattern, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEntries(t *testing.T) {
|
||||
entries := []TreeEntry{
|
||||
{Path: "README.md", Type: "blob", SHA: "abc123"},
|
||||
{Path: "main.go", Type: "blob", SHA: "def456"},
|
||||
{Path: "docs", Type: "tree", SHA: "ghi789"},
|
||||
{Path: "docs/guide.md", Type: "blob", SHA: "jkl012"},
|
||||
{Path: ".gitea/workflows/build.yml", Type: "blob", SHA: "mno345"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "no pattern returns all",
|
||||
pattern: "",
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
name: "filter markdown files",
|
||||
pattern: "*.md",
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "filter yaml files",
|
||||
pattern: "*.yml",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "filter by directory",
|
||||
pattern: ".gitea/*",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "filter go files",
|
||||
pattern: "*.go",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "no match returns empty",
|
||||
pattern: "*.py",
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filtered := filterEntries(entries, tt.pattern)
|
||||
if len(filtered) != tt.expected {
|
||||
t.Errorf("filterEntries() returned %d entries, want %d", len(filtered), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimTreeEntries(t *testing.T) {
|
||||
entries := []TreeEntry{
|
||||
{Path: "README.md", Type: "blob", SHA: "abc123", Size: 1024},
|
||||
{Path: "docs", Type: "tree", SHA: "def456", Size: 0},
|
||||
{Path: "main.go", Type: "blob", SHA: "ghi789", Size: 2048},
|
||||
}
|
||||
|
||||
slimmed := slimTreeEntries(entries)
|
||||
|
||||
if len(slimmed) != len(entries) {
|
||||
t.Errorf("slimTreeEntries() returned %d entries, want %d", len(slimmed), len(entries))
|
||||
}
|
||||
|
||||
for i, entry := range slimmed {
|
||||
if _, ok := entry["path"]; !ok {
|
||||
t.Errorf("entry %d missing 'path' field", i)
|
||||
}
|
||||
if _, ok := entry["type"]; !ok {
|
||||
t.Errorf("entry %d missing 'type' field", i)
|
||||
}
|
||||
if _, ok := entry["sha"]; !ok {
|
||||
t.Errorf("entry %d missing 'sha' field", i)
|
||||
}
|
||||
|
||||
entryType := entry["type"](string)
|
||||
_, hasSize := entry["size"]
|
||||
|
||||
if entryType == "blob" && entries[i].Size > 0 && !hasSize {
|
||||
t.Errorf("blob entry %d should have size field", i)
|
||||
}
|
||||
if entryType == "tree" && hasSize {
|
||||
t.Errorf("tree entry %d should not have size field", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPattern_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
pattern string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty path with empty pattern",
|
||||
path: "",
|
||||
pattern: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty path with pattern",
|
||||
path: "",
|
||||
pattern: "*.go",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "path with spaces",
|
||||
path: "path with spaces/file.txt",
|
||||
pattern: "*.txt",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "special characters in path",
|
||||
path: "path-with-dashes/file_name.txt",
|
||||
pattern: "*.txt",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "double star at start and end",
|
||||
path: "deep/nested/path/file.go",
|
||||
pattern: "**/*.go",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "negation with double star",
|
||||
path: "node_modules/deep/package.json",
|
||||
pattern: "!node_modules/**",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "complex glob pattern",
|
||||
path: "src/components/Button.test.tsx",
|
||||
pattern: "**/*.test.tsx",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "directory only pattern",
|
||||
path: "src/components",
|
||||
pattern: "src/*",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "trailing slash in directory",
|
||||
path: "src/components/",
|
||||
pattern: "src/*",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "single character wildcard",
|
||||
path: "file1.txt",
|
||||
pattern: "file?.txt",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "range pattern",
|
||||
path: "file5.txt",
|
||||
pattern: "file[0-9].txt",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchPattern(tt.path, tt.pattern)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.path, tt.pattern, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEntries_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []TreeEntry
|
||||
pattern string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "nil entries",
|
||||
entries: nil,
|
||||
pattern: "*.go",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "empty entries",
|
||||
entries: []TreeEntry{},
|
||||
pattern: "*.go",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "entries with empty paths",
|
||||
entries: []TreeEntry{
|
||||
{Path: "", Type: "blob", SHA: "abc123"},
|
||||
{Path: "main.go", Type: "blob", SHA: "def456"},
|
||||
},
|
||||
pattern: "*.go",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "negation pattern",
|
||||
entries: []TreeEntry{
|
||||
{Path: "test.go", Type: "blob", SHA: "abc123"},
|
||||
{Path: "vendor/lib.go", Type: "blob", SHA: "def456"},
|
||||
},
|
||||
pattern: "!vendor/**",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "complex pattern",
|
||||
entries: []TreeEntry{
|
||||
{Path: "src/main.go", Type: "blob", SHA: "abc123"},
|
||||
{Path: "src/test/main_test.go", Type: "blob", SHA: "def456"},
|
||||
{Path: "docs/readme.md", Type: "blob", SHA: "ghi789"},
|
||||
},
|
||||
pattern: "src/**/*.go",
|
||||
expected: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filtered := filterEntries(tt.entries, tt.pattern)
|
||||
if len(filtered) != tt.expected {
|
||||
t.Errorf("filterEntries() returned %d entries, want %d", len(filtered), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlimTreeEntries_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []TreeEntry
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "nil entries",
|
||||
entries: nil,
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "empty entries",
|
||||
entries: []TreeEntry{},
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "entry with zero size blob",
|
||||
entries: []TreeEntry{
|
||||
{Path: "empty.txt", Type: "blob", SHA: "abc", Size: 0},
|
||||
},
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "entry with symlink type",
|
||||
entries: []TreeEntry{
|
||||
{Path: "link", Type: "symlink", SHA: "def", Size: 0},
|
||||
},
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "many entries",
|
||||
entries: []TreeEntry{
|
||||
{Path: "file1.txt", Type: "blob", SHA: "a", Size: 100},
|
||||
{Path: "file2.txt", Type: "blob", SHA: "b", Size: 200},
|
||||
{Path: "file3.txt", Type: "blob", SHA: "c", Size: 300},
|
||||
{Path: "dir1", Type: "tree", SHA: "d", Size: 0},
|
||||
{Path: "dir2", Type: "tree", SHA: "e", Size: 0},
|
||||
},
|
||||
wantLen: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
slimmed := slimTreeEntries(tt.entries)
|
||||
if len(slimmed) != tt.wantLen {
|
||||
t.Errorf("slimTreeEntries() returned %d entries, want %d", len(slimmed), tt.wantLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeEntryStruct(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry TreeEntry
|
||||
}{
|
||||
{
|
||||
name: "minimal entry",
|
||||
entry: TreeEntry{
|
||||
Path: "file.txt",
|
||||
Type: "blob",
|
||||
SHA: "abc123",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full entry",
|
||||
entry: TreeEntry{
|
||||
Path: "file.txt",
|
||||
Type: "blob",
|
||||
SHA: "def456789abc",
|
||||
Size: 1024,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tree entry",
|
||||
entry: TreeEntry{
|
||||
Path: "directory",
|
||||
Type: "tree",
|
||||
SHA: "ghi789",
|
||||
Size: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.entry.Path == "" {
|
||||
t.Error("Path should not be empty")
|
||||
}
|
||||
if tt.entry.Type == "" {
|
||||
t.Error("Type should not be empty")
|
||||
}
|
||||
if tt.entry.SHA == "" {
|
||||
t.Error("SHA should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeResponseStruct(t *testing.T) {
|
||||
response := TreeResponse{
|
||||
SHA: "abc123def456",
|
||||
URL: "https://api.example.com/repos/owner/repo/git/trees/abc123",
|
||||
Tree: []TreeEntry{},
|
||||
Truncated: false,
|
||||
}
|
||||
|
||||
if response.SHA == "" {
|
||||
t.Error("SHA should not be empty")
|
||||
}
|
||||
if response.URL == "" {
|
||||
t.Error("URL should not be empty")
|
||||
}
|
||||
if response.Tree == nil {
|
||||
t.Error("Tree should not be nil")
|
||||
}
|
||||
if response.Truncated {
|
||||
t.Error("Truncated should be false")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user