diff --git a/README.md b/README.md index d0f4963..6cc2b92 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,7 @@ The Gitea MCP Server supports the following tools: | rerun_repo_action_run | Actions | Rerun a repository Actions run | | list_repo_action_jobs | Actions | List repository Actions jobs | | list_repo_action_run_jobs | Actions | List Actions jobs for a run | +| get_repo_action_job | Actions | Get a single Actions job's detail | | get_repo_action_job_log_preview | Actions | Get a job log preview (tail/limited) | | download_repo_action_job_log | Actions | Download a job log to a file | | list_repo_action_artifacts | Actions | List repository Actions artifacts | diff --git a/operation/actions/runs.go b/operation/actions/runs.go index c906101..98332ae 100644 --- a/operation/actions/runs.go +++ b/operation/actions/runs.go @@ -29,12 +29,12 @@ var ( ActionsRunReadToolName, mcp.WithDescription("Read Actions workflows, runs, jobs, logs, and artifacts."), mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, job, and artifact data")), - mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact")), + mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")), mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'/'list_run_artifacts'")), - mcp.WithNumber("job_id", mcp.Description("for log methods")), + mcp.WithNumber("job_id", mcp.Description("for 'get_job'/log methods")), mcp.WithNumber("artifact_id", mcp.Description("for 'get_artifact'/'download_artifact'")), mcp.WithString("artifact_name", mcp.Description("name filter for 'list_artifacts'/'list_run_artifacts'")), mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")), @@ -82,6 +82,8 @@ func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResul return listRepoActionJobsFn(ctx, req) case "list_run_jobs": return listRepoActionRunJobsFn(ctx, req) + case "get_job": + return getRepoActionJobFn(ctx, req) case "get_job_log_preview": return getRepoActionJobLogPreviewFn(ctx, req) case "download_job_log": @@ -416,6 +418,37 @@ func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp return to.TextResult(slimActionJobs(result)) } +func getRepoActionJobFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := params.GetString(req.GetArguments(), "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(req.GetArguments(), "repo") + if err != nil { + return to.ErrorResult(err) + } + jobID, err := params.GetIndex(req.GetArguments(), "job_id") + if err != nil || jobID <= 0 { + return to.ErrorResult(errors.New("job_id is required")) + } + + var result any + err = doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/jobs/%d", url.PathEscape(owner), url.PathEscape(repo), jobID), + }, + nil, nil, &result, + ) + if err != nil { + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) { + return to.ErrorResult(fmt.Errorf("get action job not supported on this Gitea version (endpoint returned %d, requires Gitea 1.26+). Check https://docs.gitea.com/api/1.26/ for available Actions endpoints", httpErr.StatusCode)) + } + return to.ErrorResult(fmt.Errorf("get action job err: %v", err)) + } + return to.TextResult(slimActionJob(result)) +} + func logPaths(owner, repo string, jobID int64) []string { return []string{ fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID), diff --git a/operation/actions/slim.go b/operation/actions/slim.go index 3ffc9d3..4257c32 100644 --- a/operation/actions/slim.go +++ b/operation/actions/slim.go @@ -76,6 +76,13 @@ func slimActionRuns(raw any) any { return slimPaginated(raw, slimRun) } +func slimActionJob(raw any) any { + if m, ok := raw.(map[string]any); ok { + return slimJob(m) + } + return raw +} + func slimActionJobs(raw any) any { return slimPaginated(raw, slimJob) } diff --git a/operation/actions/slim_test.go b/operation/actions/slim_test.go new file mode 100644 index 0000000..f4ed7e3 --- /dev/null +++ b/operation/actions/slim_test.go @@ -0,0 +1,57 @@ +package actions + +import "testing" + +func TestSlimActionJobKeepsExpectedKeys(t *testing.T) { + raw := map[string]any{ + "id": float64(42), + "run_id": float64(7), + "name": "build", + "workflow_name": "CI", + "status": "completed", + "conclusion": "success", + "html_url": "https://gitea.example/x", + "started_at": "2026-01-01T00:00:00Z", + "completed_at": "2026-01-01T00:01:00Z", + "steps": []any{ + map[string]any{"name": "checkout", "number": float64(1), "status": "completed", "conclusion": "success", "extra": "drop me"}, + }, + // fields that must be dropped + "head_sha": "deadbeef", + "runner_name": "runner-1", + } + + out, ok := slimActionJob(raw).(map[string]any) + if !ok { + t.Fatalf("slimActionJob did not return a map, got %T", slimActionJob(raw)) + } + + for _, k := range []string{"id", "run_id", "name", "workflow_name", "status", "conclusion", "html_url", "started_at", "completed_at", "steps"} { + if _, present := out[k]; !present { + t.Errorf("expected key %q to be kept", k) + } + } + for _, k := range []string{"head_sha", "runner_name"} { + if _, present := out[k]; present { + t.Errorf("expected key %q to be dropped", k) + } + } + + steps, ok := out["steps"].([]any) + if !ok || len(steps) != 1 { + t.Fatalf("expected 1 slimmed step, got %v", out["steps"]) + } + step := steps[0].(map[string]any) + if _, present := step["extra"]; present { + t.Errorf("expected step field 'extra' to be dropped") + } + if step["name"] != "checkout" { + t.Errorf("expected step name 'checkout', got %v", step["name"]) + } +} + +func TestSlimActionJobPassesThroughNonMap(t *testing.T) { + if got := slimActionJob("not-a-map"); got != "not-a-map" { + t.Errorf("expected passthrough, got %v", got) + } +}