feat(actions): add get_job to read a single Actions job (#211)

Adds a read-only `get_job` method to the `actions_run_read` tool. `actions_run_read` could already `list_jobs` / `list_run_jobs` (slimmed lists) and read job logs by `job_id`, but there was no way to fetch the full detail of one job. `get_job` closes that gap.Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/211
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni
2026-07-21 00:39:49 +00:00
committed by Lunny Xiao
parent a998cc3389
commit b1a6ed9f6b
4 changed files with 100 additions and 2 deletions
+1
View File
@@ -300,6 +300,7 @@ The Gitea MCP Server supports the following tools:
| rerun_repo_action_run | Actions | Rerun a repository Actions run | | rerun_repo_action_run | Actions | Rerun a repository Actions run |
| list_repo_action_jobs | Actions | List repository Actions jobs | | list_repo_action_jobs | Actions | List repository Actions jobs |
| list_repo_action_run_jobs | Actions | List Actions jobs for a run | | 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) | | 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 | | download_repo_action_job_log | Actions | Download a job log to a file |
| list_repo_action_artifacts | Actions | List repository Actions artifacts | | list_repo_action_artifacts | Actions | List repository Actions artifacts |
+35 -2
View File
@@ -29,12 +29,12 @@ var (
ActionsRunReadToolName, ActionsRunReadToolName,
mcp.WithDescription("Read Actions workflows, runs, jobs, logs, and artifacts."), mcp.WithDescription("Read Actions workflows, runs, jobs, logs, and artifacts."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, job, and artifact data")), 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("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")), 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("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.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("artifact_name", mcp.Description("name filter for 'list_artifacts'/'list_run_artifacts'")),
mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")), 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) return listRepoActionJobsFn(ctx, req)
case "list_run_jobs": case "list_run_jobs":
return listRepoActionRunJobsFn(ctx, req) return listRepoActionRunJobsFn(ctx, req)
case "get_job":
return getRepoActionJobFn(ctx, req)
case "get_job_log_preview": case "get_job_log_preview":
return getRepoActionJobLogPreviewFn(ctx, req) return getRepoActionJobLogPreviewFn(ctx, req)
case "download_job_log": case "download_job_log":
@@ -416,6 +418,37 @@ func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(slimActionJobs(result)) 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 { func logPaths(owner, repo string, jobID int64) []string {
return []string{ return []string{
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID), fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID),
+7
View File
@@ -76,6 +76,13 @@ func slimActionRuns(raw any) any {
return slimPaginated(raw, slimRun) 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 { func slimActionJobs(raw any) any {
return slimPaginated(raw, slimJob) return slimPaginated(raw, slimJob)
} }
+57
View File
@@ -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)
}
}