Initial commit: Gitea MCP Server
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user