Files
gitea-mcp/operation/actions/runs.go
runixer e851f542f5 fix(actions): change workflow_id parameter type from number to string (#114)
The Gitea API expects workflow_id as a string (filename like 'my-workflow.yml'
or numeric ID as string), not as a number. This was causing 404 errors when
trying to get or dispatch workflows.

Affected tools:
- get_repo_action_workflow
- dispatch_repo_action_workflow

Co-authored-by: runixer <runixer@yandex.ru>
Co-authored-by: hiifong <f@f.style>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/114
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: runixer <runixer@noreply.gitea.com>
Co-committed-by: runixer <runixer@noreply.gitea.com>
2026-01-01 14:29:06 +00:00

469 lines
17 KiB
Go

package actions
import (
"context"
"errors"
"fmt"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListRepoActionWorkflowsToolName = "list_repo_action_workflows"
GetRepoActionWorkflowToolName = "get_repo_action_workflow"
DispatchRepoActionWorkflowToolName = "dispatch_repo_action_workflow"
ListRepoActionRunsToolName = "list_repo_action_runs"
GetRepoActionRunToolName = "get_repo_action_run"
CancelRepoActionRunToolName = "cancel_repo_action_run"
RerunRepoActionRunToolName = "rerun_repo_action_run"
ListRepoActionJobsToolName = "list_repo_action_jobs"
ListRepoActionRunJobsToolName = "list_repo_action_run_jobs"
)
var (
ListRepoActionWorkflowsTool = mcp.NewTool(
ListRepoActionWorkflowsToolName,
mcp.WithDescription("List repository Actions workflows"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
)
GetRepoActionWorkflowTool = mcp.NewTool(
GetRepoActionWorkflowToolName,
mcp.WithDescription("Get a repository Actions workflow by ID"),
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 (e.g. 'my-workflow.yml')")),
)
DispatchRepoActionWorkflowTool = mcp.NewTool(
DispatchRepoActionWorkflowToolName,
mcp.WithDescription("Trigger (dispatch) a repository Actions workflow"),
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 (e.g. 'my-workflow.yml')")),
mcp.WithString("ref", mcp.Required(), mcp.Description("git ref (branch or tag)")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object")),
)
ListRepoActionRunsTool = mcp.NewTool(
ListRepoActionRunsToolName,
mcp.WithDescription("List repository Actions workflow runs"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
mcp.WithString("status", mcp.Description("optional status filter")),
)
GetRepoActionRunTool = mcp.NewTool(
GetRepoActionRunToolName,
mcp.WithDescription("Get a repository Actions run by ID"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")),
)
CancelRepoActionRunTool = mcp.NewTool(
CancelRepoActionRunToolName,
mcp.WithDescription("Cancel a repository Actions run"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")),
)
RerunRepoActionRunTool = mcp.NewTool(
RerunRepoActionRunToolName,
mcp.WithDescription("Rerun a repository Actions run"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")),
)
ListRepoActionJobsTool = mcp.NewTool(
ListRepoActionJobsToolName,
mcp.WithDescription("List repository Actions jobs"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
mcp.WithString("status", mcp.Description("optional status filter")),
)
ListRepoActionRunJobsTool = mcp.NewTool(
ListRepoActionRunJobsToolName,
mcp.WithDescription("List Actions jobs for a specific run"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionWorkflowsTool, Handler: ListRepoActionWorkflowsFn})
Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionWorkflowTool, Handler: GetRepoActionWorkflowFn})
Tool.RegisterWrite(server.ServerTool{Tool: DispatchRepoActionWorkflowTool, Handler: DispatchRepoActionWorkflowFn})
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionRunsTool, Handler: ListRepoActionRunsFn})
Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionRunTool, Handler: GetRepoActionRunFn})
Tool.RegisterWrite(server.ServerTool{Tool: CancelRepoActionRunTool, Handler: CancelRepoActionRunFn})
Tool.RegisterWrite(server.ServerTool{Tool: RerunRepoActionRunTool, Handler: RerunRepoActionRunFn})
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionJobsTool, Handler: ListRepoActionJobsFn})
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionRunJobsTool, Handler: ListRepoActionRunJobsFn})
}
func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body any, respOut any) (string, int, error) {
var lastErr error
for _, p := range paths {
status, err := gitea.DoJSON(ctx, method, p, query, body, respOut)
if err == nil {
return p, status, nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) {
continue
}
return p, status, err
}
return "", 0, lastErr
}
func ListRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoActionWorkflowsFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 50
}
query := url.Values{}
query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
var result any
_, _, err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err))
}
return to.TextResult(result)
}
func GetRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetRepoActionWorkflowFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
workflowID, ok := req.GetArguments()["workflow_id"].(string)
if !ok || workflowID == "" {
return to.ErrorResult(fmt.Errorf("workflow_id is required"))
}
var result any
_, _, err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err))
}
return to.TextResult(result)
}
func DispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DispatchRepoActionWorkflowFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
workflowID, ok := req.GetArguments()["workflow_id"].(string)
if !ok || workflowID == "" {
return to.ErrorResult(fmt.Errorf("workflow_id is required"))
}
ref, ok := req.GetArguments()["ref"].(string)
if !ok || ref == "" {
return to.ErrorResult(fmt.Errorf("ref is required"))
}
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
} else if m, ok := raw.(map[string]interface{}); ok {
inputs = make(map[string]any, len(m))
for k, v := range m {
inputs[k] = v
}
}
}
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 errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) {
return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err))
}
return to.TextResult(map[string]any{"message": "workflow dispatched"})
}
func ListRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoActionRunsFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 50
}
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
_, _, 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 to.ErrorResult(fmt.Errorf("list action runs err: %v", err))
}
return to.TextResult(result)
}
func GetRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetRepoActionRunFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
runID, ok := req.GetArguments()["run_id"].(float64)
if !ok || runID <= 0 {
return to.ErrorResult(fmt.Errorf("run_id is required"))
}
var result any
_, _, err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action run err: %v", err))
}
return to.TextResult(result)
}
func CancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CancelRepoActionRunFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
runID, ok := req.GetArguments()["run_id"].(float64)
if !ok || runID <= 0 {
return to.ErrorResult(fmt.Errorf("run_id is required"))
}
_, _, err := doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
},
nil, nil, nil,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("cancel action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run cancellation requested"})
}
func RerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called RerunRepoActionRunFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
runID, ok := req.GetArguments()["run_id"].(float64)
if !ok || runID <= 0 {
return to.ErrorResult(fmt.Errorf("run_id is required"))
}
_, _, err := doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
},
nil, nil, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) {
return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run rerun requested"})
}
func ListRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoActionJobsFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 50
}
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
_, _, err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err))
}
return to.TextResult(result)
}
func ListRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoActionRunJobsFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
runID, ok := req.GetArguments()["run_id"].(float64)
if !ok || runID <= 0 {
return to.ErrorResult(fmt.Errorf("run_id is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 50
}
query := url.Values{}
query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
var result any
_, _, err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err))
}
return to.TextResult(result)
}