Compare commits

...

1 Commits

Author SHA1 Message Date
Lunny Xiao a5d3910a89 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 <codet@commitgo.dev> (GPT-5-Codex)
2026-08-24 00:13:45 -07:00
2 changed files with 158 additions and 2 deletions
+98
View File
@@ -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")
}
}
+60 -2
View File
@@ -29,15 +29,16 @@ var (
ActionsRunReadToolName, ActionsRunReadToolName,
"Read Actions workflows, runs, jobs, logs, and artifacts.", "Read Actions workflows, runs, jobs, logs, and artifacts.",
annotation.ReadOnly("Read Actions workflow, run, job, and artifact data"), 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("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.String("workflow_id", tool.Description("ID or filename (for 'get_workflow')")), 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("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("job_id", tool.Description("for 'get_job'/log methods")),
tool.Number("artifact_id", tool.Description("for 'get_artifact'/'download_artifact'")), 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("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("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.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'")), 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) return listRepoActionRunsFn(ctx, args)
case "get_run": case "get_run":
return getRepoActionRunFn(ctx, args) return getRepoActionRunFn(ctx, args)
case "list_pr_runs":
return listPullRequestActionRunsFn(ctx, args)
case "list_jobs": case "list_jobs":
return listRepoActionJobsFn(ctx, args) return listRepoActionJobsFn(ctx, args)
case "list_run_jobs": case "list_run_jobs":
@@ -297,6 +300,61 @@ func getRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallTool
return to.TextResult(slimActionRun(result)) 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) { func cancelRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {