package tools import ( "context" "encoding/json" "fmt" "time" "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/registry" ) // dispatchResolveBudget bounds how many times we poll for the dispatched run to // appear, and dispatchResolvePoll is the gap between polls. Gitea returns 204 // with no run ID, so we list workflow_dispatch runs and take the newest above // the pre-dispatch baseline; it may register a beat after the 204. const ( dispatchResolveBudget = 5 dispatchResolvePoll = time.Second ) // 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"}, "repo":{"type":"string"}, "workflow":{"type":"string"}, "ref":{"type":"string"}, "inputs":{"type":"object"} }, "required":["owner","repo","workflow"] }`), } } type workflowRunTriggerArgs struct { Owner string `json:"owner"` Repo string `json:"repo"` 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(ctx, 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 == "" { var err error ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Repo) if err != nil { return nil, err } } // Snapshot the newest existing workflow_dispatch run BEFORE dispatching, so we // can tell our fresh run apart from a prior one (Gitea's 204 carries no run ID). baseline := t.newestDispatchRunID(ctx, args.Owner, args.Repo, args.Workflow, ref) if err := t.c.DispatchWorkflow(ctx, args.Owner, args.Repo, args.Workflow, gitea.DispatchWorkflowArgs{ Ref: ref, Inputs: args.Inputs, }); err != nil { return nil, err } // Resolve the new run by listing workflow_dispatch runs and taking the newest // one whose ID exceeds the baseline. Poll briefly: the run can register a beat // after the 204. var run *gitea.WorkflowRun for i := 0; i < dispatchResolveBudget; i++ { if i > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(dispatchResolvePoll): } } if r := t.newestDispatchRun(ctx, args.Owner, args.Repo, args.Workflow, ref); r != nil && r.ID > baseline { run = r break } } // Dispatch succeeded (204). If the run has not surfaced yet, say so honestly // rather than failing — the workflow IS firing; the caller can list runs. if run == nil { return textOK(map[string]any{ "dispatched": true, "note": "dispatch accepted but the run did not register within the resolve window; " + "list recent workflow_dispatch runs to find it", }) } htmlURL := run.HTMLURL if htmlURL == "" { htmlURL = fmt.Sprintf("%s/%s/%s/actions/runs/%d", t.baseURL, args.Owner, args.Repo, run.ID) } return textOK(map[string]any{ "dispatched": true, "run_id": run.ID, "html_url": htmlURL, }) } // newestDispatchRun returns the most recent workflow_dispatch run for the given // workflow and ref, or nil if none / on listing error (best-effort resolution). func (t *WorkflowRunTrigger) newestDispatchRun(ctx context.Context, owner, name, workflow, ref string) *gitea.WorkflowRun { resp, err := t.c.ListWorkflowRuns(ctx, owner, name, gitea.ListWorkflowRunsArgs{ Event: "workflow_dispatch", Workflow: workflow, Branch: ref, Limit: 20, }) if err != nil || resp == nil { return nil } var newest *gitea.WorkflowRun for i := range resp.WorkflowRuns { r := &resp.WorkflowRuns[i] if newest == nil || r.ID > newest.ID { newest = r } } return newest } // newestDispatchRunID is newestDispatchRun's ID, or 0 if none. func (t *WorkflowRunTrigger) newestDispatchRunID(ctx context.Context, owner, name, workflow, ref string) int64 { if r := t.newestDispatchRun(ctx, owner, name, workflow, ref); r != nil { return r.ID } return 0 }