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
7 changed files with 160 additions and 326 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 {
+2 -52
View File
@@ -55,9 +55,9 @@ var (
IssueReadTool = tool.NewDefinition( IssueReadTool = tool.NewDefinition(
IssueReadToolName, IssueReadToolName,
"Read issue: details, comments, labels, or the full discussion as Markdown.", "Read issue: details, comments, or labels.",
annotation.ReadOnly("Read issue details"), annotation.ReadOnly("Read issue details"),
tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "get_discussion_markdown")), tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels")),
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.Number("issue_number", tool.Required()), tool.Number("issue_number", tool.Required()),
@@ -112,8 +112,6 @@ func issueReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult,
return getIssueCommentsByIndexFn(ctx, args) return getIssueCommentsByIndexFn(ctx, args)
case "get_labels": case "get_labels":
return getIssueLabelsFn(ctx, args) return getIssueLabelsFn(ctx, args)
case "get_discussion_markdown":
return getIssueDiscussionMarkdownFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
@@ -400,54 +398,6 @@ func getIssueCommentsByIndexFn(ctx context.Context, args map[string]any) (*mcp.C
return to.TextResult(out) return to.TextResult(out)
} }
func getIssueDiscussionMarkdownFn(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)
}
index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
var issue issueWithAssets
issuePath := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "GET", issuePath, nil, nil, &issue); err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
}
var comments []commentWithAssets
commentsPath := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "GET", commentsPath, nil, nil, &comments); err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
}
discComments := make([]discussionComment, 0, len(comments))
for i := range comments {
discComments = append(discComments, discussionComment{
Author: slim.UserLogin(comments[i].Poster),
CreatedAt: comments[i].Created,
Body: slim.BodyWithAttachments(comments[i].Body, comments[i].Assets),
})
}
md := formatDiscussionMarkdown(discussionIssue{
Number: issue.Index,
Title: issue.Title,
Author: slim.UserLogin(issue.Poster),
State: string(issue.State),
Labels: slim.LabelNames(issue.Labels),
Body: slim.BodyWithAttachments(issue.Body, issue.Assets),
CreatedAt: issue.Created,
}, discComments)
return to.RawTextResult(md)
}
func getIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) { func getIssueLabelsFn(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 {
-113
View File
@@ -266,119 +266,6 @@ func Test_getIssueByIndexFn_includesAttachments(t *testing.T) {
} }
} }
func Test_getIssueDiscussionMarkdownFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/42", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"number": 42,
"title": "bug with screenshot",
"body": "see attached",
"state": "open",
"user": {"login": "octocat"},
"labels": [{"name": "bug"}],
"created_at": "2026-01-02T15:04:05Z",
"assets": [
{"id": 1, "name": "shot.png", "size": 1024, "browser_download_url": "https://example/shot.png"}
]
}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/42/comments", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id": 1, "body": "see this", "user": {"login": "reviewer"}, "created_at": "2026-01-03T09:00:00Z", "assets": [
{"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"}
]}
]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
args := map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(42),
}
res, err := getIssueDiscussionMarkdownFn(context.Background(), args)
if err != nil {
t.Fatalf("getIssueDiscussionMarkdownFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(*mcp.TextContent).Text
for _, want := range []string{
"# bug with screenshot (#42)",
"**Author:** octocat",
"**State:** open",
"**Labels:** bug",
"[shot.png](https://example/shot.png)",
"### reviewer on 2026-01-03T09:00:00Z",
"[log.txt](https://example/log.txt)",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected markdown to contain %q, got:\n%s", want, body)
}
}
}
func Test_getIssueDiscussionMarkdownFn_noComments(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/7", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number": 7, "title": "quiet issue", "state": "open", "user": {"login": "octocat"}}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/7/comments", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
args := map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(7),
}
res, err := getIssueDiscussionMarkdownFn(context.Background(), args)
if err != nil {
t.Fatalf("getIssueDiscussionMarkdownFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, "_No comments yet._") {
t.Fatalf("expected placeholder for no comments, got:\n%s", body)
}
}
func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) { func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) {
const ( const (
owner = "octo" owner = "octo"
-63
View File
@@ -1,63 +0,0 @@
package issue
import (
"fmt"
"strings"
"time"
)
type discussionIssue struct {
Number int64
Title string
Author string
State string
Labels []string
Body string
CreatedAt time.Time
}
type discussionComment struct {
Author string
CreatedAt time.Time
Body string
}
// formatDiscussionMarkdown renders an issue and its comments as a single
// Markdown document suitable for display without further parsing.
func formatDiscussionMarkdown(issue discussionIssue, comments []discussionComment) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s (#%d)\n\n", issue.Title, issue.Number)
fmt.Fprintf(&b, "**Author:** %s\n", issue.Author)
fmt.Fprintf(&b, "**State:** %s\n", issue.State)
if len(issue.Labels) > 0 {
fmt.Fprintf(&b, "**Labels:** %s\n", strings.Join(issue.Labels, ", "))
}
if !issue.CreatedAt.IsZero() {
fmt.Fprintf(&b, "**Created:** %s\n", issue.CreatedAt.UTC().Format(time.RFC3339))
}
b.WriteString("\n")
b.WriteString(strings.TrimSpace(issue.Body))
b.WriteString("\n\n---\n\n## Comments\n\n")
if len(comments) == 0 {
b.WriteString("_No comments yet._\n")
return b.String()
}
for i, c := range comments {
fmt.Fprintf(&b, "### %s", c.Author)
if !c.CreatedAt.IsZero() {
fmt.Fprintf(&b, " on %s", c.CreatedAt.UTC().Format(time.RFC3339))
}
b.WriteString("\n\n")
b.WriteString(strings.TrimSpace(c.Body))
if i < len(comments)-1 {
b.WriteString("\n\n---\n\n")
} else {
b.WriteString("\n")
}
}
return b.String()
}
-85
View File
@@ -1,85 +0,0 @@
package issue
import (
"strings"
"testing"
"time"
)
func Test_formatDiscussionMarkdown_includesIssueAndComments(t *testing.T) {
created := time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)
commentTime := time.Date(2026, 1, 3, 9, 0, 0, 0, time.UTC)
md := formatDiscussionMarkdown(
discussionIssue{
Number: 42,
Title: "bug with screenshot",
Author: "octocat",
State: "open",
Labels: []string{"bug", "help wanted"},
Body: "see attached",
CreatedAt: created,
},
[]discussionComment{
{Author: "reviewer", CreatedAt: commentTime, Body: "thanks for reporting"},
},
)
wantSubstrings := []string{
"# bug with screenshot (#42)",
"**Author:** octocat",
"**State:** open",
"**Labels:** bug, help wanted",
"**Created:** 2026-01-02T15:04:05Z",
"see attached",
"## Comments",
"### reviewer",
"2026-01-03T09:00:00Z",
"thanks for reporting",
}
for _, want := range wantSubstrings {
if !strings.Contains(md, want) {
t.Fatalf("expected markdown to contain %q, got:\n%s", want, md)
}
}
}
func Test_formatDiscussionMarkdown_noComments(t *testing.T) {
md := formatDiscussionMarkdown(
discussionIssue{
Number: 1,
Title: "no comments yet",
Author: "octocat",
State: "open",
},
nil,
)
if !strings.Contains(md, "## Comments") {
t.Fatalf("expected a Comments section, got:\n%s", md)
}
if !strings.Contains(md, "_No comments yet._") {
t.Fatalf("expected placeholder for no comments, got:\n%s", md)
}
}
func Test_formatDiscussionMarkdown_attachmentBodyIsInlined(t *testing.T) {
md := formatDiscussionMarkdown(
discussionIssue{
Number: 7,
Title: "with attachment",
Author: "octocat",
State: "open",
Body: "see attached\n\n[shot.png](https://example/shot.png)",
},
[]discussionComment{
{Author: "reviewer", Body: "log attached\n\n[log.txt](https://example/log.txt)"},
},
)
if !strings.Contains(md, "[shot.png](https://example/shot.png)") {
t.Fatalf("expected issue attachment link in markdown, got:\n%s", md)
}
if !strings.Contains(md, "[log.txt](https://example/log.txt)") {
t.Fatalf("expected comment attachment link in markdown, got:\n%s", md)
}
}
-11
View File
@@ -23,17 +23,6 @@ func TextResult(v any) (*mcp.CallToolResult, error) {
}, nil }, nil
} }
// RawTextResult returns text as-is, without JSON-encoding it. Use it for
// content that is already meant to be read directly, such as Markdown.
func RawTextResult(text string) (*mcp.CallToolResult, error) {
if flag.Debug {
log.Debugf("Text Result: %s", text)
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}, nil
}
func ErrorResult(err error) (*mcp.CallToolResult, error) { func ErrorResult(err error) (*mcp.CallToolResult, error) {
log.Errorf("%s", err.Error()) log.Errorf("%s", err.Error())
var result mcp.CallToolResult var result mcp.CallToolResult