From a5d3910a8951496601cd20864a1013312fd0b038 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 24 Aug 2026 00:13:45 -0700 Subject: [PATCH] feat(actions): add list_pr_runs method to actions_run_read Fetch the PR head SHA from repos/{owner}/{repo}/pulls/{pull_number} and use it to query repos/{owner}/{repo}/actions/runs?head_sha=..., returning the slim run list for the pull request. Co-Authored-By: Codet (GPT-5-Codex) --- operation/actions/pr_runs_test.go | 98 +++++++++++++++++++++++++++++++ operation/actions/runs.go | 62 ++++++++++++++++++- 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 operation/actions/pr_runs_test.go diff --git a/operation/actions/pr_runs_test.go b/operation/actions/pr_runs_test.go new file mode 100644 index 0000000..76fdd2e --- /dev/null +++ b/operation/actions/pr_runs_test.go @@ -0,0 +1,98 @@ +package actions + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func Test_listPullRequestActionRunsFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + pullNumber = 42 + headSHA = "abc123" + ) + + var gotRunsQuery string + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, pullNumber): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"head":{"sha":"%s"}}`, pullNumber, headSHA)) + case fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs", owner, repo): + gotRunsQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"total_count":1,"workflow_runs":[{"id":9,"name":"CI","status":"success"}]}`)) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + origHost := flag.Host + origToken := flag.Token + flag.Host = server.URL + flag.Token = "" + defer func() { + flag.Host = origHost + flag.Token = origToken + }() + + args := map[string]any{ + "owner": owner, + "repo": repo, + "pull_number": float64(pullNumber), + } + + result, err := listPullRequestActionRunsFn(context.Background(), args) + if err != nil { + t.Fatalf("listPullRequestActionRunsFn() error = %v", err) + } + if result.IsError { + t.Fatalf("listPullRequestActionRunsFn() returned error result: %+v", result) + } + + if gotRunsQuery == "" { + t.Fatalf("expected actions/runs to be called") + } + values, err := url.ParseQuery(gotRunsQuery) + if err != nil { + t.Fatalf("parse actions/runs query: %v", err) + } + if got := values.Get("head_sha"); got != headSHA { + t.Fatalf("actions/runs head_sha = %q, want %q", got, headSHA) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("expected text content, got %T", result.Content[0]) + } + + var parsed struct { + WorkflowRuns []map[string]any `json:"workflow_runs"` + } + if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil { + t.Fatalf("unmarshal result text: %v", err) + } + if len(parsed.WorkflowRuns) != 1 { + t.Fatalf("expected 1 run, got %d", len(parsed.WorkflowRuns)) + } + if got := parsed.WorkflowRuns[0]["name"]; got != "CI" { + t.Fatalf("run name = %v, want %q", got, "CI") + } +} diff --git a/operation/actions/runs.go b/operation/actions/runs.go index 539f782..5c32a62 100644 --- a/operation/actions/runs.go +++ b/operation/actions/runs.go @@ -29,15 +29,16 @@ var ( ActionsRunReadToolName, "Read Actions workflows, runs, jobs, logs, and artifacts.", annotation.ReadOnly("Read Actions workflow, run, job, and artifact data"), - tool.String("method", tool.Required(), tool.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")), + tool.String("method", tool.Required(), tool.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_pr_runs", "list_jobs", "list_run_jobs", "get_job", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact")), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.String("workflow_id", tool.Description("ID or filename (for 'get_workflow')")), tool.Number("run_id", tool.Description("for 'get_run'/'list_run_jobs'/'list_run_artifacts'")), + tool.Number("pull_number", tool.Description("pull request number (for 'list_pr_runs')")), tool.Number("job_id", tool.Description("for 'get_job'/log methods")), tool.Number("artifact_id", tool.Description("for 'get_artifact'/'download_artifact'")), tool.String("artifact_name", tool.Description("name filter for 'list_artifacts'/'list_run_artifacts'")), - tool.String("status", tool.Description("filter for 'list_runs'/'list_jobs'")), + tool.String("status", tool.Description("filter for 'list_runs'/'list_pr_runs'/'list_jobs'")), tool.Number("tail_lines", tool.Description("log tail lines"), tool.Default(200), tool.Minimum(1)), tool.Number("max_bytes", tool.Description("max log bytes"), tool.Default(65536), tool.Minimum(1024)), tool.String("output_path", tool.Description("for 'download_job_log'/'download_artifact'")), @@ -78,6 +79,8 @@ func runReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, e return listRepoActionRunsFn(ctx, args) case "get_run": return getRepoActionRunFn(ctx, args) + case "list_pr_runs": + return listPullRequestActionRunsFn(ctx, args) case "list_jobs": return listRepoActionJobsFn(ctx, args) case "list_run_jobs": @@ -297,6 +300,61 @@ func getRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallTool return to.TextResult(slimActionRun(result)) } +func listPullRequestActionRunsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + pullNumber, err := params.GetIndex(args, "pull_number") + if err != nil || pullNumber <= 0 { + return to.ErrorResult(errors.New("pull_number is required")) + } + page, pageSize := params.GetPagination(args, 30) + statusFilter, _ := args["status"].(string) + + var pull struct { + Head struct { + SHA string `json:"sha"` + } `json:"head"` + } + err = doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/pulls/%d", url.PathEscape(owner), url.PathEscape(repo), pullNumber), + }, + nil, nil, &pull, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("get pull request err: %v", err)) + } + if pull.Head.SHA == "" { + return to.ErrorResult(fmt.Errorf("pull request %d has no head sha", pullNumber)) + } + + query := url.Values{} + query.Set("head_sha", pull.Head.SHA) + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(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 pull request action runs err: %v", err)) + } + return to.TextResult(slimActionRuns(result)) +} + func cancelRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { owner, err := params.GetString(args, "owner") if err != nil {