Skip to content

Add tail logs option #615

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 47 additions & 13 deletions pkg/github/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand All @@ -628,18 +640,18 @@ 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
}
}

// 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",
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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{
Expand All @@ -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()
Expand All @@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be worth a comment here explaining that there isn't an exit condition because we want the total line count.

content = content[i+1:]
}
}
}
}
return content, lineCount
}

// RerunWorkflowRun creates a tool to re-run an entire workflow run
Expand Down
49 changes: 49 additions & 0 deletions pkg/github/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}