Compare commits

..

1 Commits

Author SHA1 Message Date
Lunny Xiao e885e5a4e1 feat(actions): add wait_for_pr_checks method to actions_run_read
Add a wait_for_pr_checks method that resolves a pull request's head
SHA, then polls its Actions runs until every run reaches a terminal
status/conclusion or a timeout elapses, returning the runs and
whether the wait timed out.

Co-Authored-By: Codet <codet@commitgo.dev> (GPT-5-Codex)
2026-08-23 23:38:53 -07:00
9 changed files with 349 additions and 70 deletions
-2
View File
@@ -31,7 +31,5 @@ USER nonroot:nonroot
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.source="https://gitea.com/gitea/gitea-mcp" LABEL org.opencontainers.image.source="https://gitea.com/gitea/gitea-mcp"
LABEL org.opencontainers.image.title="Gitea MCP Server"
LABEL org.opencontainers.image.description="Model Context Protocol server connecting Gitea instances to MCP clients, published as docker.gitea.com/gitea-mcp-server."
CMD ["/app/gitea-mcp"] CMD ["/app/gitea-mcp"]
-2
View File
@@ -10,8 +10,6 @@
Download a binary from the [releases page](https://gitea.com/gitea/gitea-mcp/releases) and put it in your `PATH`, use the `docker.gitea.com/gitea-mcp-server` image, or build from source into `$GOPATH/bin` with `make` and Go 1.26 or later: Download a binary from the [releases page](https://gitea.com/gitea/gitea-mcp/releases) and put it in your `PATH`, use the `docker.gitea.com/gitea-mcp-server` image, or build from source into `$GOPATH/bin` with `make` and Go 1.26 or later:
> The published Docker image is `docker.gitea.com/gitea-mcp-server`, which does not match the `gitea-mcp` repository name. This is a known mismatch, tracked in [issue #181](https://gitea.com/gitea/gitea-mcp/issues/181); the image name stays as-is for now to avoid breaking existing installs. See [docs/adr/docker-image-naming.md](docs/adr/docker-image-naming.md) for details.
```bash ```bash
git clone https://gitea.com/gitea/gitea-mcp.git git clone https://gitea.com/gitea/gitea-mcp.git
cd gitea-mcp cd gitea-mcp
-2
View File
@@ -10,8 +10,6 @@
可从 [发布页面](https://gitea.com/gitea/gitea-mcp/releases) 下载二进制文件并放入 `PATH`,或使用 `docker.gitea.com/gitea-mcp-server` 镜像,也可用 `make` 和 Go 1.26 及以上从源码构建到 `$GOPATH/bin` 可从 [发布页面](https://gitea.com/gitea/gitea-mcp/releases) 下载二进制文件并放入 `PATH`,或使用 `docker.gitea.com/gitea-mcp-server` 镜像,也可用 `make` 和 Go 1.26 及以上从源码构建到 `$GOPATH/bin`
> 已发布的 Docker 镜像名为 `docker.gitea.com/gitea-mcp-server`,与 `gitea-mcp` 仓库名不一致。这是 [issue #181](https://gitea.com/gitea/gitea-mcp/issues/181) 中记录的已知不一致,为避免破坏现有安装,镜像名暂不改动。详见 [docs/adr/docker-image-naming.md](docs/adr/docker-image-naming.md)。
```bash ```bash
git clone https://gitea.com/gitea/gitea-mcp.git git clone https://gitea.com/gitea/gitea-mcp.git
cd gitea-mcp cd gitea-mcp
-2
View File
@@ -10,8 +10,6 @@
可從 [發布頁面](https://gitea.com/gitea/gitea-mcp/releases) 下載二進位檔並放入 `PATH`,或使用 `docker.gitea.com/gitea-mcp-server` 映像檔,也可用 `make` 與 Go 1.26 以上從原始碼建置到 `$GOPATH/bin` 可從 [發布頁面](https://gitea.com/gitea/gitea-mcp/releases) 下載二進位檔並放入 `PATH`,或使用 `docker.gitea.com/gitea-mcp-server` 映像檔,也可用 `make` 與 Go 1.26 以上從原始碼建置到 `$GOPATH/bin`
> 已發布的 Docker 映像檔名稱為 `docker.gitea.com/gitea-mcp-server`,與 `gitea-mcp` 倉庫名稱不一致。這是 [issue #181](https://gitea.com/gitea/gitea-mcp/issues/181) 中記錄的已知不一致,為避免破壞現有安裝,映像檔名稱暫不變更。詳見 [docs/adr/docker-image-naming.md](docs/adr/docker-image-naming.md)。
```bash ```bash
git clone https://gitea.com/gitea/gitea-mcp.git git clone https://gitea.com/gitea/gitea-mcp.git
cd gitea-mcp cd gitea-mcp
-29
View File
@@ -1,29 +0,0 @@
# Docker image naming mismatch (issue #181)
## Current state
The project repository is `gitea.com/gitea/gitea-mcp`, but the Docker image is
published as `docker.gitea.com/gitea-mcp-server`. This mismatch was raised in
[issue #181](https://gitea.com/gitea/gitea-mcp/issues/181).
## Maintainers' direction
Per the issue discussion, the maintainers' preferred long-term name is
`gitea/mcp-server`, matching the naming scheme used by `gitea/act_runner`
(published as a `runner`-style short name) and by comparable projects such as
`github/github-mcp-server`. The name `mcp-server` was preferred over
`gitea-mcp-server` because the repository is primarily run as a local client
process, not a traditional long-running server.
## Decision
No rename has happened yet; the discussion was explicitly left open in the
issue. The current image name, `docker.gitea.com/gitea-mcp-server`, remains in
use so that existing user configurations and documentation keep working.
`org.opencontainers.image.title` and `org.opencontainers.image.description`
labels were added to the `Dockerfile` to make the image self-describing
regardless of the tag it is published under.
If the image is renamed in the future, update the `docker.gitea.com/gitea-mcp-server`
references across `README.md`, `README.zh-cn.md` and `README.zh-tw.md` together
with this document.
+6 -1
View File
@@ -29,7 +29,7 @@ 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_jobs", "list_run_jobs", "get_job", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact", "wait_for_pr_checks")),
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')")),
@@ -43,6 +43,9 @@ var (
tool.String("output_path", tool.Description("for 'download_job_log'/'download_artifact'")), tool.String("output_path", tool.Description("for 'download_job_log'/'download_artifact'")),
tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30), tool.Minimum(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30), tool.Minimum(1)),
tool.Number("pull_number", tool.Description("PR number (for 'wait_for_pr_checks')")),
tool.Number("timeout_seconds", tool.Description("max time to wait (for 'wait_for_pr_checks')"), tool.Default(120), tool.Minimum(1)),
tool.Number("poll_interval_seconds", tool.Description("time between polls (for 'wait_for_pr_checks')"), tool.Default(5), tool.Minimum(1)),
) )
ActionsRunWriteTool = tool.NewDefinition( ActionsRunWriteTool = tool.NewDefinition(
@@ -96,6 +99,8 @@ func runReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, e
return getRepoActionArtifactFn(ctx, args) return getRepoActionArtifactFn(ctx, args)
case "download_artifact": case "download_artifact":
return downloadRepoActionArtifactFn(ctx, args) return downloadRepoActionArtifactFn(ctx, args)
case "wait_for_pr_checks":
return waitForPRChecksFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
+174
View File
@@ -0,0 +1,174 @@
package actions
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
defaultWaitForPRChecksTimeoutSeconds = 120
defaultWaitForPRChecksPollIntervalSeconds = 5
)
// terminalRunStatuses lists Gitea Actions run statuses that never transition
// further, independent of whether a "conclusion" field is also present.
var terminalRunStatuses = map[string]bool{
"completed": true,
"success": true,
"failure": true,
"cancelled": true,
"skipped": true,
"failed": true,
"error": true,
}
func isRunTerminal(run map[string]any) bool {
if conclusion, ok := run["conclusion"].(string); ok && conclusion != "" {
return true
}
status, _ := run["status"].(string)
return terminalRunStatuses[strings.ToLower(status)]
}
func allRunsTerminal(runs []map[string]any) bool {
for _, run := range runs {
if !isRunTerminal(run) {
return false
}
}
return true
}
// waitForRunsUntilTerminal polls fetch until every run it returns is terminal
// or timeout elapses, sleeping pollInterval (capped to the remaining time)
// between polls so callers can inject a short pollInterval in tests.
func waitForRunsUntilTerminal(ctx context.Context, timeout, pollInterval time.Duration, fetch func(ctx context.Context) ([]map[string]any, error)) ([]map[string]any, bool, error) {
deadline := time.Now().Add(timeout)
for {
runs, err := fetch(ctx)
if err != nil {
return nil, false, err
}
if allRunsTerminal(runs) {
return runs, false, nil
}
remaining := time.Until(deadline)
if remaining <= 0 {
return runs, true, nil
}
wait := min(pollInterval, remaining)
select {
case <-ctx.Done():
return runs, false, ctx.Err()
case <-time.After(wait):
}
}
}
func fetchPullRequestHeadSHA(ctx context.Context, owner, repo string, pullNumber int64) (string, error) {
var result map[string]any
err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/pulls/%d", url.PathEscape(owner), url.PathEscape(repo), pullNumber),
},
nil, nil, &result,
)
if err != nil {
return "", err
}
head, ok := result["head"].(map[string]any)
if !ok {
return "", errors.New("pull request response missing head")
}
sha, ok := head["sha"].(string)
if !ok || sha == "" {
return "", errors.New("pull request response missing head.sha")
}
return sha, nil
}
func fetchActionRunsForSHA(ctx context.Context, owner, repo, sha string) ([]map[string]any, error) {
query := url.Values{}
query.Set("head_sha", sha)
var result map[string]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 nil, err
}
items, _ := result["workflow_runs"].([]any)
runs := make([]map[string]any, 0, len(items))
for _, item := range items {
if run, ok := item.(map[string]any); ok {
runs = append(runs, run)
}
}
return runs, nil
}
func waitForPRChecksFn(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"))
}
timeoutSeconds := params.GetOptionalInt(args, "timeout_seconds", defaultWaitForPRChecksTimeoutSeconds)
if timeoutSeconds <= 0 {
timeoutSeconds = defaultWaitForPRChecksTimeoutSeconds
}
pollIntervalSeconds := params.GetOptionalInt(args, "poll_interval_seconds", defaultWaitForPRChecksPollIntervalSeconds)
if pollIntervalSeconds <= 0 {
pollIntervalSeconds = defaultWaitForPRChecksPollIntervalSeconds
}
sha, err := fetchPullRequestHeadSHA(ctx, owner, repo, pullNumber)
if err != nil {
return to.ErrorResult(fmt.Errorf("get pull request err: %v", err))
}
runs, timedOut, err := waitForRunsUntilTerminal(ctx,
time.Duration(timeoutSeconds)*time.Second,
time.Duration(pollIntervalSeconds)*time.Second,
func(ctx context.Context) ([]map[string]any, error) {
return fetchActionRunsForSHA(ctx, owner, repo, sha)
},
)
if err != nil {
return to.ErrorResult(fmt.Errorf("wait for pr checks err: %v", err))
}
slimmedRuns := make([]map[string]any, 0, len(runs))
for _, run := range runs {
slimmedRuns = append(slimmedRuns, slimRun(run))
}
return to.TextResult(map[string]any{
"head_sha": sha,
"timed_out": timedOut,
"runs": slimmedRuns,
})
}
+169
View File
@@ -0,0 +1,169 @@
package actions
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestAllRunsTerminal(t *testing.T) {
tests := []struct {
name string
runs []map[string]any
want bool
}{
{"no runs", nil, true},
{"single completed run with conclusion", []map[string]any{{"status": "completed", "conclusion": "success"}}, true},
{"single running run", []map[string]any{{"status": "running", "conclusion": ""}}, false},
{"single waiting run", []map[string]any{{"status": "waiting"}}, false},
{"mixed terminal and running", []map[string]any{
{"status": "completed", "conclusion": "success"},
{"status": "running"},
}, false},
{"all terminal", []map[string]any{
{"status": "completed", "conclusion": "failure"},
{"status": "completed", "conclusion": "cancelled"},
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := allRunsTerminal(tt.runs); got != tt.want {
t.Errorf("allRunsTerminal() = %v, want %v", got, tt.want)
}
})
}
}
func TestWaitForRunsUntilTerminal_ReturnsOnceTerminal(t *testing.T) {
calls := 0
fetch := func(ctx context.Context) ([]map[string]any, error) {
calls++
if calls < 3 {
return []map[string]any{{"status": "running"}}, nil
}
return []map[string]any{{"status": "completed", "conclusion": "success"}}, nil
}
runs, timedOut, err := waitForRunsUntilTerminal(context.Background(), time.Second, time.Millisecond, fetch)
if err != nil {
t.Fatalf("waitForRunsUntilTerminal() error = %v", err)
}
if timedOut {
t.Fatalf("expected timedOut = false")
}
if calls != 3 {
t.Fatalf("expected 3 fetch calls, got %d", calls)
}
if len(runs) != 1 || runs[0]["conclusion"] != "success" {
t.Fatalf("unexpected runs: %v", runs)
}
}
func TestWaitForRunsUntilTerminal_TimesOut(t *testing.T) {
fetch := func(ctx context.Context) ([]map[string]any, error) {
return []map[string]any{{"status": "running"}}, nil
}
runs, timedOut, err := waitForRunsUntilTerminal(context.Background(), 20*time.Millisecond, time.Millisecond, fetch)
if err != nil {
t.Fatalf("waitForRunsUntilTerminal() error = %v", err)
}
if !timedOut {
t.Fatalf("expected timedOut = true")
}
if len(runs) != 1 {
t.Fatalf("expected last fetched runs to be returned, got %v", runs)
}
}
func TestWaitForRunsUntilTerminal_PropagatesFetchError(t *testing.T) {
wantErr := errors.New("boom")
fetch := func(ctx context.Context) ([]map[string]any, error) {
return nil, wantErr
}
_, _, err := waitForRunsUntilTerminal(context.Background(), time.Second, time.Millisecond, fetch)
if !errors.Is(err, wantErr) {
t.Fatalf("waitForRunsUntilTerminal() error = %v, want %v", err, wantErr)
}
}
func Test_waitForPRChecksFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
pullNumber = 42
headSHA = "abc123"
)
var runsRequests int32
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, pullNumber):
_, _ = fmt.Fprintf(w, `{"head":{"sha":%q}}`, headSHA)
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs", owner, repo):
atomic.AddInt32(&runsRequests, 1)
if r.URL.Query().Get("head_sha") != headSHA {
t.Errorf("expected head_sha query param %q, got %q", headSHA, r.URL.Query().Get("head_sha"))
}
_, _ = fmt.Fprint(w, `{"workflow_runs":[{"id":1,"status":"completed","conclusion":"success"},{"id":2,"status":"completed","conclusion":"failure"}]}`)
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
var mu sync.Mutex
mu.Lock()
origHost, origToken := flag.Host, flag.Token
flag.Host, flag.Token = server.URL, ""
mu.Unlock()
defer func() {
mu.Lock()
flag.Host, flag.Token = origHost, origToken
mu.Unlock()
}()
args := map[string]any{
"owner": owner,
"repo": repo,
"pull_number": float64(pullNumber),
}
result, err := waitForPRChecksFn(context.Background(), args)
if err != nil {
t.Fatalf("waitForPRChecksFn() error = %v", err)
}
if atomic.LoadInt32(&runsRequests) != 1 {
t.Fatalf("expected exactly 1 runs request, got %d", runsRequests)
}
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])
}
if !strings.Contains(textContent.Text, headSHA) {
t.Fatalf("expected result to mention head sha %q, got %s", headSHA, textContent.Text)
}
if !strings.Contains(textContent.Text, `"timed_out":false`) {
t.Fatalf("expected result to report timed_out=false, got %s", textContent.Text)
}
}
-32
View File
@@ -22,38 +22,6 @@ var readmeAccessLabels = map[string]map[string]string{
"../README.zh-tw.md": {"讀取": "read", "寫入": "write"}, "../README.zh-tw.md": {"讀取": "read", "寫入": "write"},
} }
// publishedDockerImage is the image name published for this project. See
// docs/adr/docker-image-naming.md for why it differs from the repository name.
const publishedDockerImage = "docker.gitea.com/gitea-mcp-server"
// dockerImageReference matches any docker.gitea.com/<name> image reference so
// stray typos or partial renames in the README files can be caught.
var dockerImageReference = regexp.MustCompile(`docker\.gitea\.com/[a-zA-Z0-9._-]+`)
// TestReadmeDockerImageReferencesAreConsistent ensures every README mentions
// the same, currently published Docker image name. A partial rename, where
// one example is updated but another is missed, would otherwise leave users
// copying a command that pulls a nonexistent image.
func TestReadmeDockerImageReferencesAreConsistent(t *testing.T) {
for _, path := range []string{"../README.md", "../README.zh-cn.md", "../README.zh-tw.md"} {
t.Run(filepath.Base(path), func(t *testing.T) {
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
references := dockerImageReference.FindAllString(string(content), -1)
if len(references) == 0 {
t.Fatalf("no docker.gitea.com image reference found in %s", path)
}
for _, ref := range references {
if ref != publishedDockerImage {
t.Errorf("found image reference %q in %s, want %q", ref, path, publishedDockerImage)
}
}
})
}
}
// toolInfo is what TestReadmeToolTables tracks per tool, both as registered // toolInfo is what TestReadmeToolTables tracks per tool, both as registered
// in code and as documented in a README, so the two can be compared. // in code and as documented in a README, so the two can be compared.
type toolInfo struct { type toolInfo struct {