Files

558 lines
15 KiB
Go

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 ""
}