598 lines
17 KiB
Go
598 lines
17 KiB
Go
package actions
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"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"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
)
|
|
|
|
const (
|
|
MonitorWorkflowDispatchToolName = "monitor_workflow_dispatch"
|
|
DefaultPollInterval = 10 * time.Second
|
|
DefaultTimeout = 5 * time.Minute
|
|
)
|
|
|
|
var (
|
|
MonitorWorkflowDispatchTool = mcp.NewTool(
|
|
MonitorWorkflowDispatchToolName,
|
|
mcp.WithDescription("Dispatch a workflow and monitor its execution until completion. Returns full execution summary including run ID, status, conclusion, duration, and logs."),
|
|
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
|
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
|
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("workflow ID or filename")),
|
|
mcp.WithString("ref", mcp.Required(), mcp.Description("git ref (branch/tag) to run workflow on")),
|
|
mcp.WithObject("inputs", mcp.Description("workflow inputs object")),
|
|
mcp.WithNumber("timeout_seconds", mcp.Description("polling timeout in seconds (default: 300 = 5 minutes)"), mcp.DefaultNumber(300), mcp.Min(10)),
|
|
mcp.WithNumber("poll_interval_seconds", mcp.Description("poll interval in seconds (default: 10)"), mcp.DefaultNumber(10), mcp.Min(5)),
|
|
)
|
|
)
|
|
|
|
type MonitorResult struct {
|
|
RunID int64 `json:"run_id"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
WorkflowName string `json:"workflow_name,omitempty"`
|
|
Branch string `json:"branch"`
|
|
CommitSHA string `json:"commit_sha"`
|
|
Duration string `json:"duration"`
|
|
DurationSec float64 `json:"duration_seconds"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
Jobs []JobSummary `json:"jobs"`
|
|
Logs map[string]JobLogs `json:"logs,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
TimedOut bool `json:"timed_out"`
|
|
}
|
|
|
|
type JobSummary struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
Steps []StepInfo `json:"steps,omitempty"`
|
|
}
|
|
|
|
type StepInfo struct {
|
|
Name string `json:"name"`
|
|
Number int `json:"number"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion,omitempty"`
|
|
}
|
|
|
|
type JobLogs struct {
|
|
JobID int64 `json:"job_id"`
|
|
JobName string `json:"job_name"`
|
|
Log string `json:"log,omitempty"`
|
|
Bytes int `json:"bytes"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
|
|
func init() {
|
|
Tool.RegisterWrite(server.ServerTool{
|
|
Tool: MonitorWorkflowDispatchTool,
|
|
Handler: monitorWorkflowDispatchFn,
|
|
})
|
|
}
|
|
|
|
func monitorWorkflowDispatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
log.Debugf("Called monitorWorkflowDispatchFn")
|
|
|
|
owner, err := params.GetString(req.GetArguments(), "owner")
|
|
if err != nil || owner == "" {
|
|
return to.ErrorResult(errors.TranslateError(
|
|
stderrors.New("owner is required"),
|
|
map[string]string{"operation": "MonitorWorkflowDispatch"},
|
|
))
|
|
}
|
|
|
|
repo, err := params.GetString(req.GetArguments(), "repo")
|
|
if err != nil || repo == "" {
|
|
return to.ErrorResult(errors.TranslateError(
|
|
stderrors.New("repo is required"),
|
|
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner},
|
|
))
|
|
}
|
|
|
|
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
|
|
if err != nil || workflowID == "" {
|
|
return to.ErrorResult(errors.TranslateError(
|
|
stderrors.New("workflow_id is required"),
|
|
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo},
|
|
))
|
|
}
|
|
|
|
ref, err := params.GetString(req.GetArguments(), "ref")
|
|
if err != nil || ref == "" {
|
|
return to.ErrorResult(errors.TranslateError(
|
|
stderrors.New("ref is required"),
|
|
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo, "workflow_id": workflowID},
|
|
))
|
|
}
|
|
|
|
timeoutSec := int(params.GetOptionalInt(req.GetArguments(), "timeout_seconds", 300))
|
|
pollIntervalSec := int(params.GetOptionalInt(req.GetArguments(), "poll_interval_seconds", 10))
|
|
|
|
var inputs map[string]any
|
|
if raw, exists := req.GetArguments()["inputs"]; exists {
|
|
if m, ok := raw.(map[string]any); ok {
|
|
inputs = m
|
|
}
|
|
}
|
|
|
|
if err := checkActionsAPIAvailable(ctx); err != nil {
|
|
return to.ErrorResult(err)
|
|
}
|
|
|
|
log.Infof("Dispatching workflow %s for %s/%s on ref %s", workflowID, owner, repo, ref)
|
|
_, err = dispatchWorkflow(ctx, owner, repo, workflowID, ref, inputs)
|
|
if err != nil {
|
|
return to.ErrorResult(errors.TranslateError(err, map[string]string{
|
|
"operation": "DispatchWorkflow",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"workflow": workflowID,
|
|
}))
|
|
}
|
|
|
|
timeout := time.Duration(timeoutSec) * time.Second
|
|
pollInterval := time.Duration(pollIntervalSec) * time.Second
|
|
|
|
log.Infof("Waiting for workflow run to start (timeout: %v)...", timeout)
|
|
runID, err := waitForRunToStart(ctx, owner, repo, workflowID, ref, pollInterval, timeout)
|
|
if err != nil {
|
|
result := MonitorResult{
|
|
WorkflowID: workflowID,
|
|
Branch: ref,
|
|
Error: fmt.Sprintf("Workflow dispatched but run never started: %v", err),
|
|
}
|
|
return toErrorResultWithJSON(result)
|
|
}
|
|
|
|
log.Infof("Run %d started, monitoring until completion...", runID)
|
|
monitorResult, err := monitorRunUntilComplete(ctx, owner, repo, runID, pollInterval, timeout)
|
|
if err != nil {
|
|
return to.ErrorResult(errors.TranslateError(err, map[string]string{
|
|
"operation": "MonitorRun",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"run_id": fmt.Sprintf("%d", runID),
|
|
}))
|
|
}
|
|
|
|
log.Infof("Retrieving logs for run %d...", runID)
|
|
logs, err := retrieveJobLogs(ctx, owner, repo, monitorResult.Jobs)
|
|
if err != nil {
|
|
log.Warnf("Failed to retrieve some job logs: %v", err)
|
|
}
|
|
monitorResult.Logs = logs
|
|
|
|
return toTextResultWithJSON(monitorResult)
|
|
}
|
|
|
|
func checkActionsAPIAvailable(ctx context.Context) error {
|
|
var versionResp struct {
|
|
Version string `json:"version"`
|
|
}
|
|
status, err := gitea.DoJSON(ctx, "GET", "version", nil, nil, &versionResp)
|
|
if err != nil {
|
|
return errors.TranslateError(
|
|
fmt.Errorf("failed to check Gitea version: status=%d, err=%v", status, err),
|
|
map[string]string{"operation": "CheckGiteaVersion"},
|
|
)
|
|
}
|
|
|
|
major, minor, patch, err := parseVersionForCheck(versionResp.Version)
|
|
if err != nil {
|
|
return errors.TranslateError(
|
|
fmt.Errorf("failed to parse version '%s': %v", versionResp.Version, err),
|
|
map[string]string{"operation": "ParseVersion"},
|
|
)
|
|
}
|
|
|
|
if major < 1 || (major == 1 && minor < 23) {
|
|
return errors.NewEnhancedError(
|
|
stderrors.New("Actions API not available"),
|
|
fmt.Sprintf("Actions API requires Gitea 1.23+, found %d.%d.%d", major, minor, patch),
|
|
errors.CategoryActions,
|
|
).WithOperation("CheckActionsAPIAvailable")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseVersionForCheck(version string) (int, int, int, error) {
|
|
version = trimVersionPrefix(version)
|
|
parts := splitVersion(version)
|
|
if len(parts) < 2 {
|
|
return 0, 0, 0, fmt.Errorf("invalid version format: %s", version)
|
|
}
|
|
|
|
major, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
|
|
}
|
|
|
|
minor, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
|
|
}
|
|
|
|
patch := 0
|
|
if len(parts) >= 3 {
|
|
patch, err = strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
patch = 0
|
|
}
|
|
}
|
|
|
|
return major, minor, patch, nil
|
|
}
|
|
|
|
func trimVersionPrefix(v string) string {
|
|
v = trimOnePrefix(v, "v")
|
|
v = trimOnePrefix(v, "V")
|
|
return v
|
|
}
|
|
|
|
func trimOnePrefix(s, prefix string) string {
|
|
if len(s) > 0 && s[0] == prefix[0] {
|
|
return s[1:]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func splitVersion(v string) []string {
|
|
var parts []string
|
|
start := 0
|
|
for i := 0; i < len(v); i++ {
|
|
if v[i] == '.' {
|
|
if start < i {
|
|
parts = append(parts, v[start:i])
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(v) {
|
|
parts = append(parts, v[start:])
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func dispatchWorkflow(ctx context.Context, owner, repo, workflowID, ref string, inputs map[string]any) (map[string]any, error) {
|
|
body := map[string]any{
|
|
"ref": ref,
|
|
}
|
|
if inputs != nil {
|
|
body["inputs"] = inputs
|
|
}
|
|
|
|
err := doJSONWithFallback(ctx, "POST",
|
|
[]string{
|
|
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
|
|
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
|
|
},
|
|
nil, body, nil,
|
|
)
|
|
if err != nil {
|
|
var httpErr *gitea.HTTPError
|
|
if stderrors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
|
|
return nil, errors.NewEnhancedError(
|
|
err,
|
|
fmt.Sprintf("workflow dispatch not supported on this Gitea version (endpoint returned %d)", httpErr.StatusCode),
|
|
errors.CategoryActions,
|
|
).WithOperation("DispatchWorkflow")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return map[string]any{"message": "workflow dispatched"}, nil
|
|
}
|
|
|
|
func waitForRunToStart(ctx context.Context, owner, repo, workflowID, ref string, pollInterval, timeout time.Duration) (int64, error) {
|
|
startTime := time.Now()
|
|
seenRunIDs := make(map[int64]bool)
|
|
|
|
for time.Since(startTime) < timeout {
|
|
select {
|
|
case <-ctx.Done():
|
|
return 0, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
runs, err := listRecentRuns(ctx, owner, repo, workflowID, ref, 10)
|
|
if err != nil {
|
|
log.Warnf("Failed to list runs: %v", err)
|
|
time.Sleep(pollInterval)
|
|
continue
|
|
}
|
|
|
|
for _, run := range runs {
|
|
runID := int64(run["id"].(float64))
|
|
if seenRunIDs[runID] {
|
|
continue
|
|
}
|
|
seenRunIDs[runID] = true
|
|
|
|
status := getString(run, "status")
|
|
if status == "queued" || status == "in_progress" || status == "waiting" {
|
|
return runID, nil
|
|
}
|
|
createdAt := getString(run, "created_at")
|
|
if createdAt != "" {
|
|
runTime, err := time.Parse(time.RFC3339, createdAt)
|
|
if err == nil && time.Since(runTime) < 2*time.Minute {
|
|
return runID, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
|
|
return 0, fmt.Errorf("timeout waiting for run to start after %v", timeout)
|
|
}
|
|
|
|
func listRecentRuns(ctx context.Context, owner, repo, workflowID, ref string, limit int) ([]map[string]any, error) {
|
|
query := url.Values{}
|
|
query.Set("limit", strconv.Itoa(limit))
|
|
|
|
var result struct {
|
|
WorkflowRuns []map[string]any `json:"workflow_runs"`
|
|
}
|
|
|
|
err := doJSONWithFallback(ctx, "GET",
|
|
[]string{
|
|
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
|
|
},
|
|
query, nil, &result,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var matchingRuns []map[string]any
|
|
for _, run := range result.WorkflowRuns {
|
|
if workflowID != "" {
|
|
runWorkflowID := getString(run, "workflow_id")
|
|
runPath := getString(run, "path")
|
|
if runWorkflowID != workflowID && runPath != workflowID &&
|
|
runWorkflowID != "" && !containsPath(runPath, workflowID) {
|
|
continue
|
|
}
|
|
}
|
|
if ref != "" {
|
|
headBranch := getString(run, "head_branch")
|
|
if headBranch != "" && headBranch != ref {
|
|
continue
|
|
}
|
|
}
|
|
matchingRuns = append(matchingRuns, run)
|
|
}
|
|
|
|
return matchingRuns, nil
|
|
}
|
|
|
|
func containsPath(path, substring string) bool {
|
|
return len(path) > 0 && len(substring) > 0 &&
|
|
(path == substring ||
|
|
(len(path) > len(substring) && (path[len(path)-len(substring):] == substring ||
|
|
path[:len(substring)] == substring)))
|
|
}
|
|
|
|
func getString(m map[string]any, key string) string {
|
|
if v, ok := m[key].(string); ok {
|
|
return v
|
|
}
|
|
if v, ok := m[key].(float64); ok {
|
|
return strconv.FormatInt(int64(v), 10)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func monitorRunUntilComplete(ctx context.Context, owner, repo string, runID int64, pollInterval, timeout time.Duration) (*MonitorResult, error) {
|
|
startTime := time.Now()
|
|
var firstSeen time.Time
|
|
|
|
for time.Since(startTime) < timeout {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
run, err := getRun(ctx, owner, repo, runID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get run %d: %v", runID, err)
|
|
}
|
|
|
|
status := getString(run, "status")
|
|
conclusion := getString(run, "conclusion")
|
|
|
|
if firstSeen.IsZero() {
|
|
firstSeen = time.Now()
|
|
}
|
|
|
|
if status == "completed" {
|
|
return buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
|
|
}
|
|
|
|
log.Debugf("Run %d status: %s (conclusion: %s), waiting...", runID, status, conclusion)
|
|
time.Sleep(pollInterval)
|
|
}
|
|
|
|
run, err := getRun(ctx, owner, repo, runID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("timeout after %v and failed to get final status: %v", timeout, err)
|
|
}
|
|
|
|
result, _ := buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
|
|
result.TimedOut = true
|
|
result.Error = fmt.Sprintf("Polling timed out after %v", timeout)
|
|
return result, nil
|
|
}
|
|
|
|
func getRun(ctx context.Context, owner, repo string, runID int64) (map[string]any, error) {
|
|
var result map[string]any
|
|
err := doJSONWithFallback(ctx, "GET",
|
|
[]string{
|
|
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
|
|
},
|
|
nil, nil, &result,
|
|
)
|
|
return result, err
|
|
}
|
|
|
|
func buildMonitorResult(run map[string]any, runID int64, owner, repo string, started, ended time.Time) (*MonitorResult, error) {
|
|
result := &MonitorResult{
|
|
RunID: runID,
|
|
Status: getString(run, "status"),
|
|
Conclusion: getString(run, "conclusion"),
|
|
WorkflowID: getString(run, "workflow_id"),
|
|
WorkflowName: getString(run, "name"),
|
|
Branch: getString(run, "head_branch"),
|
|
CommitSHA: getString(run, "head_sha"),
|
|
StartedAt: getString(run, "created_at"),
|
|
CompletedAt: getString(run, "updated_at"),
|
|
}
|
|
|
|
if createdAt := getString(run, "run_started_at"); createdAt != "" {
|
|
result.StartedAt = createdAt
|
|
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
|
|
started = t
|
|
}
|
|
}
|
|
if updatedAt := getString(run, "updated_at"); updatedAt != "" {
|
|
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
|
|
ended = t
|
|
}
|
|
}
|
|
|
|
duration := ended.Sub(started)
|
|
if duration < 0 {
|
|
duration = 0
|
|
}
|
|
result.Duration = duration.String()
|
|
result.DurationSec = duration.Seconds()
|
|
|
|
jobs, err := listRunJobs(runID, owner, repo, run)
|
|
if err == nil {
|
|
result.Jobs = jobs
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func listRunJobs(runID int64, owner, repo string, run map[string]any) ([]JobSummary, error) {
|
|
if jobsData, ok := run["jobs"].([]any); ok && len(jobsData) > 0 {
|
|
return parseJobSummaries(jobsData), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func parseJobSummaries(jobsData []any) []JobSummary {
|
|
var summaries []JobSummary
|
|
for _, j := range jobsData {
|
|
job, ok := j.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
summary := JobSummary{
|
|
ID: int64(job["id"].(float64)),
|
|
Name: getString(job, "name"),
|
|
Status: getString(job, "status"),
|
|
Conclusion: getString(job, "conclusion"),
|
|
StartedAt: getString(job, "started_at"),
|
|
CompletedAt: getString(job, "completed_at"),
|
|
}
|
|
|
|
if stepsData, ok := job["steps"].([]any); ok {
|
|
summary.Steps = parseSteps(stepsData)
|
|
}
|
|
|
|
summaries = append(summaries, summary)
|
|
}
|
|
return summaries
|
|
}
|
|
|
|
func parseSteps(stepsData []any) []StepInfo {
|
|
var steps []StepInfo
|
|
for _, s := range stepsData {
|
|
step, ok := s.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
steps = append(steps, StepInfo{
|
|
Name: getString(step, "name"),
|
|
Number: int(step["number"].(float64)),
|
|
Status: getString(step, "status"),
|
|
Conclusion: getString(step, "conclusion"),
|
|
})
|
|
}
|
|
return steps
|
|
}
|
|
|
|
func retrieveJobLogs(ctx context.Context, owner, repo string, jobs []JobSummary) (map[string]JobLogs, error) {
|
|
logs := make(map[string]JobLogs)
|
|
|
|
for _, job := range jobs {
|
|
if job.ID == 0 {
|
|
continue
|
|
}
|
|
|
|
logData, _, err := fetchJobLogBytes(ctx, owner, repo, job.ID)
|
|
if err != nil {
|
|
log.Warnf("Failed to fetch logs for job %d: %v", job.ID, err)
|
|
continue
|
|
}
|
|
|
|
maxLogBytes := 100 * 1024
|
|
truncated := false
|
|
if len(logData) > maxLogBytes {
|
|
logData = logData[len(logData)-maxLogBytes:]
|
|
truncated = true
|
|
}
|
|
|
|
logs[job.Name] = JobLogs{
|
|
JobID: job.ID,
|
|
JobName: job.Name,
|
|
Log: string(logData),
|
|
Bytes: len(logData),
|
|
Truncated: truncated,
|
|
}
|
|
}
|
|
|
|
return logs, nil
|
|
}
|
|
|
|
func toTextResultWithJSON(result *MonitorResult) (*mcp.CallToolResult, error) {
|
|
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
|
if err != nil {
|
|
return to.ErrorResult(fmt.Errorf("failed to marshal result: %v", err))
|
|
}
|
|
return to.TextResult(string(jsonBytes))
|
|
}
|
|
|
|
func toErrorResultWithJSON(result MonitorResult) (*mcp.CallToolResult, error) {
|
|
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
|
if err != nil {
|
|
return to.ErrorResult(fmt.Errorf("%s (marshal error: %v)", result.Error, err))
|
|
}
|
|
return to.TextResult(fmt.Sprintf("Error: %s\n\nPartial Result:\n%s", result.Error, string(jsonBytes)))
|
|
} |