diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 04c6d5c02c1..75e7134023e 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -427,7 +427,7 @@ jobs: --count 100 \ --output ./.cache/gh-aw/activity-report-logs \ --format markdown \ - > ./.cache/gh-aw/activity-report-logs/report.md + --report-file ./.cache/gh-aw/activity-report-logs/report.md - name: Save activity report logs cache if: ${{ always() }} diff --git a/pkg/cli/logs_command.go b/pkg/cli/logs_command.go index 514110610e0..947a1386cae 100644 --- a/pkg/cli/logs_command.go +++ b/pkg/cli/logs_command.go @@ -153,6 +153,7 @@ Downloaded artifacts include (when using --artifacts all): filteredIntegrity, _ := cmd.Flags().GetBool("filtered-integrity") train, _ := cmd.Flags().GetBool("train") format, _ := cmd.Flags().GetString("format") + reportFile, _ := cmd.Flags().GetString("report-file") artifacts, _ := cmd.Flags().GetStringSlice("artifacts") if engine != "" { @@ -164,6 +165,10 @@ Downloaded artifacts include (when using --artifacts all): } } + if err := validateReportFileFlags(reportFile, format, jsonOutput); err != nil { + return err + } + return DownloadWorkflowLogsFromStdin(cmd.Context(), StdinLogsOptions{ RunURLs: runURLs, OutputDir: outputDir, @@ -182,6 +187,7 @@ Downloaded artifacts include (when using --artifacts all): FilteredIntegrity: filteredIntegrity, Train: train, Format: format, + ReportFile: reportFile, ArtifactSets: artifacts, }) } @@ -268,6 +274,7 @@ Downloaded artifacts include (when using --artifacts all): filteredIntegrity, _ := cmd.Flags().GetBool("filtered-integrity") train, _ := cmd.Flags().GetBool("train") format, _ := cmd.Flags().GetString("format") + reportFile, _ := cmd.Flags().GetString("report-file") artifacts, _ := cmd.Flags().GetStringSlice("artifacts") cacheBefore, _ := cmd.Flags().GetString("cache-before") if !cmd.Flags().Changed("cache-before") { @@ -307,6 +314,10 @@ Downloaded artifacts include (when using --artifacts all): } } + if err := validateReportFileFlags(reportFile, format, jsonOutput); err != nil { + return err + } + logsCommandLog.Printf("Executing logs download: workflow=%s, count=%d, engine=%s, train=%v, cache_before=%s", workflowName, count, engine, train, cacheBefore) return DownloadWorkflowLogs(cmd.Context(), LogsDownloadOptions{ @@ -333,6 +344,7 @@ Downloaded artifacts include (when using --artifacts all): FilteredIntegrity: filteredIntegrity, Train: train, Format: format, + ReportFile: reportFile, ArtifactSets: artifacts, After: cacheBefore, }) @@ -361,6 +373,7 @@ Downloaded artifacts include (when using --artifacts all): logsCmd.Flags().String("summary-file", "summary.json", "Path to write the summary JSON file relative to output directory (use empty string to disable)") logsCmd.Flags().Bool("train", false, "Analyze log patterns across downloaded runs and save pattern weights to drain3_weights.json in the output directory") logsCmd.Flags().String("format", "", "Output format: console (decorated tables), tsv (tab-separated), pretty (cross-run report), markdown (cross-run Markdown). Default: compact agent-optimized output") + logsCmd.Flags().String("report-file", "", "Write --format markdown output directly to this file path instead of stdout (creates parent directories as needed)") logsCmd.Flags().Int("last", 0, "Alias for --count: number of recent runs to download") logsCmd.Flags().StringSlice("artifacts", []string{"usage"}, "Artifact sets to download (default: usage). Use 'all' for everything, or comma-separate sets. Valid sets: "+validArtifactSets) logsCmd.Flags().String("cache-before", "", "(Cache eviction) Evict locally cached run folders for runs before this date, prior to downloading. Accepts deltas like -1d, -1w, -1mo (or explicit day counts like -30d), or an absolute date YYYY-MM-DD. Unlike --start-date, this only clears local cache and does not filter which runs are fetched.") @@ -450,3 +463,19 @@ func repoIsLocal(repo string) bool { } return strings.EqualFold(ownerRepo, currentRepo) } + +// validateReportFileFlags returns an error if --report-file is combined with an +// incompatible flag. --report-file only takes effect for --format markdown output +// and is bypassed when --json is set. +func validateReportFileFlags(reportFile, format string, jsonOutput bool) error { + if reportFile == "" { + return nil + } + if format != "markdown" { + return errors.New("--report-file requires --format markdown") + } + if jsonOutput { + return errors.New("--report-file cannot be used with --json") + } + return nil +} diff --git a/pkg/cli/logs_orchestrator.go b/pkg/cli/logs_orchestrator.go index 3c6d82ba3f0..952f689501d 100644 --- a/pkg/cli/logs_orchestrator.go +++ b/pkg/cli/logs_orchestrator.go @@ -61,6 +61,7 @@ type LogsDownloadOptions struct { Format string ArtifactSets []string After string + ReportFile string } func shouldStopPagination(totalFetched, batchSize int) bool { @@ -634,6 +635,7 @@ func DownloadWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) error { outputDir: outputDir, summaryFile: summaryFile, format: format, + reportFile: opts.ReportFile, jsonOutput: jsonOutput, toolGraph: toolGraph, train: train, @@ -648,6 +650,7 @@ type renderLogsOutputOptions struct { outputDir string summaryFile string format string + reportFile string jsonOutput bool toolGraph bool train bool @@ -730,7 +733,31 @@ func renderLogsOutput(processedRuns []ProcessedRun, opts renderLogsOutputOptions renderLogsArtifactHint(os.Stderr, logsData.Message) return nil } - renderCrossRunReportMarkdown(report) + if opts.reportFile != "" { + if err := os.MkdirAll(filepath.Dir(opts.reportFile), constants.DirPermPublic); err != nil { + return fmt.Errorf("failed to create report file directory: %w", err) + } + f, err := os.Create(opts.reportFile) + if err != nil { + return fmt.Errorf("failed to create report file: %w", err) + } + if err := func() (retErr error) { + defer func() { + if cerr := f.Close(); cerr != nil && retErr == nil { + retErr = cerr + } + }() + oldStdout := os.Stdout + defer func() { os.Stdout = oldStdout }() + os.Stdout = f + renderCrossRunReportMarkdown(report) + return nil + }(); err != nil { + return fmt.Errorf("failed to write report file: %w", err) + } + } else { + renderCrossRunReportMarkdown(report) + } renderLogsArtifactHint(os.Stderr, logsData.Message) return nil @@ -794,6 +821,7 @@ type StdinLogsOptions struct { FilteredIntegrity bool Train bool Format string + ReportFile string // ArtifactSets defaults to nil (download all artifacts) when this API is used // programmatically. The CLI passes ["usage"] to match the logs command default. ArtifactSets []string @@ -1110,6 +1138,7 @@ func DownloadWorkflowLogsFromStdin(ctx context.Context, opts StdinLogsOptions) e outputDir: opts.OutputDir, summaryFile: opts.SummaryFile, format: opts.Format, + reportFile: opts.ReportFile, jsonOutput: opts.JSONOutput, toolGraph: opts.ToolGraph, train: opts.Train, diff --git a/pkg/cli/logs_report_file_test.go b/pkg/cli/logs_report_file_test.go new file mode 100644 index 00000000000..4806f07eb1f --- /dev/null +++ b/pkg/cli/logs_report_file_test.go @@ -0,0 +1,45 @@ +//go:build !integration + +package cli + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRenderLogsOutputReportFileWritesMarkdownToFile verifies that when --report-file +// is set with --format markdown, the markdown report is written to the file and +// stdout receives no markdown report content. +func TestRenderLogsOutputReportFileWritesMarkdownToFile(t *testing.T) { + tmpDir := t.TempDir() + reportFile := filepath.Join(tmpDir, "report.md") + + processedRuns := []ProcessedRun{{ + Run: WorkflowRun{ + DatabaseID: 1, + Status: "completed", + WorkflowName: "logs", + CreatedAt: time.Now(), + }, + }} + + stdout, _ := captureOutput(t, func() error { + return renderLogsOutput(processedRuns, renderLogsOutputOptions{ + outputDir: tmpDir, + format: "markdown", + reportFile: reportFile, + artifactFilter: []string{"usage"}, + }) + }) + + assert.NotContains(t, stdout, "# Audit Report", "stdout should not contain the markdown report when --report-file is set") + + content, err := os.ReadFile(reportFile) + require.NoError(t, err, "report file should be created by renderLogsOutput") + assert.Contains(t, string(content), "# Audit Report — Cross-Run Analysis", "report file should contain the markdown report header") +} diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 34ce21c6a73..784ad4f1668 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -511,7 +511,7 @@ jobs: --count 100 \ --output ./.cache/gh-aw/activity-report-logs \ --format markdown \ - > ./.cache/gh-aw/activity-report-logs/report.md + --report-file ./.cache/gh-aw/activity-report-logs/report.md - name: Save activity report logs cache if: ${{ always() }} diff --git a/pkg/workflow/side_repo_maintenance.go b/pkg/workflow/side_repo_maintenance.go index b35c3d5c596..a3c16283cd9 100644 --- a/pkg/workflow/side_repo_maintenance.go +++ b/pkg/workflow/side_repo_maintenance.go @@ -520,7 +520,7 @@ jobs: --count 100 \ --output ./.cache/gh-aw/activity-report-logs \ --format markdown \ - > ./.cache/gh-aw/activity-report-logs/report.md + --report-file ./.cache/gh-aw/activity-report-logs/report.md - name: Save activity report logs cache if: ${{ always() }}