8000 feat: enhance GetJobLogs functionality for improved job log retrieval · github/github-mcp-server@15ca4b7 · GitHub
[go: up one dir, main page]

Skip to content

Commit 15ca4b7

Browse files
committed
feat: enhance GetJobLogs functionality for improved job log retrieval
- Added new tests for GetJobLogs, including scenarios for retrieving logs for both single jobs and failed jobs. - Updated GetJobLogs tool description to clarify its capabilities for fetching logs efficiently. - Implemented error handling for missing required parameters and optimized responses for failed job logs. - Introduced functionality to return actual log content instead of just URLs when requested.
1 parent 9153c1f commit 15ca4b7

File tree

2 files changed

+506
-37
lines changed

2 files changed

+506
-37
lines changed

pkg/github/actions.go

Lines changed: 187 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
710

811
"github.com/github/github-mcp-server/pkg/translations"
912
"github.com/google/go-github/v72/github"
@@ -336,7 +339,7 @@ func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc)
336339
// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run
337340
func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
338341
return mcp.NewTool("get_workflow_run_logs",
339-
mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run")),
342+
mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")),
340343
mcp.WithToolAnnotation(mcp.ToolAnnotation{
341344
ReadOnlyHint: toBoolPtr(true),
342345
}),
@@ -382,9 +385,11 @@ func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperF
382385

383386
// Create response with the logs URL and information
384387
result := map[string]any{
385-
"logs_url": url.String(),
386-
"message": "Workflow run logs are available for download",
387-
"note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.",
388+
"logs_url": url.String(),
389+
"message": "Workflow run logs are available for download",
390+
"note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.",
391+
"warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.",
392+
"optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging",
388393
}
389394

390395
r, err := json.Marshal(result)
@@ -476,7 +481,13 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun
476481
}
477482
defer func() { _ = resp.Body.Close() }()
478483

479-
r, err := json.Marshal(jobs)
484+
// Add optimization tip for failed job debugging
485+
response := map[string]any{
486+
"jobs": jobs,
487+
"optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first",
488+
}
489+
490+
r, err := json.Marshal(response)
480491
if err != nil {
481492
return nil, fmt.Errorf("failed to marshal response: %w", err)
482493
}
@@ -485,10 +496,10 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun
485496
}
486497
}
487498

488-
// GetJobLogs creates a tool to download logs for a specific workflow job
499+
// GetJobLogs creates a tool to download logs for a specific workflow job or get failed job logs efficiently
489500
func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
490501
return mcp.NewTool("get_job_logs",
491-
mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job")),
502+
mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")),
492503
mcp.WithToolAnnotation(mcp.ToolAnnotation{
493504
ReadOnlyHint: toBoolPtr(true),
494505
}),
@@ -501,8 +512,16 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to
501512
mcp.Description("Repository name"),
502513
),
503514
mcp.WithNumber("job_id",
504-
mcp.Required(),
505-
mcp.Description("The unique identifier of the workflow job"),
515+
mcp.Description("The unique identifier of the workflow job (required for single job logs)"),
516+
),
517+
mcp.WithNumber("run_id",
518+
mcp.Description("Workflow run ID (required when using failed_only)"),
519+
),
520+
mcp.WithBoolean("failed_only",
521+
mcp.Description("When true, gets logs for all failed jobs in run_id"),
522+
),
523+
mcp.WithBoolean("return_content",
524+
mcp.Description("Returns actual log content instead of URLs"),
506525
),
507526
),
508527
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -514,38 +533,181 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to
514533
if err != nil {
515534
return mcp.NewToolResultError(err.Error()), nil
516535
}
517-
jobIDInt, err := RequiredInt(request, "job_id")
536+
537+
// Get optional parameters
538+
jobID, err := OptionalIntParam(request, "job_id")
539+
if err != nil {
540+
return mcp.NewToolResultError(err.Error()), nil
541+
}
542+
runID, err := OptionalIntParam(request, "run_id")
543+
if err != nil {
544+
return mcp.NewToolResultError(err.Error()), nil
545+
}
546+
failedOnly, err := OptionalParam[bool](request, "failed_only")
547+
if err != nil {
548+
return mcp.NewToolResultError(err.Error()), nil
549+
}
550+
returnContent, err := OptionalParam[bool](request, "return_content")
518551
if err != nil {
519552
return mcp.NewToolResultError(err.Error()), nil
520553
}
521-
jobID := int64(jobIDInt)
522554

523555
client, err := getClient(ctx)
524556
if err != nil {
525557
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
526558
}
527559

528-
// Get the download URL for the job logs
529-
url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1)
530-
if err != nil {
531-
return nil, fmt.Errorf("failed to get job logs: %w", err)
560+
// Validate parameters
561+
if failedOnly && runID == 0 {
562+
return mcp.NewToolResultError("run_id is required when failed_only is true"), nil
563+
}
564+
if !failedOnly && jobID == 0 {
565+
return mcp.NewToolResultError("job_id is required when failed_only is false"), nil
532566
}
533-
defer func() { _ = resp.Body.Close() }()
534567

535-
// Create response with the logs URL and information
536-
result := map[string]any{
537-
"logs_url": url.String(),
538-
"message": "Job logs are available for download",
539-
"note": "The logs_url provides a download link for the individual job logs in plain text format. This is more targeted than workflow run logs and easier to read for debugging specific failed steps.",
568+
if failedOnly && runID > 0 {
569+
// Handle failed-only mode: get logs for all failed jobs in the workflow run
570+
return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent)
571+
} else if jobID > 0 {
572+
// Handle single job mode
573+
return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent)
540574
}
541575

542-
r, err := json.Marshal(result)
543-
if err != nil {
544-
return nil, fmt.Errorf("failed to marshal response: %w", err)
576+
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
577+
}
578+
}
579+
580+
// handleFailedJobLogs gets logs for all failed jobs in a workflow run
581+
func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool) (*mcp.CallToolResult, error) {
582+
// First, get all jobs for the workflow run
583+
jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{
584+
Filter: "latest",
585+
})
586+
if err != nil {
587+
return mcp.NewToolResultError(fmt.Sprintf("failed to list workflow jobs: %v", err)), nil
588+
}
589+
defer func() { _ = resp.Body.Close() }()
590+
591+
// Filter for failed jobs
592+
var failedJobs []*github.WorkflowJob
593+
for _, job := range jobs.Jobs {
594+
if job.GetConclusion() == "failure" {
595+
failedJobs = append(failedJobs, job)
596+
}
597+
}
598+
599+
if len(failedJobs) == 0 {
600+
result := map[string]any{
601+
"message": "No failed jobs found in this workflow run",
602+
"run_id": runID,
603+
"total_jobs": len(jobs.Jobs),
604+
"failed_jobs": 0,
605+
}
606+
r, _ := json.Marshal(result)
607+
return mcp.NewToolResultText(string(r)), nil
608+
}
609+
610+
// Collect logs for all failed jobs
611+
var logResults []map[string]any
612+
for _, job := range failedJobs {
613+
jobResult, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent)
614+
if err != nil {
615+
// Continue with other jobs even if one fails
616+
jobResult = map[string]any{
617+
"job_id": job.GetID(),
618+
"job_name": job.GetName(),
619+
"error": err.Error(),
545620
}
621+
}
622+
logResults = append(logResults, jobResult)
623+
}
624+
625+
result := map[string]any{
626+
"message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)),
627+
"run_id": runID,
628+
"total_jobs": len(jobs.Jobs),
629+
"failed_jobs": len(failedJobs),
630+
"logs": logResults,
631+
"return_format": map[string]bool{"content": returnContent, "urls": !returnContent},
632+
}
633+
634+
r, err := json.Marshal(result)
635+
if err != nil {
636+
return nil, fmt.Errorf("failed to marshal response: %w", err)
637+
}
638+
639+
return mcp.NewToolResultText(string(r)), nil
640+
}
546641

547-
return mcp.NewToolResultText(string(r)), nil
642+
// handleSingleJobLogs gets logs for a single job
643+
func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool) (*mcp.CallToolResult, error) {
644+
jobResult, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent)
645+
if err != nil {
646+
return mcp.NewToolResultError(err.Error()), nil
647+
}
648+
649+
r, err := json.Marshal(jobResult)
650+
if err != nil {
651+
return nil, fmt.Errorf("failed to marshal response: %w", err)
652+
}
653+
654+
return mcp.NewToolResultText(string(r)), nil
655+
}
656+
657+
// getJobLogData retrieves log data for a single job, either as URL or content
658+
func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool) (map[string]any, error) {
659+
// Get the download URL for the job logs
660+
url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1)
661+
if err != nil {
662+
return nil, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err)
663+
}
664+
defer func() { _ = resp.Body.Close() }()
665+
666+
result := map[string]any{
667+
"job_id": jobID,
668+
}
669+
if jobName != "" {
670+
result["job_name"] = jobName
671+
}
672+
673+
if returnContent {
674+
// Download and return the actual log content
675+
content, err := downloadLogContent(url.String())
676+
if err != nil {
677+
return nil, fmt.Errorf("failed to download log content for job %d: %w", jobID, err)
548678
}
679+
result["logs_content"] = content
680+
result["message"] = "Job logs content retrieved successfully"
681+
} else {
682+
// Return just the URL
683+
result["logs_url"] = url.String()
684+
result["message"] = "Job logs are available for download"
685+
result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content."
686+
}
687+
688+
return result, nil
689+
}
690+
691+
// downloadLogContent downloads the actual log content from a GitHub logs URL
692+
func downloadLogContent(logURL string) (string, error) {
693+
httpResp, err := http.Get(logURL)
694+
if err != nil {
695+
return "", fmt.Errorf("failed to download logs: %w", err)
696+
}
697+
defer func() { _ = httpResp.Body.Close() }()
698+
699+
if httpResp.StatusCode != http.StatusOK {
700+
return "", fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode)
701+
}
702+
703+
content, err := io.ReadAll(httpResp.Body)
704+
if err != nil {
705+
return "", fmt.Errorf("failed to read log content: %w", err)
706+
}
707+
708+
// Clean up and format the log content for better readability
709+
logContent := strings.TrimSpace(string(content))
710+
return logContent, nil
549711
}
550712

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

0 commit comments

Comments
 (0)
0