Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
47 changes: 38 additions & 9 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(50),
Copy link

@kehao95 kehao95 Jun 30, 2025

Choose a reason for hiding this comment

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

We can encourage LLM using this option in the description. But I think the default behavior should be return all if tail_lines not specified?

),
),
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 50 lines if not specified
if tailLines == 0 {
tailLines = 50
}

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, 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 @@ -757,7 +769,7 @@ 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, *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)
Expand All @@ -775,6 +787,23 @@ func downloadLogContent(logURL string) (string, *http.Response, error) {

// Clean up and format the log content for better readability
logContent := strings.TrimSpace(string(content))

// Truncate to tail_lines if specified
if tailLines > 0 {
lineCount := 0

// Count backwards to find the nth newline from the end
for i := len(logContent) - 1; i >= 0 && lineCount < tailLines; i-- {
if logContent[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.

logContent = logContent[i+1:]
break
}
}
}
}

return logContent, httpResp, nil
}

Expand Down
48 changes: 48 additions & 0 deletions pkg/github/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1095,3 +1095,51 @@ 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, 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
}