diff --git a/routers/api/v2/mcp.go b/routers/api/v2/mcp.go index 29a2f0d4f6..d13d29bf74 100644 --- a/routers/api/v2/mcp.go +++ b/routers/api/v2/mcp.go @@ -4,10 +4,12 @@ package v2 import ( + "context" "errors" "fmt" "io" "net/http" + "net/url" "strings" "time" @@ -16,14 +18,23 @@ import ( packages_model "code.gitcaddy.com/server/v3/models/packages" repo_model "code.gitcaddy.com/server/v3/models/repo" secret_model "code.gitcaddy.com/server/v3/models/secret" + "code.gitcaddy.com/server/v3/models/unit" user_model "code.gitcaddy.com/server/v3/models/user" "code.gitcaddy.com/server/v3/modules/actions" + "code.gitcaddy.com/server/v3/modules/git" "code.gitcaddy.com/server/v3/modules/json" "code.gitcaddy.com/server/v3/modules/log" "code.gitcaddy.com/server/v3/modules/optional" "code.gitcaddy.com/server/v3/modules/setting" api "code.gitcaddy.com/server/v3/modules/structs" - "code.gitcaddy.com/server/v3/services/context" + "code.gitcaddy.com/server/v3/modules/util" + actions_service "code.gitcaddy.com/server/v3/services/actions" + context_service "code.gitcaddy.com/server/v3/services/context" + notify_service "code.gitcaddy.com/server/v3/services/notify" + + "github.com/nektos/act/pkg/model" + "gopkg.in/yaml.v3" + "xorm.io/builder" ) // MCP Protocol Types (JSON-RPC 2.0) @@ -175,7 +186,7 @@ var mcpTools = []MCPTool{ }, { Name: "get_job_logs", - Description: "Get logs from a specific job in a workflow run", + Description: "Get logs from a specific job in a workflow run. For failed jobs, automatically extracts error context.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -191,10 +202,40 @@ var mcpTools = []MCPTool{ "type": "integer", "description": "The job ID", }, + "errors_only": map[string]any{ + "type": "boolean", + "description": "If true, only return error-related log lines with context (default: true for failed jobs)", + }, + "context_lines": map[string]any{ + "type": "integer", + "description": "Number of lines before/after each error to include (default: 5)", + }, }, "required": []string{"owner", "repo", "job_id"}, }, }, + { + Name: "cancel_workflow_run", + Description: "Cancel a running workflow run and all its jobs. Only works on runs that are not yet completed.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name", + }, + "run_id": map[string]any{ + "type": "integer", + "description": "The workflow run ID to cancel", + }, + }, + "required": []string{"owner", "repo", "run_id"}, + }, + }, { Name: "list_releases", Description: "List releases for a repository", @@ -277,6 +318,188 @@ var mcpTools = []MCPTool{ }, }, }, + { + Name: "rerun_workflow", + Description: "Rerun a completed workflow run or a specific failed job. Only works on runs that have finished.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name", + }, + "run_id": map[string]any{ + "type": "integer", + "description": "The workflow run ID to rerun", + }, + "job_id": map[string]any{ + "type": "integer", + "description": "Optional: specific job ID to rerun. If omitted, reruns all jobs.", + }, + }, + "required": []string{"owner", "repo", "run_id"}, + }, + }, + { + Name: "trigger_workflow", + Description: "Manually trigger a workflow_dispatch workflow with optional inputs.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name", + }, + "workflow": map[string]any{ + "type": "string", + "description": "Workflow filename (e.g., build.yml)", + }, + "ref": map[string]any{ + "type": "string", + "description": "Git ref to run on (branch, tag, or SHA)", + }, + "inputs": map[string]any{ + "type": "object", + "description": "Optional workflow inputs as key-value pairs", + }, + }, + "required": []string{"owner", "repo", "workflow", "ref"}, + }, + }, + { + Name: "list_artifacts", + Description: "List artifacts from a workflow run.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name", + }, + "run_id": map[string]any{ + "type": "integer", + "description": "The workflow run ID", + }, + }, + "required": []string{"owner", "repo", "run_id"}, + }, + }, + { + Name: "approve_workflow", + Description: "Approve a workflow run that requires approval (e.g., from fork PRs).", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name", + }, + "run_id": map[string]any{ + "type": "integer", + "description": "The workflow run ID to approve", + }, + }, + "required": []string{"owner", "repo", "run_id"}, + }, + }, + { + Name: "list_workflows", + Description: "List available workflow files in a repository.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name", + }, + "ref": map[string]any{ + "type": "string", + "description": "Git ref (branch/tag). Defaults to default branch.", + }, + }, + "required": []string{"owner", "repo"}, + }, + }, + { + Name: "get_queue_depth", + Description: "Get the number of waiting jobs per runner label. Useful for understanding runner capacity.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + }, + { + Name: "get_workflow_file", + Description: "Get the content of a workflow YAML file.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name", + }, + "workflow": map[string]any{ + "type": "string", + "description": "Workflow filename (e.g., build.yml)", + }, + "ref": map[string]any{ + "type": "string", + "description": "Git ref (branch/tag). Defaults to default branch.", + }, + }, + "required": []string{"owner", "repo", "workflow"}, + }, + }, + { + Name: "get_artifact_download_url", + Description: "Get the download URL for a workflow artifact.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name", + }, + "run_id": map[string]any{ + "type": "integer", + "description": "The workflow run ID", + }, + "artifact_name": map[string]any{ + "type": "string", + "description": "Name of the artifact to download", + }, + }, + "required": []string{"owner", "repo", "run_id", "artifact_name"}, + }, + }, } // MCPHandler handles MCP protocol requests @@ -287,7 +510,7 @@ var mcpTools = []MCPTool{ // @Produce json // @Success 200 {object} MCPResponse // @Router /mcp [post] -func MCPHandler(ctx *context.APIContext) { +func MCPHandler(ctx *context_service.APIContext) { body, err := io.ReadAll(ctx.Req.Body) if err != nil { sendMCPError(ctx, nil, -32700, "Parse error", err.Error()) @@ -321,7 +544,7 @@ func MCPHandler(ctx *context.APIContext) { } } -func handleInitialize(ctx *context.APIContext, req *MCPRequest) { +func handleInitialize(ctx *context_service.APIContext, req *MCPRequest) { result := MCPInitializeResult{ ProtocolVersion: "2024-11-05", Capabilities: map[string]any{ @@ -335,7 +558,7 @@ func handleInitialize(ctx *context.APIContext, req *MCPRequest) { sendMCPResult(ctx, req.ID, result) } -func handleToolsList(ctx *context.APIContext, req *MCPRequest) { +func handleToolsList(ctx *context_service.APIContext, req *MCPRequest) { allTools := make([]MCPTool, 0, len(mcpTools)+len(mcpAITools)) allTools = append(allTools, mcpTools...) allTools = append(allTools, mcpAITools...) @@ -343,7 +566,7 @@ func handleToolsList(ctx *context.APIContext, req *MCPRequest) { sendMCPResult(ctx, req.ID, result) } -func handleToolsCall(ctx *context.APIContext, req *MCPRequest) { +func handleToolsCall(ctx *context_service.APIContext, req *MCPRequest) { var params MCPToolCallParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { sendMCPError(ctx, req.ID, -32602, "Invalid params", err.Error()) @@ -364,6 +587,8 @@ func handleToolsCall(ctx *context.APIContext, req *MCPRequest) { result, err = toolGetWorkflowRun(ctx, params.Arguments) case "get_job_logs": result, err = toolGetJobLogs(ctx, params.Arguments) + case "cancel_workflow_run": + result, err = toolCancelWorkflowRun(ctx, params.Arguments) case "list_releases": result, err = toolListReleases(ctx, params.Arguments) case "get_release": @@ -372,6 +597,22 @@ func handleToolsCall(ctx *context.APIContext, req *MCPRequest) { result, err = toolListSecrets(ctx, params.Arguments) case "list_packages": result, err = toolListPackages(ctx, params.Arguments) + case "rerun_workflow": + result, err = toolRerunWorkflow(ctx, params.Arguments) + case "trigger_workflow": + result, err = toolTriggerWorkflow(ctx, params.Arguments) + case "list_artifacts": + result, err = toolListArtifacts(ctx, params.Arguments) + case "approve_workflow": + result, err = toolApproveWorkflow(ctx, params.Arguments) + case "list_workflows": + result, err = toolListWorkflows(ctx, params.Arguments) + case "get_queue_depth": + result, err = toolGetQueueDepth(ctx, params.Arguments) + case "get_workflow_file": + result, err = toolGetWorkflowFile(ctx, params.Arguments) + case "get_artifact_download_url": + result, err = toolGetArtifactDownloadURL(ctx, params.Arguments) case "get_error_patterns": result, err = toolGetErrorPatterns(ctx, params.Arguments) case "report_error_solution": @@ -397,7 +638,7 @@ func handleToolsCall(ctx *context.APIContext, req *MCPRequest) { sendMCPToolResult(ctx, req.ID, string(jsonBytes), false) } -func sendMCPResult(ctx *context.APIContext, id, result any) { +func sendMCPResult(ctx *context_service.APIContext, id, result any) { ctx.JSON(http.StatusOK, MCPResponse{ JSONRPC: "2.0", ID: id, @@ -405,7 +646,7 @@ func sendMCPResult(ctx *context.APIContext, id, result any) { }) } -func sendMCPError(ctx *context.APIContext, id any, code int, message, data string) { +func sendMCPError(ctx *context_service.APIContext, id any, code int, message, data string) { ctx.JSON(http.StatusOK, MCPResponse{ JSONRPC: "2.0", ID: id, @@ -417,7 +658,7 @@ func sendMCPError(ctx *context.APIContext, id any, code int, message, data strin }) } -func sendMCPToolResult(ctx *context.APIContext, id any, text string, isError bool) { +func sendMCPToolResult(ctx *context_service.APIContext, id any, text string, isError bool) { ctx.JSON(http.StatusOK, MCPResponse{ JSONRPC: "2.0", ID: id, @@ -430,7 +671,7 @@ func sendMCPToolResult(ctx *context.APIContext, id any, text string, isError boo // Tool implementations -func toolListRunners(ctx *context.APIContext, args map[string]any) (any, error) { +func toolListRunners(ctx *context_service.APIContext, args map[string]any) (any, error) { var runners actions_model.RunnerList var err error @@ -497,7 +738,7 @@ func toolListRunners(ctx *context.APIContext, args map[string]any) (any, error) }, nil } -func toolGetRunner(ctx *context.APIContext, args map[string]any) (any, error) { +func toolGetRunner(ctx *context_service.APIContext, args map[string]any) (any, error) { runnerIDFloat, ok := args["runner_id"].(float64) if !ok { return nil, errors.New("runner_id is required") @@ -531,7 +772,7 @@ func toolGetRunner(ctx *context.APIContext, args map[string]any) (any, error) { return result, nil } -func toolListWorkflowRuns(ctx *context.APIContext, args map[string]any) (any, error) { +func toolListWorkflowRuns(ctx *context_service.APIContext, args map[string]any) (any, error) { owner, _ := args["owner"].(string) repo, _ := args["repo"].(string) @@ -591,7 +832,7 @@ func toolListWorkflowRuns(ctx *context.APIContext, args map[string]any) (any, er }, nil } -func toolGetWorkflowRun(ctx *context.APIContext, args map[string]any) (any, error) { +func toolGetWorkflowRun(ctx *context_service.APIContext, args map[string]any) (any, error) { owner, _ := args["owner"].(string) repo, _ := args["repo"].(string) runIDFloat, ok := args["run_id"].(float64) @@ -645,7 +886,7 @@ func toolGetWorkflowRun(ctx *context.APIContext, args map[string]any) (any, erro }, nil } -func toolGetJobLogs(ctx *context.APIContext, args map[string]any) (any, error) { +func toolGetJobLogs(ctx *context_service.APIContext, args map[string]any) (any, error) { owner, _ := args["owner"].(string) repo, _ := args["repo"].(string) jobIDFloat, ok := args["job_id"].(float64) @@ -690,13 +931,26 @@ func toolGetJobLogs(ctx *context.APIContext, args map[string]any) (any, error) { // Check if logs are expired if task.LogExpired { return map[string]any{ - "job_id": jobID, - "job_name": job.Name, - "status": job.Status.String(), - "message": "Logs have expired", + "job_id": jobID, + "job_name": job.Name, + "status": job.Status.String(), + "log_expired": true, + "message": "Logs have expired", }, nil } + // Determine if we should extract errors only + // Default to errors_only=true for failed jobs + errorsOnly := job.Status.String() == "failure" + if val, ok := args["errors_only"].(bool); ok { + errorsOnly = val + } + + contextLines := 5 + if val, ok := args["context_lines"].(float64); ok { + contextLines = int(val) + } + // Get steps for this task steps := actions.FullSteps(task) @@ -716,16 +970,40 @@ func toolGetJobLogs(ctx *context.APIContext, args map[string]any) (any, error) { if err != nil { stepInfo["error"] = fmt.Sprintf("failed to read logs: %v", err) } else { - lines := make([]string, 0, len(logRows)) + allLines := make([]string, 0, len(logRows)) for _, row := range logRows { - lines = append(lines, row.Content) + allLines = append(allLines, row.Content) + } + + if errorsOnly { + // Extract only error-related lines with context + errorLines := extractErrorLines(allLines, contextLines) + if len(errorLines) > 0 { + stepInfo["lines"] = errorLines + stepInfo["line_count"] = len(errorLines) + stepInfo["filtered"] = true + stepInfo["original_line_count"] = len(allLines) + } else if step.Status.String() == "failure" { + // For failed steps with no detected errors, include last N lines + lastN := min(50, len(allLines)) + stepInfo["lines"] = allLines[len(allLines)-lastN:] + stepInfo["line_count"] = lastN + stepInfo["filtered"] = true + stepInfo["original_line_count"] = len(allLines) + stepInfo["note"] = "No specific errors detected, showing last 50 lines" + } + // Skip successful steps when filtering for errors + } else { + stepInfo["lines"] = allLines + stepInfo["line_count"] = len(allLines) } - stepInfo["lines"] = lines - stepInfo["line_count"] = len(lines) } } - stepLogs = append(stepLogs, stepInfo) + // Only include steps that have content when filtering + if !errorsOnly || stepInfo["lines"] != nil || step.Status.String() == "failure" { + stepLogs = append(stepLogs, stepInfo) + } } return map[string]any{ @@ -736,10 +1014,131 @@ func toolGetJobLogs(ctx *context.APIContext, args map[string]any) (any, error) { "log_expired": task.LogExpired, "steps": stepLogs, "step_count": len(stepLogs), + "errors_only": errorsOnly, }, nil } -func toolListReleases(ctx *context.APIContext, args map[string]any) (any, error) { +// extractErrorLines finds error-related lines and includes context around them +func extractErrorLines(lines []string, contextLines int) []string { + // Patterns that indicate errors + errorPatterns := []string{ + "error:", "Error:", "ERROR:", "error[", + "failed", "Failed", "FAILED", + "fatal:", "Fatal:", "FATAL:", + "panic:", "PANIC:", + "exception:", "Exception:", + "cannot ", "Cannot ", + "undefined:", "Undefined:", + "not found", "Not found", "NOT FOUND", + "permission denied", "Permission denied", + "exit code", "exit status", + "--- FAIL:", + "SIGILL", "SIGSEGV", "SIGABRT", "SIGKILL", + "Build FAILED", + "error MSB", "error CS", "error TS", + "npm ERR!", + "go: ", // go module errors + } + + // Find indices of error lines + errorIndices := make(map[int]bool) + for i, line := range lines { + lineLower := strings.ToLower(line) + for _, pattern := range errorPatterns { + if strings.Contains(lineLower, strings.ToLower(pattern)) { + // Mark this line and surrounding context + for j := max(0, i-contextLines); j <= min(len(lines)-1, i+contextLines); j++ { + errorIndices[j] = true + } + break + } + } + } + + if len(errorIndices) == 0 { + return nil + } + + // Collect lines in order, adding separators between non-contiguous sections + result := make([]string, 0) + lastIdx := -1 + for i, line := range lines { + if errorIndices[i] { + if lastIdx >= 0 && i > lastIdx+1 { + result = append(result, "--- [skipped lines] ---") + } + result = append(result, line) + lastIdx = i + } + } + + return result +} + +func toolCancelWorkflowRun(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + runIDFloat, ok := args["run_id"].(float64) + + if owner == "" || repo == "" || !ok { + return nil, errors.New("owner, repo, and run_id are required") + } + runID := int64(runIDFloat) + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, runID) + if err != nil { + return nil, fmt.Errorf("run not found: %d", runID) + } + + // Check if run is already done + if run.Status.IsDone() { + return map[string]any{ + "run_id": runID, + "status": run.Status.String(), + "message": "Run is already completed and cannot be cancelled", + }, nil + } + + // Get all jobs for this run + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return nil, fmt.Errorf("failed to get jobs: %v", err) + } + + // Cancel the jobs + var cancelledJobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(txCtx context.Context) error { + cancelled, err := actions_model.CancelJobs(txCtx, jobs) + if err != nil { + return fmt.Errorf("cancel jobs: %w", err) + } + cancelledJobs = append(cancelledJobs, cancelled...) + return nil + }); err != nil { + return nil, err + } + + // Return result + cancelledJobIDs := make([]int64, 0, len(cancelledJobs)) + for _, job := range cancelledJobs { + cancelledJobIDs = append(cancelledJobIDs, job.ID) + } + + return map[string]any{ + "run_id": runID, + "status": "cancelled", + "cancelled_jobs": cancelledJobIDs, + "cancelled_count": len(cancelledJobs), + "message": fmt.Sprintf("Successfully cancelled %d job(s)", len(cancelledJobs)), + }, nil +} + +func toolListReleases(ctx *context_service.APIContext, args map[string]any) (any, error) { owner, _ := args["owner"].(string) repo, _ := args["repo"].(string) @@ -787,7 +1186,7 @@ func toolListReleases(ctx *context.APIContext, args map[string]any) (any, error) }, nil } -func toolGetRelease(ctx *context.APIContext, args map[string]any) (any, error) { +func toolGetRelease(ctx *context_service.APIContext, args map[string]any) (any, error) { owner, _ := args["owner"].(string) repo, _ := args["repo"].(string) tag, _ := args["tag"].(string) @@ -836,7 +1235,7 @@ func toolGetRelease(ctx *context.APIContext, args map[string]any) (any, error) { }, nil } -func toolListSecrets(ctx *context.APIContext, args map[string]any) (any, error) { +func toolListSecrets(ctx *context_service.APIContext, args map[string]any) (any, error) { owner, _ := args["owner"].(string) repo, _ := args["repo"].(string) @@ -927,7 +1326,7 @@ func toolListSecrets(ctx *context.APIContext, args map[string]any) (any, error) return result, nil } -func toolListPackages(ctx *context.APIContext, args map[string]any) (any, error) { +func toolListPackages(ctx *context_service.APIContext, args map[string]any) (any, error) { owner, _ := args["owner"].(string) pkgType, _ := args["type"].(string) @@ -1024,3 +1423,584 @@ func toolListPackages(ctx *context.APIContext, args map[string]any) (any, error) return result, nil } + +func toolRerunWorkflow(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + runIDFloat, ok := args["run_id"].(float64) + + if owner == "" || repo == "" || !ok { + return nil, errors.New("owner, repo, and run_id are required") + } + runID := int64(runIDFloat) + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, runID) + if err != nil { + return nil, fmt.Errorf("run not found: %d", runID) + } + + // Check if run is done + if !run.Status.IsDone() { + return map[string]any{ + "run_id": runID, + "status": run.Status.String(), + "message": "Run is not yet completed and cannot be rerun", + }, nil + } + + // Check if workflow is disabled + cfgUnit := repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + return nil, errors.New("workflow is disabled") + } + + // Reset run's start and stop time + run.PreviousDuration = run.Duration() + run.Started = 0 + run.Stopped = 0 + run.Status = actions_model.StatusWaiting + + vars, err := actions_model.GetVariablesOfRun(ctx, run) + if err != nil { + return nil, fmt.Errorf("get run variables: %w", err) + } + + if run.RawConcurrency != "" { + var rawConcurrency model.RawConcurrency + if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil { + return nil, fmt.Errorf("unmarshal raw concurrency: %w", err) + } + + err = actions_service.EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars) + if err != nil { + return nil, fmt.Errorf("evaluate run concurrency: %w", err) + } + + run.Status, err = actions_service.PrepareToStartRunWithConcurrency(ctx, run) + if err != nil { + return nil, fmt.Errorf("prepare run with concurrency: %w", err) + } + } + + if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil { + return nil, fmt.Errorf("update run: %w", err) + } + + if err := run.LoadAttributes(ctx); err != nil { + return nil, fmt.Errorf("load run attributes: %w", err) + } + notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) + + // Get jobs and rerun them + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return nil, fmt.Errorf("get jobs: %w", err) + } + + isRunBlocked := run.Status == actions_model.StatusBlocked + + // Check if specific job_id was requested + jobIDFloat, hasJobID := args["job_id"].(float64) + if hasJobID { + jobID := int64(jobIDFloat) + // Find the specific job + var targetJob *actions_model.ActionRunJob + for _, j := range jobs { + if j.ID == jobID { + targetJob = j + break + } + } + if targetJob == nil { + return nil, fmt.Errorf("job not found: %d", jobID) + } + + // Rerun only the specified job and its dependents + rerunJobs := actions_service.GetAllRerunJobs(targetJob, jobs) + rerunCount := 0 + for _, j := range rerunJobs { + shouldBlockJob := j.ID != targetJob.ID || isRunBlocked + if err := rerunJobMCP(ctx, j, shouldBlockJob, vars); err != nil { + return nil, fmt.Errorf("rerun job %d: %w", j.ID, err) + } + rerunCount++ + } + + return map[string]any{ + "run_id": runID, + "status": "rerun_started", + "rerun_job_id": jobID, + "jobs_rerun": rerunCount, + "message": fmt.Sprintf("Started rerun of job %d and %d dependent jobs", jobID, rerunCount-1), + }, nil + } + + // Rerun all jobs + rerunCount := 0 + for _, j := range jobs { + shouldBlockJob := len(j.Needs) > 0 || isRunBlocked + if err := rerunJobMCP(ctx, j, shouldBlockJob, vars); err != nil { + return nil, fmt.Errorf("rerun job %d: %w", j.ID, err) + } + rerunCount++ + } + + return map[string]any{ + "run_id": runID, + "status": "rerun_started", + "jobs_rerun": rerunCount, + "message": fmt.Sprintf("Started rerun of all %d jobs", rerunCount), + }, nil +} + +// rerunJobMCP is a helper function to rerun a single job +func rerunJobMCP(ctx context.Context, job *actions_model.ActionRunJob, shouldBlock bool, vars map[string]string) error { + status := job.Status + if !status.IsDone() { + return nil + } + + job.TaskID = 0 + job.Status = util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting) + job.Started = 0 + job.Stopped = 0 + job.ConcurrencyGroup = "" + job.ConcurrencyCancel = false + job.IsConcurrencyEvaluated = false + + if err := job.LoadRun(ctx); err != nil { + return err + } + + if job.RawConcurrency != "" && !shouldBlock { + err := actions_service.EvaluateJobConcurrencyFillModel(ctx, job.Run, job, vars) + if err != nil { + return fmt.Errorf("evaluate job concurrency: %w", err) + } + + var err2 error + job.Status, err2 = actions_service.PrepareToStartJobWithConcurrency(ctx, job) + if err2 != nil { + return err2 + } + } + + if err := db.WithTx(ctx, func(txCtx context.Context) error { + updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"} + _, err := actions_model.UpdateRunJob(txCtx, job, builder.Eq{"status": status}, updateCols...) + return err + }); err != nil { + return err + } + + actions_service.CreateCommitStatusForRunJobs(ctx, job.Run, job) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + + return nil +} + +func toolTriggerWorkflow(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + workflow, _ := args["workflow"].(string) + ref, _ := args["ref"].(string) + + if owner == "" || repo == "" || workflow == "" || ref == "" { + return nil, errors.New("owner, repo, workflow, and ref are required") + } + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + // Open git repo + gitRepo, err := git.OpenRepository(ctx, repository.RepoPath()) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + defer gitRepo.Close() + + // Get the doer (authenticated user) + doer := ctx.Doer + if doer == nil { + return nil, errors.New("authentication required to trigger workflows") + } + + // Process inputs + inputsArg, _ := args["inputs"].(map[string]any) + + err = actions_service.DispatchActionWorkflow(ctx, doer, repository, gitRepo, workflow, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + for name, config := range workflowDispatch.Inputs { + if val, ok := inputsArg[name]; ok { + inputs[name] = fmt.Sprintf("%v", val) + } else { + inputs[name] = config.Default + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("dispatch workflow: %w", err) + } + + return map[string]any{ + "workflow": workflow, + "ref": ref, + "status": "triggered", + "message": fmt.Sprintf("Successfully triggered workflow %s on ref %s", workflow, ref), + }, nil +} + +func toolListArtifacts(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + runIDFloat, ok := args["run_id"].(float64) + + if owner == "" || repo == "" || !ok { + return nil, errors.New("owner, repo, and run_id are required") + } + runID := int64(runIDFloat) + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, runID) + if err != nil { + return nil, fmt.Errorf("run not found: %d", runID) + } + + artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID) + if err != nil { + return nil, fmt.Errorf("list artifacts: %w", err) + } + + result := make([]map[string]any, 0, len(artifacts)) + for _, art := range artifacts { + status := "completed" + if art.Status == actions_model.ArtifactStatusExpired { + status = "expired" + } + result = append(result, map[string]any{ + "name": art.ArtifactName, + "size": art.FileSize, + "status": status, + "download_url": fmt.Sprintf("%s%s/%s/actions/runs/%d/artifacts/%s", setting.AppURL, owner, repo, run.Index, url.PathEscape(art.ArtifactName)), + }) + } + + return map[string]any{ + "run_id": runID, + "artifacts": result, + "count": len(result), + }, nil +} + +func toolApproveWorkflow(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + runIDFloat, ok := args["run_id"].(float64) + + if owner == "" || repo == "" || !ok { + return nil, errors.New("owner, repo, and run_id are required") + } + runID := int64(runIDFloat) + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, runID) + if err != nil { + return nil, fmt.Errorf("run not found: %d", runID) + } + + if !run.NeedApproval { + return map[string]any{ + "run_id": runID, + "status": run.Status.String(), + "message": "Run does not require approval", + }, nil + } + + doer := ctx.Doer + if doer == nil { + return nil, errors.New("authentication required to approve workflows") + } + + run.Repo = repository + run.NeedApproval = false + run.ApprovedBy = doer.ID + + if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { + return nil, fmt.Errorf("update run: %w", err) + } + + // Update job statuses + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return nil, fmt.Errorf("get jobs: %w", err) + } + + updatedCount := 0 + for _, job := range jobs { + var err error + job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) + if err != nil { + return nil, fmt.Errorf("prepare job: %w", err) + } + if job.Status == actions_model.StatusWaiting { + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return nil, fmt.Errorf("update job: %w", err) + } + if n > 0 { + updatedCount++ + } + } + } + + actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...) + + return map[string]any{ + "run_id": runID, + "status": "approved", + "approved_by": doer.Name, + "jobs_started": updatedCount, + "message": fmt.Sprintf("Successfully approved workflow run %d", runID), + }, nil +} + +func toolListWorkflows(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + if owner == "" || repo == "" { + return nil, errors.New("owner and repo are required") + } + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + // Open git repo + gitRepo, err := git.OpenRepository(ctx, repository.RepoPath()) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + defer gitRepo.Close() + + // Get ref (default to default branch) + ref, _ := args["ref"].(string) + if ref == "" { + ref = repository.DefaultBranch + } + + // Get commit + commit, err := gitRepo.GetBranchCommit(ref) + if err != nil { + // Try as tag + commit, err = gitRepo.GetTagCommit(ref) + if err != nil { + return nil, fmt.Errorf("ref not found: %s", ref) + } + } + + // List workflows + workflowsPath, entries, err := actions.ListWorkflows(commit) + if err != nil { + return nil, fmt.Errorf("list workflows: %w", err) + } + + workflows := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + workflows = append(workflows, map[string]any{ + "name": entry.Name(), + "path": workflowsPath + "/" + entry.Name(), + }) + } + + return map[string]any{ + "ref": ref, + "path": workflowsPath, + "workflows": workflows, + "count": len(workflows), + }, nil +} + +func toolGetQueueDepth(ctx *context_service.APIContext, _ map[string]any) (any, error) { + queueDepth, err := actions_model.GetQueueDepthByLabels(ctx) + if err != nil { + return nil, fmt.Errorf("get queue depth: %w", err) + } + + labels := make([]map[string]any, 0, len(queueDepth)) + totalJobs := int64(0) + totalStuck := int64(0) + + for _, q := range queueDepth { + totalJobs += q.JobCount + totalStuck += q.StuckJobs + + labelInfo := map[string]any{ + "label": q.Label, + "job_count": q.JobCount, + "stuck_jobs": q.StuckJobs, + } + if q.OldestWait > 0 { + labelInfo["oldest_wait"] = q.OldestWait.AsTime().Format(time.RFC3339) + labelInfo["wait_duration"] = time.Since(q.OldestWait.AsTime()).String() + } + labels = append(labels, labelInfo) + } + + return map[string]any{ + "labels": labels, + "total_waiting": totalJobs, + "total_stuck": totalStuck, + "stuck_threshold": "30 minutes", + "label_count": len(labels), + }, nil +} + +func toolGetWorkflowFile(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + workflow, _ := args["workflow"].(string) + + if owner == "" || repo == "" || workflow == "" { + return nil, errors.New("owner, repo, and workflow are required") + } + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + // Open git repo + gitRepo, err := git.OpenRepository(ctx, repository.RepoPath()) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + defer gitRepo.Close() + + // Get ref (default to default branch) + ref, _ := args["ref"].(string) + if ref == "" { + ref = repository.DefaultBranch + } + + // Get commit + commit, err := gitRepo.GetBranchCommit(ref) + if err != nil { + // Try as tag + commit, err = gitRepo.GetTagCommit(ref) + if err != nil { + return nil, fmt.Errorf("ref not found: %s", ref) + } + } + + // List workflows to find the entry + workflowsPath, entries, err := actions.ListWorkflows(commit) + if err != nil { + return nil, fmt.Errorf("list workflows: %w", err) + } + + var targetEntry *git.TreeEntry + for _, entry := range entries { + if entry.Name() == workflow { + targetEntry = entry + break + } + } + + if targetEntry == nil { + return nil, fmt.Errorf("workflow not found: %s", workflow) + } + + // Get content + content, err := actions.GetContentFromEntry(targetEntry) + if err != nil { + return nil, fmt.Errorf("read workflow file: %w", err) + } + + return map[string]any{ + "workflow": workflow, + "ref": ref, + "path": workflowsPath + "/" + workflow, + "content": string(content), + }, nil +} + +func toolGetArtifactDownloadURL(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + runIDFloat, ok := args["run_id"].(float64) + artifactName, _ := args["artifact_name"].(string) + + if owner == "" || repo == "" || !ok || artifactName == "" { + return nil, errors.New("owner, repo, run_id, and artifact_name are required") + } + runID := int64(runIDFloat) + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, runID) + if err != nil { + return nil, fmt.Errorf("run not found: %d", runID) + } + + // Check if artifact exists + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RunID: run.ID, + ArtifactName: artifactName, + }) + if err != nil { + return nil, fmt.Errorf("find artifact: %w", err) + } + + if len(artifacts) == 0 { + return nil, fmt.Errorf("artifact not found: %s", artifactName) + } + + // Check status + art := artifacts[0] + if art.Status == actions_model.ArtifactStatusExpired { + return map[string]any{ + "artifact_name": artifactName, + "status": "expired", + "message": "Artifact has expired and is no longer available for download", + }, nil + } + + if art.Status != actions_model.ArtifactStatusUploadConfirmed { + return map[string]any{ + "artifact_name": artifactName, + "status": "pending", + "message": "Artifact upload is not yet complete", + }, nil + } + + downloadURL := fmt.Sprintf("%s%s/%s/actions/runs/%d/artifacts/%s", + setting.AppURL, owner, repo, run.Index, url.PathEscape(artifactName)) + + return map[string]any{ + "artifact_name": artifactName, + "status": "available", + "size": art.FileSize, + "download_url": downloadURL, + }, nil +}