diff --git a/README.md b/README.md index dc063f22c..cd0d5d2bb 100644 --- a/README.md +++ b/README.md @@ -591,15 +591,19 @@ The following sets of tools are available: - **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) - **Required OAuth Scopes**: `repo` + - `artifact_name`: Exact workflow artifact name to resolve within 'run_id' for 'download_workflow_run_artifact'. (string, optional) + - `max_bytes`: Maximum number of bytes of text content to return per file when extracting 'download_workflow_run_artifact'. Defaults to 65536. (number, optional) - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) + - `path`: Optional exact file path inside the artifact ZIP to return for 'download_workflow_run_artifact'. (string, optional) - `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 an artifact ID for 'download_workflow_run_artifact' method. + - Provide an artifact ID for 'download_workflow_run_artifact' to get a temporary download URL, or omit it and use 'run_id' plus 'artifact_name' to return artifact contents. - Provide a job ID for 'get_workflow_job' method. - (string, required) + (string, optional) + - `run_id`: Workflow run ID used with 'artifact_name' to resolve and extract an artifact for 'download_workflow_run_artifact'. (number, optional) - **actions_list** - List GitHub Actions workflows in a repository - **Required OAuth Scopes**: `repo` diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index ba128875e..53911eddd 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -6,6 +6,16 @@ "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", "inputSchema": { "properties": { + "artifact_name": { + "description": "Exact workflow artifact name to resolve within 'run_id' for 'download_workflow_run_artifact'.", + "type": "string" + }, + "max_bytes": { + "default": 65536, + "description": "Maximum number of bytes of text content to return per file when extracting 'download_workflow_run_artifact'. Defaults to 65536.", + "minimum": 1, + "type": "number" + }, "method": { "description": "The method to execute", "enum": [ @@ -22,20 +32,27 @@ "description": "Repository owner", "type": "string" }, + "path": { + "description": "Optional exact file path inside the artifact ZIP to return for 'download_workflow_run_artifact'.", + "type": "string" + }, "repo": { "description": "Repository name", "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', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' to get a temporary download URL, or omit it and use 'run_id' plus 'artifact_name' to return artifact contents.\n- Provide a job ID for 'get_workflow_job' method.\n", "type": "string" + }, + "run_id": { + "description": "Workflow run ID used with 'artifact_name' to resolve and extract an artifact for 'download_workflow_run_artifact'.", + "type": "number" } }, "required": [ "method", "owner", - "repo", - "resource_id" + "repo" ], "type": "object" }, diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 9dac87773..54130dfe9 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -1,13 +1,18 @@ package github import ( + "archive/zip" + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "net/http" + "net/url" "strconv" "strings" + "unicode/utf8" "github.com/github/github-mcp-server/internal/profiler" buffer "github.com/github/github-mcp-server/pkg/buffer" @@ -27,6 +32,11 @@ const ( DescriptionRepositoryName = "Repository name" ) +const ( + defaultArtifactContentMaxBytes = 64 * 1024 + maxArtifactArchiveBytes = 32 * 1024 * 1024 +) + // Method constants for consolidated actions tools const ( actionsMethodListWorkflows = "list_workflows" @@ -445,12 +455,30 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an 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 an artifact ID for 'download_workflow_run_artifact' method. +- Provide an artifact ID for 'download_workflow_run_artifact' to get a temporary download URL, or omit it and use 'run_id' plus 'artifact_name' to return artifact contents. - Provide a job ID for 'get_workflow_job' method. `, }, + "run_id": { + Type: "number", + Description: "Workflow run ID used with 'artifact_name' to resolve and extract an artifact for 'download_workflow_run_artifact'.", + }, + "artifact_name": { + Type: "string", + Description: "Exact workflow artifact name to resolve within 'run_id' for 'download_workflow_run_artifact'.", + }, + "path": { + Type: "string", + Description: "Optional exact file path inside the artifact ZIP to return for 'download_workflow_run_artifact'.", + }, + "max_bytes": { + Type: "number", + Description: "Maximum number of bytes of text content to return per file when extracting 'download_workflow_run_artifact'. Defaults to 65536.", + Minimum: jsonschema.Ptr(1.0), + Default: json.RawMessage(`65536`), + }, }, - Required: []string{"method", "owner", "repo", "resource_id"}, + Required: []string{"method", "owner", "repo"}, }, }, []scopes.Scope{scopes.Repo}, @@ -468,10 +496,27 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an return utils.NewToolResultError(err.Error()), nil, nil } - resourceID, err := RequiredParam[string](args, "resource_id") + resourceID, err := OptionalParam[string](args, "resource_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + runID, err := OptionalIntParam(args, "run_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + artifactName, err := OptionalParam[string](args, "artifact_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + artifactPath, err := OptionalParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + maxBytes, err := OptionalIntParam(args, "max_bytes") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + _, hasMaxBytes := args["max_bytes"] client, err := deps.GetClient(ctx) if err != nil { @@ -490,9 +535,42 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an var parseErr error switch method { case actionsMethodGetWorkflow: - // Do nothing, we accept both a string workflow ID or filename + if resourceID == "" { + return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil + } + case actionsMethodDownloadWorkflowArtifact: + if artifactName != "" { + if resourceID != "" { + return utils.NewToolResultError("resource_id cannot be combined with artifact_name for download_workflow_run_artifact"), nil, nil + } + if runID == 0 { + return utils.NewToolResultError("run_id is required when artifact_name is provided for download_workflow_run_artifact"), nil, nil + } + if hasMaxBytes && maxBytes < 1 { + return utils.NewToolResultError("max_bytes must be >= 1 when provided"), nil, nil + } + } else { + if resourceID == "" { + return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil + } + if runID != 0 { + return utils.NewToolResultError("run_id requires artifact_name for download_workflow_run_artifact"), nil, nil + } + if artifactPath != "" { + return utils.NewToolResultError("path requires artifact_name for download_workflow_run_artifact"), nil, nil + } + if hasMaxBytes { + return utils.NewToolResultError("max_bytes requires artifact_name for download_workflow_run_artifact"), nil, nil + } + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil + } + } default: - // For other methods, resource ID must be an integer + if resourceID == "" { + return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil + } resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) if parseErr != nil { return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil @@ -510,7 +588,13 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an result, payload, err := getWorkflowJob(ctx, client, owner, repo, resourceIDInt) return attachIFC(result), payload, err case actionsMethodDownloadWorkflowArtifact: - result, payload, err := downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) + result, payload, err := downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt, workflowArtifactDownloadOptions{ + RunID: int64(runID), + ArtifactName: artifactName, + Path: artifactPath, + MaxBytes: maxBytes, + DefaultMax: deps.GetContentWindowSize(), + }) return attachIFC(result), payload, err case actionsMethodGetWorkflowRunUsage: result, payload, err := getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) @@ -951,15 +1035,107 @@ func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, re return utils.NewToolResultText(string(r)), nil, nil } -func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +type workflowArtifactDownloadOptions struct { + RunID int64 + ArtifactName string + Path string + MaxBytes int + DefaultMax int +} + +type workflowArtifactFileResult struct { + Path string `json:"path"` + Size int64 `json:"size"` + Truncated bool `json:"truncated"` + Binary bool `json:"binary,omitempty"` + Content string `json:"content,omitempty"` + ContentOmittedReason string `json:"content_omitted_reason,omitempty"` +} + +type workflowArtifactContentResult struct { + ArtifactID int64 `json:"artifact_id"` + ArtifactName string `json:"artifact_name"` + Expired bool `json:"expired"` + SizeInBytes int64 `json:"size_in_bytes"` + MaxBytes int `json:"max_bytes"` + Files []workflowArtifactFileResult `json:"files"` +} + +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, opts workflowArtifactDownloadOptions) (*mcp.CallToolResult, any, error) { + if opts.ArtifactName == "" { + return downloadWorkflowArtifactURL(ctx, client, owner, repo, resourceID) + } + + maxBytes := opts.MaxBytes + if maxBytes <= 0 { + maxBytes = opts.DefaultMax + } + if maxBytes <= 0 { + maxBytes = defaultArtifactContentMaxBytes + } + + artifact, resp, err := findWorkflowArtifactByName(ctx, client, owner, repo, opts.RunID, opts.ArtifactName) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil, nil + } + if artifact == nil { + return utils.NewToolResultError( + fmt.Sprintf("artifact %q was not found in workflow run %d for %s/%s", opts.ArtifactName, opts.RunID, owner, repo), + ), nil, nil + } + // Get the download URL for the artifact + downloadURL, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifact.GetID(), 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + archiveData, httpResp, err := downloadArtifactArchive(ctx, downloadURL, maxArtifactArchiveBytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to download artifact archive: %w", err) + } + if httpResp != nil { + defer func() { _ = httpResp.Body.Close() }() + } + + files, matchedPath, err := extractWorkflowArtifactFiles(archiveData, opts.Path, maxBytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to extract artifact archive: %w", err) + } + if opts.Path != "" && !matchedPath { + return utils.NewToolResultError( + fmt.Sprintf("artifact %q in workflow run %d does not contain path %q", opts.ArtifactName, opts.RunID, opts.Path), + ), nil, nil + } + + result := workflowArtifactContentResult{ + ArtifactID: artifact.GetID(), + ArtifactName: artifact.GetName(), + Expired: artifact.GetExpired(), + SizeInBytes: artifact.GetSizeInBytes(), + MaxBytes: maxBytes, + Files: files, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func downloadWorkflowArtifactURL(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - // Create response with the download URL and information result := map[string]any{ "download_url": url.String(), "message": "Artifact is available for download", @@ -975,6 +1151,146 @@ func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, return utils.NewToolResultText(string(r)), nil, nil } +func findWorkflowArtifactByName(ctx context.Context, client *github.Client, owner, repo string, runID int64, artifactName string) (*github.Artifact, *github.Response, error) { + opts := &github.ListOptions{ + PerPage: 100, + Page: 1, + } + + for { + artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) + if err != nil { + return nil, resp, err + } + + for _, artifact := range artifacts.Artifacts { + if artifact.GetName() == artifactName { + return artifact, resp, nil + } + } + + nextPage := resp.NextPage + _ = resp.Body.Close() + if nextPage == 0 { + return nil, resp, nil + } + opts.Page = nextPage + } +} + +func downloadArtifactArchive(ctx context.Context, artifactURL *url.URL, maxArchiveBytes int64) ([]byte, *http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, artifactURL.String(), nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to build artifact archive request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) //nolint:gosec + if err != nil { + return nil, resp, fmt.Errorf("failed to request artifact archive: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, resp, fmt.Errorf("failed to download artifact archive: HTTP %d", resp.StatusCode) + } + return nil, resp, fmt.Errorf("failed to download artifact archive: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxArchiveBytes+1)) + if err != nil { + return nil, resp, fmt.Errorf("failed to read artifact archive: %w", err) + } + if int64(len(data)) > maxArchiveBytes { + return nil, resp, fmt.Errorf("artifact archive exceeds maximum supported size of %d bytes", maxArchiveBytes) + } + + return data, resp, nil +} + +func extractWorkflowArtifactFiles(archiveData []byte, pathFilter string, maxBytes int) ([]workflowArtifactFileResult, bool, error) { + reader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) + if err != nil { + return nil, false, err + } + + files := make([]workflowArtifactFileResult, 0, len(reader.File)) + matchedPath := pathFilter == "" + + for _, f := range reader.File { + if f.FileInfo().IsDir() { + continue + } + if pathFilter != "" && f.Name != pathFilter { + continue + } + + matchedPath = true + fileResult, err := readWorkflowArtifactFile(f, maxBytes) + if err != nil { + return nil, matchedPath, err + } + files = append(files, fileResult) + } + + return files, matchedPath, nil +} + +func readWorkflowArtifactFile(f *zip.File, maxBytes int) (workflowArtifactFileResult, error) { + result := workflowArtifactFileResult{ + Path: f.Name, + Size: f.FileInfo().Size(), + Truncated: false, + } + + rc, err := f.Open() + if err != nil { + return result, err + } + defer func() { _ = rc.Close() }() + + previewLimit := max(int64(maxBytes)+1, int64(1)) + + content, err := io.ReadAll(io.LimitReader(rc, previewLimit)) + if err != nil { + return result, err + } + + if bytes.IndexByte(content, 0) >= 0 { + result.Binary = true + result.ContentOmittedReason = "binary or non-UTF-8 content" + return result, nil + } + + if len(content) > maxBytes { + content = truncateUTF8(content[:maxBytes]) + result.Truncated = true + } + if result.Size > int64(maxBytes) { + result.Truncated = true + } + + if !utf8.Valid(content) { + result.Binary = true + result.ContentOmittedReason = "binary or non-UTF-8 content" + return result, nil + } + + result.Content = string(content) + if result.Truncated { + result.ContentOmittedReason = fmt.Sprintf("content truncated to %d bytes", maxBytes) + } + + return result, nil +} + +func truncateUTF8(content []byte) []byte { + for len(content) > 0 && !utf8.Valid(content) { + content = content[:len(content)-1] + } + return content +} + func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { // Get the download URL for the logs url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 371bbbe9d..f90f24ff6 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1,9 +1,13 @@ package github import ( + "archive/zip" + "bytes" "context" "encoding/json" "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/github/github-mcp-server/internal/toolsnaps" @@ -221,7 +225,11 @@ func Test_ActionsGet(t *testing.T) { assert.Contains(t, inputSchema.Properties, "owner") assert.Contains(t, inputSchema.Properties, "repo") assert.Contains(t, inputSchema.Properties, "resource_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "repo", "resource_id"}) + assert.Contains(t, inputSchema.Properties, "run_id") + assert.Contains(t, inputSchema.Properties, "artifact_name") + assert.Contains(t, inputSchema.Properties, "path") + assert.Contains(t, inputSchema.Properties, "max_bytes") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "repo"}) } func Test_ActionsGet_GetWorkflow(t *testing.T) { @@ -310,6 +318,298 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { }) } +func TestActionsGet_DownloadWorkflowArtifact_LegacyURL(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + + mockedClient := MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/actions/artifacts/456/zip" { + w.Header().Set("Location", "https://example.com/artifacts/456.zip") + w.WriteHeader(http.StatusFound) + return + } + + http.NotFound(w, r) + }) + + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "download_workflow_run_artifact", + "owner": "owner", + "repo": "repo", + "resource_id": "456", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "https://example.com/artifacts/456.zip", response["download_url"]) + assert.Equal(t, float64(456), response["artifact_id"]) +} + +func TestActionsGet_DownloadWorkflowArtifact_ByRunAndName(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + archiveBytes := mustCreateArtifactZip(t, map[string][]byte{ + "usage.jsonl": []byte("{\"count\":123}\n"), + "trace.bin": {0x00, 0x01, 0x02}, + }) + archiveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(archiveBytes) + })) + defer archiveServer.Close() + + mockedClient := MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/actions/runs/123/artifacts": + artifacts := &github.ArtifactList{ + TotalCount: github.Ptr(int64(2)), + Artifacts: []*github.Artifact{ + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("agent-artifacts"), + Expired: github.Ptr(false), + SizeInBytes: github.Ptr(int64(len(archiveBytes))), + }, + { + ID: github.Ptr(int64(789)), + Name: github.Ptr("other-artifact"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(artifacts) + case r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/actions/artifacts/456/zip": + w.Header().Set("Location", archiveServer.URL+"/download.zip") + w.WriteHeader(http.StatusFound) + default: + http.NotFound(w, r) + } + }) + + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "download_workflow_run_artifact", + "owner": "owner", + "repo": "repo", + "run_id": float64(123), + "artifact_name": "agent-artifacts", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response struct { + ArtifactID int64 `json:"artifact_id"` + ArtifactName string `json:"artifact_name"` + Expired bool `json:"expired"` + SizeInBytes int64 `json:"size_in_bytes"` + MaxBytes int `json:"max_bytes"` + Files []struct { + Path string `json:"path"` + Size int64 `json:"size"` + Truncated bool `json:"truncated"` + Binary bool `json:"binary"` + Content string `json:"content"` + ContentOmittedReason string `json:"content_omitted_reason"` + } `json:"files"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, int64(456), response.ArtifactID) + assert.Equal(t, "agent-artifacts", response.ArtifactName) + assert.False(t, response.Expired) + assert.Len(t, response.Files, 2) + + filesByPath := map[string]struct { + Path string `json:"path"` + Size int64 `json:"size"` + Truncated bool `json:"truncated"` + Binary bool `json:"binary"` + Content string `json:"content"` + ContentOmittedReason string `json:"content_omitted_reason"` + }{} + for _, file := range response.Files { + filesByPath[file.Path] = file + } + + usageFile := filesByPath["usage.jsonl"] + assert.Equal(t, "{\"count\":123}\n", usageFile.Content) + assert.False(t, usageFile.Truncated) + + binaryFile := filesByPath["trace.bin"] + assert.True(t, binaryFile.Binary) + assert.Empty(t, binaryFile.Content) +} + +func TestActionsGet_DownloadWorkflowArtifact_PathFilteringAndTruncation(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + archiveBytes := mustCreateArtifactZip(t, map[string][]byte{ + "nested/result.txt": []byte("abcdefghij"), + "other.txt": []byte("unused"), + }) + archiveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(archiveBytes) + })) + defer archiveServer.Close() + + mockedClient := MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/actions/runs/123/artifacts": + artifacts := &github.ArtifactList{ + TotalCount: github.Ptr(int64(1)), + Artifacts: []*github.Artifact{ + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("agent-artifacts"), + Expired: github.Ptr(false), + SizeInBytes: github.Ptr(int64(len(archiveBytes))), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(artifacts) + case r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/actions/artifacts/456/zip": + w.Header().Set("Location", archiveServer.URL+"/download.zip") + w.WriteHeader(http.StatusFound) + default: + http.NotFound(w, r) + } + }) + + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "download_workflow_run_artifact", + "owner": "owner", + "repo": "repo", + "run_id": float64(123), + "artifact_name": "agent-artifacts", + "path": "nested/result.txt", + "max_bytes": float64(4), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response struct { + Files []struct { + Path string `json:"path"` + Truncated bool `json:"truncated"` + Content string `json:"content"` + ContentOmittedReason string `json:"content_omitted_reason"` + } `json:"files"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + require.Len(t, response.Files, 1) + assert.Equal(t, "nested/result.txt", response.Files[0].Path) + assert.Equal(t, "abcd", response.Files[0].Content) + assert.True(t, response.Files[0].Truncated) + assert.Contains(t, response.Files[0].ContentOmittedReason, "truncated") +} + +func TestActionsGet_DownloadWorkflowArtifact_MissingArtifact(t *testing.T) { + toolDef := ActionsGet(translations.NullTranslationHelper) + + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsArtifactsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + artifacts := &github.ArtifactList{ + TotalCount: github.Ptr(int64(1)), + Artifacts: []*github.Artifact{ + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("other-artifact"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(artifacts) + }), + }) + + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "download_workflow_run_artifact", + "owner": "owner", + "repo": "repo", + "run_id": float64(123), + "artifact_name": "agent-artifacts", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "artifact \"agent-artifacts\" was not found") +} + +func TestDownloadArtifactArchiveRejectsOversizedArchive(t *testing.T) { + archiveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("12345")) + })) + defer archiveServer.Close() + + archiveURL, err := url.Parse(archiveServer.URL + "/download.zip") + require.NoError(t, err) + + _, resp, err := downloadArtifactArchive(context.Background(), archiveURL, 4) + require.Error(t, err) + assert.Contains(t, err.Error(), "artifact archive exceeds maximum supported size") + require.NotNil(t, resp) + require.NoError(t, resp.Body.Close()) +} + +func TestReadWorkflowArtifactFileTruncatesAtUTF8Boundary(t *testing.T) { + archiveBytes := mustCreateArtifactZip(t, map[string][]byte{ + "unicode.txt": []byte("abé"), + }) + reader, err := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes))) + require.NoError(t, err) + require.Len(t, reader.File, 1) + + result, err := readWorkflowArtifactFile(reader.File[0], 2) + require.NoError(t, err) + assert.Equal(t, "ab", result.Content) + assert.True(t, result.Truncated) +} + +func mustCreateArtifactZip(t *testing.T, files map[string][]byte) []byte { + t.Helper() + + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for path, content := range files { + entry, err := writer.Create(path) + require.NoError(t, err) + _, err = entry.Write(content) + require.NoError(t, err) + } + require.NoError(t, writer.Close()) + + return archive.Bytes() +} + func Test_ActionsRunTrigger(t *testing.T) { // Verify tool definition once toolDef := ActionsRunTrigger(translations.NullTranslationHelper)