Skip to content

Commit 2dc74c8

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 a29225d commit 2dc74c8

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)
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