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
4 changed files with 160 additions and 120 deletions
+2 -57
View File
@@ -1,10 +1,7 @@
package main package main
import ( import (
"path"
"runtime/debug" "runtime/debug"
"strconv"
"strings"
"gitea.com/gitea/gitea-mcp/cmd" "gitea.com/gitea/gitea-mcp/cmd"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
@@ -14,65 +11,13 @@ var Version = "dev"
func init() { func init() {
if Version == "dev" { if Version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
Version = resolveVersion(Version, info) Version = info.Main.Version
} }
} }
flag.Version = Version flag.Version = Version
} }
// resolveVersion returns the version reported by debug.ReadBuildInfo when its
// major version matches the major version encoded in the module path (e.g.
// "/v2" suffix). Otherwise it falls back to devVersion, since Go's module
// versioning rules make a mismatched major version untrustworthy (see #231).
func resolveVersion(devVersion string, info *debug.BuildInfo) string {
if info == nil {
return devVersion
}
buildVersion := info.Main.Version
if buildVersion == "" || buildVersion == "(devel)" {
return devVersion
}
buildMajor := majorVersionOf(buildVersion)
pathMajor := majorVersionFromModulePath(info.Main.Path)
if buildMajor != pathMajor {
return devVersion
}
return buildVersion
}
// majorVersionOf extracts the numeric major version from a semver-like
// string such as "v1.2.3", returning 0 if it cannot be parsed.
func majorVersionOf(version string) int {
version = strings.TrimPrefix(version, "v")
dot := strings.IndexByte(version, '.')
if dot >= 0 {
version = version[:dot]
}
major, err := strconv.Atoi(version)
if err != nil {
return 0
}
return major
}
// majorVersionFromModulePath returns the major version encoded in a module
// path's "/vN" suffix, or 1 if the module path has no such suffix (as is the
// case for v0 and v1 modules).
func majorVersionFromModulePath(modulePath string) int {
suffix := path.Base(modulePath)
if len(suffix) < 2 || suffix[0] != 'v' {
return 1
}
major, err := strconv.Atoi(suffix[1:])
if err != nil {
return 1
}
return major
}
func main() { func main() {
cmd.Execute() cmd.Execute()
} }
-61
View File
@@ -1,61 +0,0 @@
package main
import (
"runtime/debug"
"testing"
)
func TestResolveVersion(t *testing.T) {
cases := []struct {
name string
dev string
info *debug.BuildInfo
want string
}{
{
name: "nil build info falls back to dev version",
dev: "dev",
info: nil,
want: "dev",
},
{
name: "devel version falls back to dev version",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "(devel)"}},
want: "dev",
},
{
name: "empty version falls back to dev version",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: ""}},
want: "dev",
},
{
name: "v1 version accepted for module path without major suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "v1.2.3"}},
want: "v1.2.3",
},
{
name: "v2 version rejected when module path has no /v2 suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp", Version: "v2.0.0"}},
want: "dev",
},
{
name: "v2 version accepted when module path has /v2 suffix",
dev: "dev",
info: &debug.BuildInfo{Main: debug.Module{Path: "gitea.com/gitea/gitea-mcp/v2", Version: "v2.0.0"}},
want: "v2.0.0",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resolveVersion(tc.dev, tc.info)
if got != tc.want {
t.Errorf("resolveVersion(%q, %+v) = %q, want %q", tc.dev, tc.info, got, tc.want)
}
})
}
}
+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 {