558 lines
15 KiB
Go
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 ""
|
|
}
|