Skip to content

Commit 7d06c88

Browse files
committed
Add tail logs option
1 parent 5904a03 commit 7d06c88

File tree

2 files changed

+137
-9
lines changed

2 files changed

+137
-9
lines changed

pkg/github/actions.go

Lines changed: 31 additions & 9 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(50),
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 50 lines if not specified
624+
if tailLines == 0 {
625+
tailLines = 50
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, 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{
@@ -757,7 +769,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin
757769
}
758770

759771
// downloadLogContent downloads the actual log content from a GitHub logs URL
760-
func downloadLogContent(logURL string) (string, *http.Response, error) {
772+
func downloadLogContent(logURL string, tailLines int) (string, *http.Response, error) {
761773
httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe
762774
if err != nil {
763775
return "", httpResp, fmt.Errorf("failed to download logs: %w", err)
@@ -775,6 +787,16 @@ func downloadLogContent(logURL string) (string, *http.Response, error) {
775787

776788
// Clean up and format the log content for better readability
777789
logContent := strings.TrimSpace(string(content))
790+
791+
// Truncate to tail_lines if specified
792+
if tailLines > 0 {
793+
lines := strings.Split(logContent, "\n")
794+
if len(lines) > tailLines {
795+
lines = lines[len(lines)-tailLines:]
796+
logContent = strings.Join(lines, "\n")
797+
}
798+
}
799+
778800
return logContent, httpResp, nil
779801
}
780802

pkg/github/actions_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,64 @@ func Test_GetJobLogs(t *testing.T) {
885885
assert.Len(t, logs, 2)
886886
},
887887
},
888+
{
889+
name: "successful failed jobs logs with tailing",
890+
mockedClient: mock.NewMockedHTTPClient(
891+
mock.WithRequestMatchHandler(
892+
mock.GetReposActionsRunsJobsByOwnerByRepoByRunId,
893+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
894+
jobs := &github.Jobs{
895+
TotalCount: github.Ptr(3),
896+
Jobs: []*github.WorkflowJob{
897+
{
898+
ID: github.Ptr(int64(1)),
899+
Name: github.Ptr("test-job-1"),
900+
Conclusion: github.Ptr("failure"),
901+
},
902+
{
903+
ID: github.Ptr(int64(2)),
904+
Name: github.Ptr("test-job-2"),
905+
Conclusion: github.Ptr("failure"),
906+
},
907+
{
908+
ID: github.Ptr(int64(3)),
909+
Name: github.Ptr("test-job-3"),
910+
Conclusion: github.Ptr("failure"),
911+
},
912+
},
913+
}
914+
w.WriteHeader(http.StatusOK)
915+
_ = json.NewEncoder(w).Encode(jobs)
916+
}),
917+
),
918+
mock.WithRequestMatchHandler(
919+
mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
920+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
921+
w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:])
922+
w.WriteHeader(http.StatusFound)
923+
}),
924+
),
925+
),
926+
requestArgs: map[string]any{
927+
"owner": "owner",
928+
"repo": "repo",
929+
"run_id": float64(456),
930+
"failed_only": true,
931+
"tail_lines": float64(1),
932+
},
933+
expectError: false,
934+
checkResponse: func(t *testing.T, response map[string]any) {
935+
assert.Equal(t, float64(456), response["run_id"])
936+
assert.Equal(t, float64(3), response["total_jobs"])
937+
assert.Equal(t, float64(2), response["failed_jobs"])
938+
assert.Contains(t, response, "logs")
939+
assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"])
940+
941+
logs, ok := response["logs"].([]interface{})
942+
assert.True(t, ok)
943+
assert.Len(t, logs, 3)
944+
},
945+
},
888946
{
889947
name: "no failed jobs found",
890948
mockedClient: mock.NewMockedHTTPClient(
@@ -1095,3 +1153,51 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) {
10951153
assert.Equal(t, "Job logs content retrieved successfully", response["message"])
10961154
assert.NotContains(t, response, "logs_url") // Should not have URL when returning content
10971155
}
1156+
1157+
func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) {
1158+
// Test the return_content functionality with a mock HTTP server
1159+
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"
1160+
expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully"
1161+
1162+
// Create a test server to serve log content
1163+
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1164+
w.WriteHeader(http.StatusOK)
1165+
_, _ = w.Write([]byte(logContent))
1166+
}))
1167+
defer testServer.Close()
1168+
1169+
mockedClient := mock.NewMockedHTTPClient(
1170+
mock.WithRequestMatchHandler(
1171+
mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
1172+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1173+
w.Header().Set("Location", testServer.URL)
1174+
w.WriteHeader(http.StatusFound)
1175+
}),
1176+
),
1177+
)
1178+
1179+
client := github.NewClient(mockedClient)
1180+
_, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper)
1181+
1182+
request := createMCPRequest(map[string]any{
1183+
"owner": "owner",
1184+
"repo": "repo",
1185+
"job_id": float64(123),
1186+
"return_content": true,
1187+
"tail_lines": float64(1), // Requesting last 1 line
1188+
})
1189+
1190+
result, err := handler(context.Background(), request)
1191+
require.NoError(t, err)
1192+
require.False(t, result.IsError)
1193+
1194+
textContent := getTextResult(t, result)
1195+
var response map[string]any
1196+
err = json.Unmarshal([]byte(textContent.Text), &response)
1197+
require.NoError(t, err)
1198+
1199+
assert.Equal(t, float64(123), response["job_id"])
1200+
assert.Equal(t, expectedLogContent, response["logs_content"])
1201+
assert.Equal(t, "Job logs content retrieved successfully", response["message"])
1202+
assert.NotContains(t, response, "logs_url") // Should not have URL when returning content
1203+
}

0 commit comments

Comments
 (0)