Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
JoannaaKL merged 6 commits intogithub:mainfromJoannaaKL:tructate-job-logs
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes fromall 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 deletionsREADME.md
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionspkg/github/actions.go
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 deletionspkg/github/actions_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff 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
}

[8]ページ先頭

©2009-2025 Movatter.jp