Initial commit: Gitea MCP Server

This commit is contained in:
2026-04-10 21:57:19 -07:00
commit 1f27844dce
119 changed files with 25001 additions and 0 deletions
+150
View File
@@ -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))
}
+77
View File
@@ -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))
}
+321
View File
@@ -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")
}
+365
View File
@@ -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")
}
}
+557
View File
@@ -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 ""
}
+697
View File
@@ -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)
}
}
+264
View File
@@ -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))
}
+225
View File
@@ -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)
}
+201
View File
@@ -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
}
+142
View File
@@ -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)
}
}
+138
View File
@@ -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,
}
}
+483
View File
@@ -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)
}
}
}
+199
View File
@@ -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))
}
+228
View File
@@ -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
}
+468
View File
@@ -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")
}
}