From a6d7bd9f238161ef6372ff90fd4f2b3b89f2e9f7 Mon Sep 17 00:00:00 2001 From: Jeremy Carter Date: Tue, 20 Oct 2026 01:14:13 -0400 Subject: [PATCH 1/2] Add one-time validated integration workflow for upstream #3406 --- .../integrate-watch-workflow-run.yml | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 .github/workflows/integrate-watch-workflow-run.yml diff --git a/.github/workflows/integrate-watch-workflow-run.yml b/.github/workflows/integrate-watch-workflow-run.yml new file mode 100644 index 0000000000..874a01111a --- /dev/null +++ b/.github/workflows/integrate-watch-workflow-run.yml @@ -0,0 +1,232 @@ +name: Prepare and validate workflow-run watch port +"on": + push: + branches: [integration/watch-workflow-run-1c7d37a] + paths: [.github/workflows/integrate-watch-workflow-run.yml] +permissions: + contents: write +concurrency: + group: integrate-watch-workflow-run-1c7d37a + cancel-in-progress: false +jobs: + port-and-test: + if: github.repository == 'gptenv/github-mcp-server-fork-official' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + - name: Verify pinned base and fetch pinned upstream source + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD^)" = e3809fe91c7546633241006bf0b91f23b2595e28 + git fetch --no-tags https://github.com/github/github-mcp-server.git 1c7d37aac4bc9697bdbe853a844e6c502d07c30c + test "$(git rev-parse FETCH_HEAD)" = 1c7d37aac4bc9697bdbe853a844e6c502d07c30c + - name: Build UI + uses: ./.github/actions/build-ui + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e + with: + go-version-file: go.mod + - name: Port pinned feature without reverting typed Actions + shell: bash + run: | + python3 - <<'PY' + import subprocess + from pathlib import Path + + SOURCE = "1c7d37aac4bc9697bdbe853a844e6c502d07c30c" + + def upstream(path): + return subprocess.check_output(["git", "show", f"{SOURCE}:{path}"], text=True) + + def between(text, start, end): + assert text.count(start) == 1, start + return start + text.split(start, 1)[1].split(end, 1)[0] + + def replace_once(text, old, new): + assert text.count(old) == 1, (old, text.count(old)) + return text.replace(old, new, 1) + + original = upstream("pkg/github/actions.go") + config = between(original, "const (\n\tdefaultWatchTimeoutSeconds", "// handleFailedJobLogs") + helpers = between(original, "// watchWorkflowRun polls", "func getWorkflowJob(") + assert helpers.count("(*mcp.CallToolResult, any, error)") == 2 + helpers = helpers.replace("(*mcp.CallToolResult, any, error)", "(*mcp.CallToolResult, *ActionsGetOutput, error)") + helpers = replace_once(helpers, "return utils.NewToolResultText(string(r)), nil, nil", "return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodWatchWorkflowRun, Watch: &result}, nil") + types = between(upstream("pkg/github/minimal_types.go"), "// MinimalFailedWorkflowJob", "// MinimalWorkflowJobStep") + header = """package github + + import ( + "context" + "encoding/json" + "fmt" + "time" + + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + ) + + const actionsMethodWatchWorkflowRun = "watch_workflow_run" + + """ + Path("pkg/github/actions_watch.go").write_text(header + config + types + helpers) + + path = Path("pkg/github/actions.go") + text = path.read_text() + text = replace_once(text, '\t"strings"\n', '\t"strings"\n\t"time"\n') + start = text.index("func ActionsGet(") + end = text.index("// ActionsRunTrigger returns", start) + block = text[start:end] + block = replace_once(block, "Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n", "Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\nUse the 'watch_workflow_run' method to wait for a workflow run to finish instead of polling 'get_workflow_run' repeatedly.\n") + block = replace_once(block, "'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.", "'get_workflow_run_usage', 'get_workflow_run_logs_url', and 'watch_workflow_run' methods.") + property_text = """ "timeout_seconds": { + Type: "number", + Description: fmt.Sprintf("How long to wait for the workflow run to complete before returning its current status. **ONLY** used when method is 'watch_workflow_run'. Default %d, maximum %d.", defaultWatchTimeoutSeconds, maxWatchTimeoutSeconds), + Minimum: new(1.0), + Maximum: new(float64(maxWatchTimeoutSeconds)), + }, + """ + block = replace_once(block, '\t\t\t\t},\n\t\t\t\tRequired:', property_text + '\t\t\t\t},\n\t\t\t\tRequired:') + block = replace_once(block, '_ *mcp.CallToolRequest, args ActionsGetInput', 'request *mcp.CallToolRequest, args ActionsGetInput') + watch_case = """ case actionsMethodWatchWorkflowRun: + timeoutSeconds := args.TimeoutSeconds + if timeoutSeconds == 0 { + timeoutSeconds = defaultWatchTimeoutSeconds + } + if timeoutSeconds < 1 || timeoutSeconds > maxWatchTimeoutSeconds { + return utils.NewToolResultError(fmt.Sprintf("timeout_seconds must be between 1 and %d", maxWatchTimeoutSeconds)), nil, nil + } + result, payload, err := watchWorkflowRun(ctx, client, request, owner, repo, resourceIDInt, time.Duration(timeoutSeconds)*time.Second) + return attachIFC(result), payload, err + """ + block = replace_once(block, '\t\t\tdefault:\n\t\t\t\treturn utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil', watch_case + '\t\t\tdefault:\n\t\t\t\treturn utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil') + path.write_text(text[:start] + block + text[end:]) + + path = Path("pkg/github/actions_types.go") + text = path.read_text() + text = replace_once(text, '\t\t\tactionsMethodDownloadWorkflowArtifact, actionsMethodGetWorkflowRunUsage, actionsMethodGetWorkflowRunLogsURL,\n', '\t\t\tactionsMethodDownloadWorkflowArtifact, actionsMethodGetWorkflowRunUsage, actionsMethodGetWorkflowRunLogsURL, actionsMethodWatchWorkflowRun,\n') + text = replace_once(text, 'type ActionsGetInput struct {\n', 'type ActionsGetInput struct {\n\tTimeoutSeconds int `json:"timeout_seconds,omitempty"`\n') + text = replace_once(text, 'type ActionsGetOutput struct {\n', 'type ActionsGetOutput struct {\n\tWatch *MinimalWorkflowRunWatchResult `json:"watch,omitempty"`\n') + text = replace_once(text, '\t\tactionsMethodDownloadWorkflowArtifact: "artifact", actionsMethodGetWorkflowRunLogsURL: "logs",\n', '\t\tactionsMethodDownloadWorkflowArtifact: "artifact", actionsMethodGetWorkflowRunLogsURL: "logs", actionsMethodWatchWorkflowRun: "watch",\n') + text = replace_once(text, '[]string{"page", "perPage"}', '[]string{"page", "perPage", "timeout_seconds"}') + text = replace_once(text, '\tcase "get":\n\t\t_, err := RequiredParam[string](args, "resource_id")\n\t\treturn err', """ case "get": + if _, err := RequiredParam[string](args, "resource_id"); err != nil { + return err + } + if args["method"] != actionsMethodWatchWorkflowRun { + delete(args, "timeout_seconds") + return nil + } + timeout, err := OptionalIntParamWithDefault(args, "timeout_seconds", defaultWatchTimeoutSeconds) + if err != nil { + return err + } + args["timeout_seconds"] = timeout + return nil""") + path.write_text(text) + + tests = between(upstream("pkg/github/actions_test.go"), "// workflowRunSequence serves", "func Test_ActionsRunTrigger(") + test_header = """package github + + import ( + "context" + "encoding/json" + "maps" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + ) + + """ + wire_tests = """ + func TestTypedActionsWatchWorkflowRunWire(t *testing.T) { + for _, protocol := range typedActionsProtocols { + for _, state := range [][2]string{{"completed", "success"}, {"completed", "failure"}, {"in_progress", ""}} { + t.Run(protocol+"/"+state[0]+"/"+state[1], func(t *testing.T) { + runHandler, _ := workflowRunSequence(state) + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: runHandler, + GetReposActionsRunsJobsByOwnerByRepoByRunID: mockResponse(t, http.StatusOK, &github.Jobs{ + TotalCount: new(1), + Jobs: []*github.WorkflowJob{{ID: new(int64(2)), Name: new("test"), Status: new("completed"), Conclusion: new("failure")}}, + }), + }))} + session, schemas := actionsTypedSession(t, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "actions_get", + Arguments: map[string]any{"method": "watch_workflow_run", "owner": "owner", "repo": "repo", "resource_id": "30433642", "timeout_seconds": "1"}, + }) + require.NoError(t, err) + expected := MinimalWorkflowRunWatchResult{WorkflowRun: watchedRun(state[0], state[1]), Completed: state[0] == "completed"} + if !expected.Completed { + expected.WaitedSeconds = 1 + expected.NextStep = "The workflow run is still in_progress. Call watch_workflow_run again to keep waiting." + } else if state[1] == "failure" { + expected.FailedJobs = []MinimalFailedWorkflowJob{{ID: 2, Name: "test", Conclusion: "failure"}} + expected.NextStep = "Use get_job_logs with run_id 30433642 and failed_only true to read the logs of the failed jobs." + } + projected := mustMarshalJSON(t, map[string]any{"method": "watch_workflow_run", "watch": expected}) + assertActionsWireResult(t, result, schemas["actions_get"], mustMarshalJSON(t, expected), false, projected) + }) + } + } + } + """ + Path("pkg/github/actions_watch_test.go").write_text(test_header + tests + wire_tests) + + path = Path("pkg/github/typed_actions_outputs_test.go") + text = path.read_text() + text = replace_once(text, '\"get_workflow_run_usage\", \"get_workflow_run_logs_url\"}, get.Properties[\"method\"].Enum)', '\"get_workflow_run_usage\", \"get_workflow_run_logs_url\", \"watch_workflow_run\"}, get.Properties[\"method\"].Enum)') + path.write_text(text) + PY + gofmt -w pkg/github/actions.go pkg/github/actions_types.go pkg/github/actions_watch.go pkg/github/actions_watch_test.go pkg/github/typed_actions_outputs_test.go + - name: Generate expected schema snapshot and documentation + shell: bash + run: | + set -euo pipefail + UPDATE_TOOLSNAPS=true go test ./pkg/github -run '^Test_ActionsGet$' -count=1 + bash script/generate-docs + - name: Validate dependencies, race tests, build, and whitespace + shell: bash + run: | + set -euo pipefail + { + go mod tidy -diff && + bash script/test && + go build -o "$RUNNER_TEMP/github-mcp-server" ./cmd/github-mcp-server && + git diff --check + } 2>&1 | tee "$RUNNER_TEMP/watch-validation.log" + - name: Commit validated source only to the integration branch + shell: bash + run: | + set -euo pipefail + test "$(git ls-remote origin refs/heads/main | cut -f1)" = e3809fe91c7546633241006bf0b91f23b2595e28 + test "$(git ls-remote origin refs/heads/integration/watch-workflow-run-1c7d37a | cut -f1)" = "$GITHUB_SHA" + git add -- README.md pkg/github/actions.go pkg/github/actions_types.go pkg/github/actions_watch.go pkg/github/actions_watch_test.go pkg/github/typed_actions_outputs_test.go pkg/github/__toolsnaps__/actions_get.snap + git diff --cached --check + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'Port upstream #3406 onto typed Actions tools' -m 'Upstream-Commit: 1c7d37aac4bc9697bdbe853a844e6c502d07c30c' -m 'Co-authored-by: Mehdi ' + git push origin HEAD:refs/heads/integration/watch-workflow-run-1c7d37a + git show --stat --oneline HEAD >> "$GITHUB_STEP_SUMMARY" + - name: Publish validation tail + if: always() + shell: bash + run: | + if test -f "$RUNNER_TEMP/watch-validation.log"; then + printf '\n```text\n' >> "$GITHUB_STEP_SUMMARY" + tail -n 100 "$RUNNER_TEMP/watch-validation.log" >> "$GITHUB_STEP_SUMMARY" + printf '\n```\n' >> "$GITHUB_STEP_SUMMARY" + fi From 74f637be638770a4e9d3312a5ee9aedaacd91946 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:18:46 +0000 Subject: [PATCH 2/2] Port upstream #3406 onto typed Actions tools Upstream-Commit: 1c7d37aac4bc9697bdbe853a844e6c502d07c30c Co-authored-by: Mehdi --- README.md | 3 +- pkg/github/__toolsnaps__/actions_get.snap | 480 +++++++++++++++++++++- pkg/github/actions.go | 22 +- pkg/github/actions_types.go | 31 +- pkg/github/actions_watch.go | 149 +++++++ pkg/github/actions_watch_test.go | 315 ++++++++++++++ pkg/github/typed_actions_outputs_test.go | 2 +- 7 files changed, 985 insertions(+), 17 deletions(-) create mode 100644 pkg/github/actions_watch.go create mode 100644 pkg/github/actions_watch_test.go diff --git a/README.md b/README.md index 432541f434..ac23ff4599 100644 --- a/README.md +++ b/README.md @@ -652,10 +652,11 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - `repo`: Repository name (string, required) - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. - - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. + - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', 'get_workflow_run_logs_url', and 'watch_workflow_run' methods. - Provide an artifact ID for 'download_workflow_run_artifact' method. - Provide a job ID for 'get_workflow_job' method. (string, required) + - `timeout_seconds`: How long to wait for the workflow run to complete before returning its current status. **ONLY** used when method is 'watch_workflow_run'. Default 45, maximum 600. (number, optional) - **actions_list** - List GitHub Actions workflows in a repository - **OAuth Challenge Scopes**: `repo` diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index 8e87fadf6b..f1c4a4d65e 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -4,7 +4,7 @@ "readOnlyHint": true, "title": "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)" }, - "description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n", + "description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\nUse the 'watch_workflow_run' method to wait for a workflow run to finish instead of polling 'get_workflow_run' repeatedly.\n", "inputSchema": { "properties": { "method": { @@ -15,7 +15,8 @@ "get_workflow_job", "download_workflow_run_artifact", "get_workflow_run_usage", - "get_workflow_run_logs_url" + "get_workflow_run_logs_url", + "watch_workflow_run" ], "type": "string" }, @@ -28,8 +29,14 @@ "type": "string" }, "resource_id": { - "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n", + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', 'get_workflow_run_logs_url', and 'watch_workflow_run' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n", "type": "string" + }, + "timeout_seconds": { + "description": "How long to wait for the workflow run to complete before returning its current status. **ONLY** used when method is 'watch_workflow_run'. Default 45, maximum 600.", + "maximum": 600, + "minimum": 1, + "type": "number" } }, "required": [ @@ -66,6 +73,11 @@ "description": "Any value." } }, + "watch": { + "not": { + "description": "Any value." + } + }, "workflow_job": { "not": { "description": "Any value." @@ -100,6 +112,11 @@ "description": "Any value." } }, + "watch": { + "not": { + "description": "Any value." + } + }, "workflow": { "not": { "description": "Any value." @@ -137,6 +154,11 @@ "description": "Any value." } }, + "watch": { + "not": { + "description": "Any value." + } + }, "workflow": { "not": { "description": "Any value." @@ -166,6 +188,11 @@ "description": "Any value." } }, + "watch": { + "not": { + "description": "Any value." + } + }, "workflow": { "not": { "description": "Any value." @@ -203,6 +230,11 @@ "get_workflow_run_usage" ] }, + "watch": { + "not": { + "description": "Any value." + } + }, "workflow": { "not": { "description": "Any value." @@ -237,6 +269,11 @@ "description": "Any value." } }, + "watch": { + "not": { + "description": "Any value." + } + }, "workflow": { "not": { "description": "Any value." @@ -256,6 +293,48 @@ "required": [ "logs" ] + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "watch_workflow_run" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "watch" + ] } ], "properties": { @@ -325,7 +404,8 @@ "get_workflow_job", "download_workflow_run_artifact", "get_workflow_run_usage", - "get_workflow_run_logs_url" + "get_workflow_run_logs_url", + "watch_workflow_run" ], "type": "string" }, @@ -402,6 +482,398 @@ "object" ] }, + "watch": { + "additionalProperties": false, + "properties": { + "completed": { + "type": "boolean" + }, + "failed_jobs": { + "items": { + "additionalProperties": false, + "properties": { + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "next_step": { + "type": "string" + }, + "waited_seconds": { + "type": "integer" + }, + "workflow_run": { + "additionalProperties": false, + "properties": { + "actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "display_title": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_commit": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "pull_requests": { + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "referenced_workflows": { + "items": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_attempt": { + "type": "integer" + }, + "run_number": { + "type": "integer" + }, + "run_started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "triggering_actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_id": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "workflow_id", + "run_number", + "run_attempt", + "status" + ], + "type": "object" + } + }, + "required": [ + "workflow_run", + "completed", + "waited_seconds" + ], + "type": [ + "null", + "object" + ] + }, "workflow": { "additionalProperties": false, "properties": { diff --git a/pkg/github/actions.go b/pkg/github/actions.go index fc31477f63..fc08a9ca32 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -9,6 +9,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/github/github-mcp-server/v2/internal/profiler" buffer "github.com/github/github-mcp-server/v2/pkg/buffer" @@ -383,6 +384,7 @@ func ActionsGet(t translations.TranslationHelperFunc) inventory.ServerTool { OutputSchema: actionsGetOutputSchema(), Description: t("TOOL_ACTIONS_GET_DESCRIPTION", `Get details about specific GitHub Actions resources. Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs. +Use the 'watch_workflow_run' method to wait for a workflow run to finish instead of polling 'get_workflow_run' repeatedly. `), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ACTIONS_GET_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), @@ -408,17 +410,23 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an Type: "string", Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. -- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. +- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', 'get_workflow_run_logs_url', and 'watch_workflow_run' methods. - Provide an artifact ID for 'download_workflow_run_artifact' method. - Provide a job ID for 'get_workflow_job' method. `, }, + "timeout_seconds": { + Type: "number", + Description: fmt.Sprintf("How long to wait for the workflow run to complete before returning its current status. **ONLY** used when method is 'watch_workflow_run'. Default %d, maximum %d.", defaultWatchTimeoutSeconds, maxWatchTimeoutSeconds), + Minimum: new(1.0), + Maximum: new(float64(maxWatchTimeoutSeconds)), + }, }, Required: []string{"method", "owner", "repo", "resource_id"}, }, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsGetInput) (*mcp.CallToolResult, *ActionsGetOutput, error) { + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args ActionsGetInput) (*mcp.CallToolResult, *ActionsGetOutput, error) { owner, repo, method, resourceID := args.Owner, args.Repo, args.Method, args.ResourceID client, err := deps.GetClient(ctx) @@ -466,6 +474,16 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an case actionsMethodGetWorkflowRunLogsURL: result, payload, err := getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) return attachIFC(result), payload, err + case actionsMethodWatchWorkflowRun: + timeoutSeconds := args.TimeoutSeconds + if timeoutSeconds == 0 { + timeoutSeconds = defaultWatchTimeoutSeconds + } + if timeoutSeconds < 1 || timeoutSeconds > maxWatchTimeoutSeconds { + return utils.NewToolResultError(fmt.Sprintf("timeout_seconds must be between 1 and %d", maxWatchTimeoutSeconds)), nil, nil + } + result, payload, err := watchWorkflowRun(ctx, client, request, owner, repo, resourceIDInt, time.Duration(timeoutSeconds)*time.Second) + return attachIFC(result), payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } diff --git a/pkg/github/actions_types.go b/pkg/github/actions_types.go index ffdd689b00..68c1683abf 100644 --- a/pkg/github/actions_types.go +++ b/pkg/github/actions_types.go @@ -28,7 +28,7 @@ var actionsEnumSchemas = sync.OnceValue(func() map[reflect.Type]*jsonschema.Sche }}, reflect.TypeFor[ActionsGetMethod](): {Type: "string", Enum: []any{ actionsMethodGetWorkflow, actionsMethodGetWorkflowRun, actionsMethodGetWorkflowJob, - actionsMethodDownloadWorkflowArtifact, actionsMethodGetWorkflowRunUsage, actionsMethodGetWorkflowRunLogsURL, + actionsMethodDownloadWorkflowArtifact, actionsMethodGetWorkflowRunUsage, actionsMethodGetWorkflowRunLogsURL, actionsMethodWatchWorkflowRun, }}, reflect.TypeFor[ActionsRunTriggerMethod](): {Type: "string", Enum: []any{ actionsMethodRunWorkflow, actionsMethodRerunWorkflowRun, actionsMethodRerunFailedJobs, @@ -142,7 +142,7 @@ func actionsValidationSchema(inputSchema any) *jsonschema.Schema { }, } } - for _, field := range []string{"page", "perPage"} { + for _, field := range []string{"page", "perPage", "timeout_seconds"} { if property := schema.Properties[field]; property != nil { property.Minimum, property.Maximum = nil, nil } @@ -151,10 +151,11 @@ func actionsValidationSchema(inputSchema any) *jsonschema.Schema { } type ActionsGetInput struct { - Method string `json:"method"` - Owner string `json:"owner"` - Repo string `json:"repo"` - ResourceID string `json:"resource_id"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + ResourceID string `json:"resource_id"` } type ActionsRunTriggerInput struct { @@ -239,6 +240,7 @@ var actionsListOutputSchema = sync.OnceValue(func() *jsonschema.Schema { }) type ActionsGetOutput struct { + Watch *MinimalWorkflowRunWatchResult `json:"watch,omitempty"` Method ActionsGetMethod `json:"method,omitempty"` Workflow *ActionsWorkflow `json:"workflow,omitempty"` Run *MinimalWorkflowRun `json:"workflow_run,omitempty"` @@ -366,7 +368,7 @@ var actionsGetOutputSchema = sync.OnceValue(func() *jsonschema.Schema { constrainMethodOutput(schema, map[string]string{ actionsMethodGetWorkflow: "workflow", actionsMethodGetWorkflowRun: "workflow_run", actionsMethodGetWorkflowJob: "workflow_job", actionsMethodGetWorkflowRunUsage: "usage", - actionsMethodDownloadWorkflowArtifact: "artifact", actionsMethodGetWorkflowRunLogsURL: "logs", + actionsMethodDownloadWorkflowArtifact: "artifact", actionsMethodGetWorkflowRunLogsURL: "logs", actionsMethodWatchWorkflowRun: "watch", }, "workflow", "workflow_job", "usage") return schema }) @@ -589,8 +591,19 @@ func normalizeActionsFields(args map[string]any, kind string) error { } switch kind { case "get": - _, err := RequiredParam[string](args, "resource_id") - return err + if _, err := RequiredParam[string](args, "resource_id"); err != nil { + return err + } + if args["method"] != actionsMethodWatchWorkflowRun { + delete(args, "timeout_seconds") + return nil + } + timeout, err := OptionalIntParamWithDefault(args, "timeout_seconds", defaultWatchTimeoutSeconds) + if err != nil { + return err + } + args["timeout_seconds"] = timeout + return nil case "list": if _, err := OptionalParam[string](args, "resource_id"); err != nil { return err diff --git a/pkg/github/actions_watch.go b/pkg/github/actions_watch.go new file mode 100644 index 0000000000..0900e515f6 --- /dev/null +++ b/pkg/github/actions_watch.go @@ -0,0 +1,149 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "time" + + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const actionsMethodWatchWorkflowRun = "watch_workflow_run" + +const ( + defaultWatchTimeoutSeconds = 45 + maxWatchTimeoutSeconds = 600 +) + +// WatchConfig configures how often watch_workflow_run polls a workflow run. +type WatchConfig struct { + PollInterval time.Duration +} + +type watchConfigKey struct{} + +// ContextWithWatchConfig returns a context with workflow run watch configuration. +// Use this in tests to poll without waiting. +func ContextWithWatchConfig(ctx context.Context, config WatchConfig) context.Context { + return context.WithValue(ctx, watchConfigKey{}, config) +} + +func getWatchConfig(ctx context.Context) WatchConfig { + if config, ok := ctx.Value(watchConfigKey{}).(WatchConfig); ok { + return config + } + return WatchConfig{PollInterval: 10 * time.Second} +} + +// MinimalFailedWorkflowJob identifies a job that did not succeed in a watched workflow run. +type MinimalFailedWorkflowJob struct { + ID int64 `json:"id"` + Name string `json:"name"` + Conclusion string `json:"conclusion,omitempty"` + HTMLURL string `json:"html_url,omitempty"` +} + +// MinimalWorkflowRunWatchResult is the output type for the watch_workflow_run method. +type MinimalWorkflowRunWatchResult struct { + WorkflowRun MinimalWorkflowRun `json:"workflow_run"` + Completed bool `json:"completed"` + WaitedSeconds int `json:"waited_seconds"` + FailedJobs []MinimalFailedWorkflowJob `json:"failed_jobs,omitempty"` + NextStep string `json:"next_step,omitempty"` +} + +// watchWorkflowRun polls a workflow run until it completes or the timeout elapses. +// Reaching the timeout is not an error: the result reports the current status so the caller can watch again. +func watchWorkflowRun(ctx context.Context, client *github.Client, request *mcp.CallToolRequest, owner, repo string, runID int64, timeout time.Duration) (*mcp.CallToolResult, *ActionsGetOutput, error) { + pollInterval := getWatchConfig(ctx).PollInterval + start := time.Now() + deadline := start.Add(timeout) + + var progressToken any + if request != nil && request.Params != nil { + progressToken = request.Params.GetProgressToken() + } + + for poll := 1; ; poll++ { + workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil + } + _ = resp.Body.Close() + + completed := workflowRun.GetStatus() == "completed" + remaining := time.Until(deadline) + if completed || remaining <= 0 { + return watchWorkflowRunResult(ctx, client, owner, repo, workflowRun, completed, time.Since(start)) + } + + if progressToken != nil && request.Session != nil { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: float64(poll), + Message: fmt.Sprintf("Workflow run %d is %s (waited %ds)", runID, workflowRun.GetStatus(), int(time.Since(start).Seconds())), + }) + } + + timer := time.NewTimer(min(pollInterval, remaining)) + select { + case <-ctx.Done(): + timer.Stop() + return nil, nil, fmt.Errorf("stopped watching workflow run %d: %w", runID, ctx.Err()) + case <-timer.C: + } + } +} + +func watchWorkflowRunResult(ctx context.Context, client *github.Client, owner, repo string, workflowRun *github.WorkflowRun, completed bool, waited time.Duration) (*mcp.CallToolResult, *ActionsGetOutput, error) { + result := MinimalWorkflowRunWatchResult{ + WorkflowRun: convertToMinimalWorkflowRun(workflowRun), + Completed: completed, + WaitedSeconds: int(waited.Seconds()), + } + + switch { + case !completed: + result.NextStep = fmt.Sprintf("The workflow run is still %s. Call watch_workflow_run again to keep waiting.", workflowRun.GetStatus()) + case !isSuccessfulConclusion(workflowRun.GetConclusion()): + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, workflowRun.GetID(), &github.ListWorkflowJobsOptions{ + Filter: "latest", + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + _ = resp.Body.Close() + + for _, job := range jobs.Jobs { + if !isSuccessfulConclusion(job.GetConclusion()) { + result.FailedJobs = append(result.FailedJobs, MinimalFailedWorkflowJob{ + ID: job.GetID(), + Name: job.GetName(), + Conclusion: job.GetConclusion(), + HTMLURL: job.GetHTMLURL(), + }) + } + } + result.NextStep = fmt.Sprintf("Use get_job_logs with run_id %d and failed_only true to read the logs of the failed jobs.", workflowRun.GetID()) + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow run watch result: %w", err) + } + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodWatchWorkflowRun, Watch: &result}, nil +} + +// isSuccessfulConclusion reports whether a workflow run or job conclusion needs no attention. +func isSuccessfulConclusion(conclusion string) bool { + switch conclusion { + case "success", "neutral", "skipped": + return true + default: + return false + } +} diff --git a/pkg/github/actions_watch_test.go b/pkg/github/actions_watch_test.go new file mode 100644 index 0000000000..802df7b58e --- /dev/null +++ b/pkg/github/actions_watch_test.go @@ -0,0 +1,315 @@ +package github + +import ( + "context" + "encoding/json" + "maps" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// workflowRunSequence serves the given status/conclusion pairs in order, repeating the last one. +// It returns the handler and a function that reports how many times the run was fetched. +func workflowRunSequence(states ...[2]string) (http.HandlerFunc, func() int) { + var calls atomic.Int32 + handler := func(w http.ResponseWriter, _ *http.Request) { + i := int(calls.Add(1)) - 1 + state := states[min(i, len(states)-1)] + run := actionsTestWorkflowRun() + run.Status = new(state[0]) + run.Conclusion = new(state[1]) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + } + return handler, func() int { return int(calls.Load()) } +} + +func watchedRun(status, conclusion string) MinimalWorkflowRun { + run := actionsTestWorkflowRun() + run.Status = new(status) + run.Conclusion = new(conclusion) + return convertToMinimalWorkflowRun(run) +} + +func Test_ActionsGet_WatchWorkflowRun(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + + failedRunJobs := &github.Jobs{ + TotalCount: new(3), + Jobs: []*github.WorkflowJob{ + {ID: new(int64(1)), Name: new("lint"), Status: new("completed"), Conclusion: new("success")}, + {ID: new(int64(2)), Name: new("test"), Status: new("completed"), Conclusion: new("failure"), HTMLURL: new("https://github.com/octo-org/octo-repo/actions/runs/30433642/job/2")}, + {ID: new(int64(3)), Name: new("deploy"), Status: new("completed"), Conclusion: new("cancelled")}, + }, + } + + tests := []struct { + name string + states [][2]string + jobs *github.Jobs + timeout int + expectedCalls int + expected MinimalWorkflowRunWatchResult + }{ + { + name: "returns at once when the run already completed", + states: [][2]string{{"completed", "success"}}, + expectedCalls: 1, + expected: MinimalWorkflowRunWatchResult{ + WorkflowRun: watchedRun("completed", "success"), + Completed: true, + }, + }, + { + name: "waits until the run completes", + states: [][2]string{{"queued", ""}, {"in_progress", ""}, {"completed", "success"}}, + expectedCalls: 3, + expected: MinimalWorkflowRunWatchResult{ + WorkflowRun: watchedRun("completed", "success"), + Completed: true, + }, + }, + { + name: "lists the jobs that did not succeed when the run fails", + states: [][2]string{{"in_progress", ""}, {"completed", "failure"}}, + jobs: failedRunJobs, + expectedCalls: 2, + expected: MinimalWorkflowRunWatchResult{ + WorkflowRun: watchedRun("completed", "failure"), + Completed: true, + FailedJobs: []MinimalFailedWorkflowJob{ + {ID: 2, Name: "test", Conclusion: "failure", HTMLURL: "https://github.com/octo-org/octo-repo/actions/runs/30433642/job/2"}, + {ID: 3, Name: "deploy", Conclusion: "cancelled"}, + }, + NextStep: "Use get_job_logs with run_id 30433642 and failed_only true to read the logs of the failed jobs.", + }, + }, + { + name: "returns the current status when the timeout elapses", + states: [][2]string{{"in_progress", ""}}, + timeout: 1, + expected: MinimalWorkflowRunWatchResult{ + WorkflowRun: watchedRun("in_progress", ""), + Completed: false, + WaitedSeconds: 1, + NextStep: "The workflow run is still in_progress. Call watch_workflow_run again to keep waiting.", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + runHandler, calls := workflowRunSequence(tc.states...) + handlers := map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: runHandler, + } + if tc.jobs != nil { + handlers[GetReposActionsRunsJobsByOwnerByRepoByRunID] = mockResponse(t, http.StatusOK, tc.jobs) + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers))} + handler := toolDef.Handler(deps) + + args := map[string]any{ + "method": "watch_workflow_run", + "owner": "owner", + "repo": "repo", + "resource_id": "30433642", + } + if tc.timeout > 0 { + args["timeout_seconds"] = tc.timeout + } + request := createMCPRequest(args) + ctx := ContextWithWatchConfig(ContextWithDeps(context.Background(), deps), WatchConfig{PollInterval: 10 * time.Millisecond}) + result, err := handler(ctx, &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response MinimalWorkflowRunWatchResult + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, tc.expected, response) + if tc.expectedCalls > 0 { + assert.Equal(t, tc.expectedCalls, calls()) + } + }) + } +} + +func Test_ActionsGet_WatchWorkflowRun_Errors(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + + tests := []struct { + name string + handler http.HandlerFunc + args map[string]any + expectedErrMsg string + }{ + { + name: "timeout above the maximum", + args: map[string]any{"timeout_seconds": 601}, + expectedErrMsg: "timeout_seconds must be between 1 and 600", + }, + { + name: "negative timeout", + args: map[string]any{"timeout_seconds": -5}, + expectedErrMsg: "timeout_seconds must be between 1 and 600", + }, + { + name: "run ID is not a number", + args: map[string]any{"resource_id": "ci.yml"}, + expectedErrMsg: "invalid resource_id, must be an integer for method watch_workflow_run", + }, + { + name: "run not found", + handler: mockResponse(t, http.StatusNotFound, map[string]string{"message": "Not Found"}), + expectedErrMsg: "failed to get workflow run", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + handlers := map[string]http.HandlerFunc{} + if tc.handler != nil { + handlers[GetReposActionsRunsByOwnerByRepoByRunID] = tc.handler + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers))} + handler := toolDef.Handler(deps) + + args := map[string]any{ + "method": "watch_workflow_run", + "owner": "owner", + "repo": "repo", + "resource_id": "30433642", + } + maps.Copy(args, tc.args) + request := createMCPRequest(args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + }) + } +} + +func Test_ActionsGet_WatchWorkflowRun_StopsWhenCancelled(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + runHandler, calls := workflowRunSequence([2]string{"in_progress", ""}) + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: func(w http.ResponseWriter, r *http.Request) { + runHandler(w, r) + cancel() + }, + }))} + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "watch_workflow_run", + "owner": "owner", + "repo": "repo", + "resource_id": "30433642", + "timeout_seconds": 600, + }) + ctx = ContextWithWatchConfig(ContextWithDeps(ctx, deps), WatchConfig{PollInterval: time.Hour}) + _, err := handler(ctx, &request) + require.ErrorIs(t, err, context.Canceled) + assert.Equal(t, 1, calls()) +} + +func Test_ActionsGet_WatchWorkflowRun_SendsProgress(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + runHandler, _ := workflowRunSequence([2]string{"queued", ""}, [2]string{"in_progress", ""}, [2]string{"completed", "success"}) + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: runHandler, + }))} + handler := toolDef.Handler(deps) + + var mu sync.Mutex + var messages []string + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, &mcp.ClientOptions{ + ProgressNotificationHandler: func(_ context.Context, req *mcp.ProgressNotificationClientRequest) { + mu.Lock() + defer mu.Unlock() + messages = append(messages, req.Params.Message) + }, + }) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server"}, nil) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + argsJSON, err := json.Marshal(map[string]any{ + "method": "watch_workflow_run", + "owner": "owner", + "repo": "repo", + "resource_id": "30433642", + }) + require.NoError(t, err) + request := mcp.CallToolRequest{ + Session: serverSession, + Params: &mcp.CallToolParamsRaw{ + Meta: mcp.Meta{"progressToken": "watch-1"}, + Arguments: argsJSON, + }, + } + ctx := ContextWithWatchConfig(ContextWithDeps(context.Background(), deps), WatchConfig{PollInterval: 10 * time.Millisecond}) + result, err := handler(ctx, &request) + require.NoError(t, err) + require.False(t, result.IsError) + + expected := []string{ + "Workflow run 30433642 is queued (waited 0s)", + "Workflow run 30433642 is in_progress (waited 0s)", + } + assert.EventuallyWithT(t, func(c *assert.CollectT) { + mu.Lock() + defer mu.Unlock() + assert.Equal(c, expected, messages) + }, time.Second, 10*time.Millisecond) +} + +func TestTypedActionsWatchWorkflowRunWire(t *testing.T) { + for _, protocol := range typedActionsProtocols { + for _, state := range [][2]string{{"completed", "success"}, {"completed", "failure"}, {"in_progress", ""}} { + t.Run(protocol+"/"+state[0]+"/"+state[1], func(t *testing.T) { + runHandler, _ := workflowRunSequence(state) + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: runHandler, + GetReposActionsRunsJobsByOwnerByRepoByRunID: mockResponse(t, http.StatusOK, &github.Jobs{ + TotalCount: new(1), + Jobs: []*github.WorkflowJob{{ID: new(int64(2)), Name: new("test"), Status: new("completed"), Conclusion: new("failure")}}, + }), + }))} + session, schemas := actionsTypedSession(t, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "actions_get", + Arguments: map[string]any{"method": "watch_workflow_run", "owner": "owner", "repo": "repo", "resource_id": "30433642", "timeout_seconds": "1"}, + }) + require.NoError(t, err) + expected := MinimalWorkflowRunWatchResult{WorkflowRun: watchedRun(state[0], state[1]), Completed: state[0] == "completed"} + if !expected.Completed { + expected.WaitedSeconds = 1 + expected.NextStep = "The workflow run is still in_progress. Call watch_workflow_run again to keep waiting." + } else if state[1] == "failure" { + expected.FailedJobs = []MinimalFailedWorkflowJob{{ID: 2, Name: "test", Conclusion: "failure"}} + expected.NextStep = "Use get_job_logs with run_id 30433642 and failed_only true to read the logs of the failed jobs." + } + projected := mustMarshalJSON(t, map[string]any{"method": "watch_workflow_run", "watch": expected}) + assertActionsWireResult(t, result, schemas["actions_get"], mustMarshalJSON(t, expected), false, projected) + }) + } + } +} diff --git a/pkg/github/typed_actions_outputs_test.go b/pkg/github/typed_actions_outputs_test.go index 7e7a8dbab4..ac406019d5 100644 --- a/pkg/github/typed_actions_outputs_test.go +++ b/pkg/github/typed_actions_outputs_test.go @@ -507,7 +507,7 @@ func TestActionsAdvertisedInputMetadata(t *testing.T) { get := ActionsGet(tr).Tool.InputSchema.(*jsonschema.Schema) trigger := ActionsRunTrigger(tr).Tool.InputSchema.(*jsonschema.Schema) assert.Equal(t, []any{"list_workflows", "list_workflow_runs", "list_workflow_jobs", "list_workflow_run_artifacts"}, list.Properties["method"].Enum) - assert.Equal(t, []any{"get_workflow", "get_workflow_run", "get_workflow_job", "download_workflow_run_artifact", "get_workflow_run_usage", "get_workflow_run_logs_url"}, get.Properties["method"].Enum) + assert.Equal(t, []any{"get_workflow", "get_workflow_run", "get_workflow_job", "download_workflow_run_artifact", "get_workflow_run_usage", "get_workflow_run_logs_url", "watch_workflow_run"}, get.Properties["method"].Enum) assert.Equal(t, []any{"run_workflow", "rerun_workflow_run", "rerun_failed_jobs", "cancel_workflow_run", "delete_workflow_run_logs"}, trigger.Properties["method"].Enum) runs := list.Properties["workflow_runs_filter"] assert.Equal(t, "object", runs.Type)