Skip to content

Commit 7c62774

Browse files
authored
Add tail logs option (#615)
1 parent 5904a03 commit 7c62774

File tree

3 files changed

+97
-13
lines changed

3 files changed

+97
-13
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
456456
- `repo`: Repository name (string, required)
457457
- `return_content`: Returns actual log content instead of URLs (boolean, optional)
458458
- `run_id`: Workflow run ID (required when using failed_only) (number, optional)
459+
- `tail_lines`: Number of lines to return from the end of the log (number, optional)
459460

460461
- **get_workflow_run** - Get workflow run
461462
- `owner`: Repository owner (string, required)

pkg/github/actions.go

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to
584584
mcp.WithBoolean("return_content",
585585
mcp.Description("Returns actual log content instead of URLs"),
586586
),
587+
mcp.WithNumber("tail_lines",
588+
mcp.Description("Number of lines to return from the end of the log"),
589+
mcp.DefaultNumber(500),
590+
),
587591
),
588592
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
589593
owner, err := RequiredParam[string](request, "owner")
@@ -612,6 +616,14 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to
612616
if err != nil {
613617
return mcp.NewToolResultError(err.Error()), nil
614618
}
619+
tailLines, err := OptionalIntParam(request, "tail_lines")
620+
if err != nil {
621+
return mcp.NewToolResultError(err.Error()), nil
622+
}
623+
// Default to 500 lines if not specified
624+
if tailLines == 0 {
625+
tailLines = 500
626+
}
615627

616628
client, err := getClient(ctx)
617629
if err != nil {
@@ -628,18 +640,18 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to
628640

629641
if failedOnly && runID > 0 {
630642
// Handle failed-only mode: get logs for all failed jobs in the workflow run
631-
return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent)
643+
return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines)
632644
} else if jobID > 0 {
633645
// Handle single job mode
634-
return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent)
646+
return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines)
635647
}
636648

637649
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
638650
}
639651
}
640652

641653
// handleFailedJobLogs gets logs for all failed jobs in a workflow run
642-
func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool) (*mcp.CallToolResult, error) {
654+
func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) {
643655
// First, get all jobs for the workflow run
644656
jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{
645657
Filter: "latest",
@@ -671,7 +683,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo
671683
// Collect logs for all failed jobs
672684
var logResults []map[string]any
673685
for _, job := range failedJobs {
674-
jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent)
686+
jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines)
675687
if err != nil {
676688
// Continue with other jobs even if one fails
677689
jobResult = map[string]any{
@@ -704,8 +716,8 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo
704716
}
705717

706718
// handleSingleJobLogs gets logs for a single job
707-
func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool) (*mcp.CallToolResult, error) {
708-
jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent)
719+
func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) {
720+
jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines)
709721
if err != nil {
710722
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil
711723
}
@@ -719,7 +731,7 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo
719731
}
720732

721733
// getJobLogData retrieves log data for a single job, either as URL or content
722-
func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool) (map[string]any, *github.Response, error) {
734+
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) {
723735
// Get the download URL for the job logs
724736
url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1)
725737
if err != nil {
@@ -736,7 +748,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin
736748

737749
if returnContent {
738750
// Download and return the actual log content
739-
content, httpResp, err := downloadLogContent(url.String()) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp
751+
content, originalLength, httpResp, err := downloadLogContent(url.String(), tailLines) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp
740752
if err != nil {
741753
// To keep the return value consistent wrap the response as a GitHub Response
742754
ghRes := &github.Response{
@@ -746,6 +758,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin
746758
}
747759
result["logs_content"] = content
748760
result["message"] = "Job logs content retrieved successfully"
761+
result["original_length"] = originalLength
749762
} else {
750763
// Return just the URL
751764
result["logs_url"] = url.String()
@@ -757,25 +770,46 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin
757770
}
758771

759772
// downloadLogContent downloads the actual log content from a GitHub logs URL
760-
func downloadLogContent(logURL string) (string, *http.Response, error) {
773+
func downloadLogContent(logURL string, tailLines int) (string, int, *http.Response, error) {
761774
httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe
762775
if err != nil {
763-
return "", httpResp, fmt.Errorf("failed to download logs: %w", err)
776+
return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err)
764777
}
765778
defer func() { _ = httpResp.Body.Close() }()
766779

767780
if httpResp.StatusCode != http.StatusOK {
768-
return "", httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode)
781+
return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode)
769782
}
770783

771784
content, err := io.ReadAll(httpResp.Body)
772785
if err != nil {
773-
return "", httpResp, fmt.Errorf("failed to read log content: %w", err)
786+
return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err)
774787
}
775788

776789
// Clean up and format the log content for better readability
777790
logContent := strings.TrimSpace(string(content))
778-
return logContent, httpResp, nil
791+
792+
trimmedContent, lineCount := trimContent(logContent, tailLines)
793+
return trimmedContent, lineCount, httpResp, nil
794+
}
795+
796+
// trimContent trims the content to a maximum length and returns the trimmed content and an original length
797+
func trimContent(content string, tailLines int) (string, int) {
798+
// Truncate to tail_lines if specified
799+
lineCount := 0
800+
if tailLines > 0 {
801+
802+
// Count backwards to find the nth newline from the end
803+
for i := len(content) - 1; i >= 0 && lineCount < tailLines; i-- {
804+
if content[i] == '\n' {
805+
lineCount++
806+
if lineCount == tailLines {
807+
content = content[i+1:]
808+
}
809+
}
810+
}
811+
}
812+
return content, lineCount
779813
}
780814

781815
// RerunWorkflowRun creates a tool to re-run an entire workflow run

pkg/github/actions_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,3 +1095,52 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) {
10951095
assert.Equal(t, "Job logs content retrieved successfully", response["message"])
10961096
assert.NotContains(t, response, "logs_url") // Should not have URL when returning content
10971097
}
1098+
1099+
func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) {
1100+
// Test the return_content functionality with a mock HTTP server
1101+
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"
1102+
expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully"
1103+
1104+
// Create a test server to serve log content
1105+
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1106+
w.WriteHeader(http.StatusOK)
1107+
_, _ = w.Write([]byte(logContent))
1108+
}))
1109+
defer testServer.Close()
1110+
1111+
mockedClient := mock.NewMockedHTTPClient(
1112+
mock.WithRequestMatchHandler(
1113+
mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
1114+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1115+
w.Header().Set("Location", testServer.URL)
1116+
w.WriteHeader(http.StatusFound)
1117+
}),
1118+
),
1119+
)
1120+
1121+
client := github.NewClient(mockedClient)
1122+
_, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper)
1123+
1124+
request := createMCPRequest(map[string]any{
1125+
"owner": "owner",
1126+
"repo": "repo",
1127+
"job_id": float64(123),
1128+
"return_content": true,
1129+
"tail_lines": float64(1), // Requesting last 1 line
1130+
})
1131+
1132+
result, err := handler(context.Background(), request)
1133+
require.NoError(t, err)
1134+
require.False(t, result.IsError)
1135+
1136+
textContent := getTextResult(t, result)
1137+
var response map[string]any
1138+
err = json.Unmarshal([]byte(textContent.Text), &response)
1139+
require.NoError(t, err)
1140+
1141+
assert.Equal(t, float64(123), response["job_id"])
1142+
assert.Equal(t, float64(1), response["original_length"])
1143+
assert.Equal(t, expectedLogContent, response["logs_content"])
1144+
assert.Equal(t, "Job logs content retrieved successfully", response["message"])
1145+
assert.NotContains(t, response, "logs_url") // Should not have URL when returning content
1146+
}

0 commit comments

Comments
 (0)