Skip to content

Commit 7c62774

Browse files
authored
Add tail logs option (github#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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy