diff --git a/README.md b/README.md index 4458b95ab..44a829601 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - `return_content`: Returns actual log content instead of URLs (boolean, optional) - `run_id`: Workflow run ID (required when using failed_only) (number, optional) + - `tail_lines`: Number of lines to return from the end of the log (number, optional) - **get_workflow_run** - Get workflow run - `owner`: Repository owner (string, required) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index cf33fb5a8..0fa49bac7 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -584,6 +584,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to mcp.WithBoolean("return_content", mcp.Description("Returns actual log content instead of URLs"), ), + mcp.WithNumber("tail_lines", + mcp.Description("Number of lines to return from the end of the log"), + mcp.DefaultNumber(500), + ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -612,6 +616,14 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to if err != nil { return mcp.NewToolResultError(err.Error()), nil } + tailLines, err := OptionalIntParam(request, "tail_lines") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // Default to 500 lines if not specified + if tailLines == 0 { + tailLines = 500 + } client, err := getClient(ctx) if err != nil { @@ -628,10 +640,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to if failedOnly && runID > 0 { // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent) + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines) } else if jobID > 0 { // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent) + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines) } return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil @@ -639,7 +651,7 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to } // handleFailedJobLogs gets logs for all failed jobs in a workflow run -func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool) (*mcp.CallToolResult, error) { +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) { // First, get all jobs for the workflow run jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ Filter: "latest", @@ -671,7 +683,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo // Collect logs for all failed jobs var logResults []map[string]any for _, job := range failedJobs { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent) + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines) if err != nil { // Continue with other jobs even if one fails jobResult = map[string]any{ @@ -704,8 +716,8 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo } // handleSingleJobLogs gets logs for a single job -func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool) (*mcp.CallToolResult, error) { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent) +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil } @@ -719,7 +731,7 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo } // getJobLogData retrieves log data for a single job, either as URL or content -func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool) (map[string]any, *github.Response, error) { +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int) (map[string]any, *github.Response, error) { // Get the download URL for the job logs url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) if err != nil { @@ -736,7 +748,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin if returnContent { // Download and return the actual log content - content, httpResp, err := downloadLogContent(url.String()) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp + content, originalLength, httpResp, err := downloadLogContent(url.String(), tailLines) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp if err != nil { // To keep the return value consistent wrap the response as a GitHub Response ghRes := &github.Response{ @@ -746,6 +758,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } result["logs_content"] = content result["message"] = "Job logs content retrieved successfully" + result["original_length"] = originalLength } else { // Return just the URL result["logs_url"] = url.String() @@ -757,25 +770,46 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } // downloadLogContent downloads the actual log content from a GitHub logs URL -func downloadLogContent(logURL string) (string, *http.Response, error) { +func downloadLogContent(logURL string, tailLines int) (string, int, *http.Response, error) { httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe if err != nil { - return "", httpResp, fmt.Errorf("failed to download logs: %w", err) + return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) } defer func() { _ = httpResp.Body.Close() }() if httpResp.StatusCode != http.StatusOK { - return "", httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) + return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) } content, err := io.ReadAll(httpResp.Body) if err != nil { - return "", httpResp, fmt.Errorf("failed to read log content: %w", err) + return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err) } // Clean up and format the log content for better readability logContent := strings.TrimSpace(string(content)) - return logContent, httpResp, nil + + trimmedContent, lineCount := trimContent(logContent, tailLines) + return trimmedContent, lineCount, httpResp, nil +} + +// trimContent trims the content to a maximum length and returns the trimmed content and an original length +func trimContent(content string, tailLines int) (string, int) { + // Truncate to tail_lines if specified + lineCount := 0 + if tailLines > 0 { + + // Count backwards to find the nth newline from the end + for i := len(content) - 1; i >= 0 && lineCount < tailLines; i-- { + if content[i] == '\n' { + lineCount++ + if lineCount == tailLines { + content = content[i+1:] + } + } + } + } + return content, lineCount } // RerunWorkflowRun creates a tool to re-run an entire workflow run diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 9303932d2..1b904b9b1 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1095,3 +1095,52 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) { assert.Equal(t, "Job logs content retrieved successfully", response["message"]) assert.NotContains(t, response, "logs_url") // Should not have URL when returning content } + +func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { + // Test the return_content functionality with a mock HTTP server + logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" + expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully" + + // Create a test server to serve log content + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + "return_content": true, + "tail_lines": float64(1), // Requesting last 1 line + }) + + result, err := handler(context.Background(), 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, float64(123), response["job_id"]) + assert.Equal(t, float64(1), response["original_length"]) + assert.Equal(t, expectedLogContent, response["logs_content"]) + assert.Equal(t, "Job logs content retrieved successfully", response["message"]) + assert.NotContains(t, response, "logs_url") // Should not have URL when returning content +}