Files
MCP/operation/actions/wait_test.go
T
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

170 lines
4.8 KiB
Go

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)
}
}