From ba172e3db82b2b55f4b079823d1edb1c398f4d3e Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Mon, 4 May 2026 22:25:10 +0200 Subject: [PATCH] feat(tools): workflow_run_trigger --- cmd/gitea-mcp/main.go | 2 + internal/gitea/client.go | 32 +++++++ internal/gitea/workflows.go | 79 +++++++++++++++++ internal/gitea/workflows_test.go | 93 +++++++++++++++++++++ internal/tools/workflow_run_trigger.go | 84 +++++++++++++++++++ internal/tools/workflow_run_trigger_test.go | 86 +++++++++++++++++++ 6 files changed, 376 insertions(+) create mode 100644 internal/gitea/workflows.go create mode 100644 internal/gitea/workflows_test.go create mode 100644 internal/tools/workflow_run_trigger.go create mode 100644 internal/tools/workflow_run_trigger_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index d1674b5..fb8e013 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -33,6 +33,8 @@ func main() { reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow)) reg.Register(tools.NewPRCreate(giteaClient, ownerAllow)) reg.Register(tools.NewPRGet(giteaClient, ownerAllow)) + reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL)) + reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow)) mcpSrv := mcp.NewServer(mcp.ServerOptions{ Registry: reg, diff --git a/internal/gitea/client.go b/internal/gitea/client.go index 864ce3e..6590f17 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -67,3 +67,35 @@ func (c *Client) PutJSON(ctx context.Context, path string, body []byte) ([]byte, func (c *Client) DeleteJSON(ctx context.Context, path string) ([]byte, int, error) { return c.do(ctx, http.MethodDelete, path, nil) } + +type rawResponse struct { + Body []byte + Status int + Headers http.Header +} + +func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return nil, err + } + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return &rawResponse{Body: b, Status: resp.StatusCode, Headers: resp.Header}, err +} diff --git a/internal/gitea/workflows.go b/internal/gitea/workflows.go new file mode 100644 index 0000000..6b53905 --- /dev/null +++ b/internal/gitea/workflows.go @@ -0,0 +1,79 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// DispatchWorkflowArgs is the request body for a workflow_dispatch trigger. +type DispatchWorkflowArgs struct { + Ref string `json:"ref"` + Inputs map[string]any `json:"inputs,omitempty"` +} + +// WorkflowRunTrigger holds the run ID extracted from the Location header. +type WorkflowRunTrigger struct { + RunID int64 +} + +// DispatchWorkflow triggers a workflow_dispatch event and returns the new run ID. +func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) (*WorkflowRunTrigger, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow) + payload, err := json.Marshal(args) + if err != nil { + return nil, err + } + resp, err := c.doRaw(ctx, "POST", p, payload) + if err != nil { + return nil, err + } + if resp.Status != 204 { + if mapErr := MapStatus(resp.Status, resp.Body); mapErr != nil { + return nil, mapErr + } + return nil, fmt.Errorf("unexpected status %d", resp.Status) + } + location := resp.Headers.Get("Location") + if location == "" { + return nil, fmt.Errorf("missing Location header in dispatch response") + } + // Location is e.g. "/api/v1/repos/o/r/actions/runs/123" — take the last segment. + parts := strings.Split(strings.TrimRight(location, "/"), "/") + if len(parts) == 0 { + return nil, fmt.Errorf("malformed Location: %s", location) + } + runID, err := strconv.ParseInt(parts[len(parts)-1], 10, 64) + if err != nil { + return nil, fmt.Errorf("parse run id from %q: %w", location, err) + } + return &WorkflowRunTrigger{RunID: runID}, nil +} + +// WorkflowRun represents a Gitea Actions run. +type WorkflowRun struct { + ID int64 `json:"id"` + Status string `json:"status"` // queued | in_progress | completed + Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed) + StartedAt string `json:"started_at"` + HTMLURL string `json:"html_url"` +} + +// GetWorkflowRun fetches the status of a specific Actions run. +func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID int64) (*WorkflowRun, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, repo, runID) + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var run WorkflowRun + if err := json.Unmarshal(body, &run); err != nil { + return nil, err + } + return &run, nil +} diff --git a/internal/gitea/workflows_test.go b/internal/gitea/workflows_test.go new file mode 100644 index 0000000..5644f08 --- /dev/null +++ b/internal/gitea/workflows_test.go @@ -0,0 +1,93 @@ +package gitea_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDispatchWorkflow(t *testing.T) { + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/repos/o/r/actions/workflows/ci.yml/dispatches", r.URL.Path) + var err error + gotBody, err = io.ReadAll(r.Body) + assert.NoError(t, err) + w.Header().Set("Location", "/api/v1/repos/o/r/actions/runs/789") + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + result, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{ + Ref: "main", + Inputs: map[string]any{"env": "prod"}, + }) + require.NoError(t, err) + assert.Equal(t, int64(789), result.RunID) + + var body map[string]any + require.NoError(t, json.Unmarshal(gotBody, &body)) + assert.Equal(t, "main", body["ref"]) + inputs, ok := body["inputs"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "prod", inputs["env"]) +} + +func TestDispatchWorkflowMissingLocation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 204 but no Location header + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + _, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Location") +} + +func TestDispatchWorkflowError404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + _, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"}) + require.Error(t, err) + assert.True(t, errors.Is(err, gitea.ErrNotFound)) +} + +func TestGetWorkflowRun(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/o/r/actions/runs/789", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":789, + "status":"completed", + "conclusion":"success", + "started_at":"2026-05-04T10:00:00Z", + "html_url":"http://gitea.example/o/r/actions/runs/789" + }`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + run, err := c.GetWorkflowRun(context.Background(), "o", "r", 789) + require.NoError(t, err) + assert.Equal(t, int64(789), run.ID) + assert.Equal(t, "completed", run.Status) + assert.Equal(t, "success", run.Conclusion) + assert.Equal(t, "2026-05-04T10:00:00Z", run.StartedAt) + assert.Equal(t, "http://gitea.example/o/r/actions/runs/789", run.HTMLURL) +} diff --git a/internal/tools/workflow_run_trigger.go b/internal/tools/workflow_run_trigger.go new file mode 100644 index 0000000..400e823 --- /dev/null +++ b/internal/tools/workflow_run_trigger.go @@ -0,0 +1,84 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +// WorkflowRunTrigger triggers a Gitea Actions workflow_dispatch run. +type WorkflowRunTrigger struct { + c *gitea.Client + a *allowlist.Allowlist + baseURL string +} + +func NewWorkflowRunTrigger(c *gitea.Client, a *allowlist.Allowlist, baseURL string) *WorkflowRunTrigger { + return &WorkflowRunTrigger{c: c, a: a, baseURL: baseURL} +} + +func (t *WorkflowRunTrigger) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "workflow_run_trigger", + Description: "Trigger a Gitea Actions workflow_dispatch run.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "workflow":{"type":"string"}, + "ref":{"type":"string"}, + "inputs":{"type":"object"} + }, + "required":["owner","name","workflow"] + }`), + } +} + +type workflowRunTriggerArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Workflow string `json:"workflow"` + Ref string `json:"ref"` + Inputs map[string]any `json:"inputs"` +} + +func (t *WorkflowRunTrigger) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args workflowRunTriggerArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Workflow == "" { + return nil, fmt.Errorf("workflow is required: %w", gitea.ErrValidation) + } + + ref := args.Ref + if ref == "" { + repo, err := t.c.GetRepo(ctx, args.Owner, args.Name) + if err != nil { + return nil, err + } + ref = repo.DefaultBranch + } + + result, err := t.c.DispatchWorkflow(ctx, args.Owner, args.Name, args.Workflow, gitea.DispatchWorkflowArgs{ + Ref: ref, + Inputs: args.Inputs, + }) + if err != nil { + return nil, err + } + + htmlURL := fmt.Sprintf("%s/%s/%s/actions/runs/%d", t.baseURL, args.Owner, args.Name, result.RunID) + return textOK(map[string]any{ + "run_id": result.RunID, + "html_url": htmlURL, + }) +} diff --git a/internal/tools/workflow_run_trigger_test.go b/internal/tools/workflow_run_trigger_test.go new file mode 100644 index 0000000..86b3251 --- /dev/null +++ b/internal/tools/workflow_run_trigger_test.go @@ -0,0 +1,86 @@ +package tools_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowRunTriggerSuccess(t *testing.T) { + // Fake server handles both the repo endpoint (default_branch) and the dispatch endpoint. + repoHit := false + dispatchHit := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/repos/mathias/myrepo" && r.Method == http.MethodGet: + repoHit = true + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"myrepo","full_name":"mathias/myrepo","default_branch":"main"}`)) + case r.URL.Path == "/api/v1/repos/mathias/myrepo/actions/workflows/ci.yml/dispatches" && r.Method == http.MethodPost: + dispatchHit = true + w.Header().Set("Location", "/api/v1/repos/mathias/myrepo/actions/runs/42") + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + tool := tools.NewWorkflowRunTrigger(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}), srv.URL) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"myrepo","workflow":"ci.yml"}`)) + require.NoError(t, err) + assert.True(t, repoHit, "expected GET /repo for default branch") + assert.True(t, dispatchHit, "expected POST dispatch") + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, float64(42), result["run_id"]) + assert.Contains(t, result["html_url"], "/mathias/myrepo/actions/runs/42") +} + +func TestWorkflowRunTriggerExplicitRef(t *testing.T) { + repoHit := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/mathias/myrepo" { + repoHit = true + } + if r.URL.Path == "/api/v1/repos/mathias/myrepo/actions/workflows/ci.yml/dispatches" { + w.Header().Set("Location", "/api/v1/repos/mathias/myrepo/actions/runs/99") + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + tool := tools.NewWorkflowRunTrigger(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}), srv.URL) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"myrepo","workflow":"ci.yml","ref":"develop"}`)) + require.NoError(t, err) + assert.False(t, repoHit, "should not call GET /repo when ref is provided") + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + assert.Equal(t, float64(99), result["run_id"]) +} + +func TestWorkflowRunTriggerAllowlistRejects(t *testing.T) { + tool := tools.NewWorkflowRunTrigger(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "http://unused") + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","workflow":"ci.yml"}`)) + require.Error(t, err) +} + +func TestWorkflowRunTriggerRequiresWorkflow(t *testing.T) { + // workflow field is present in required schema but let's test empty string fallback guard + tool := tools.NewWorkflowRunTrigger(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "http://unused") + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"repo","workflow":""}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "workflow") +}