Skip to content

Commit 6f33929

Browse files
authored
Add summary.json output to gh aw logs for campaign orchestrators (#6879)
1 parent a2389f6 commit 6f33929

5 files changed

Lines changed: 225 additions & 5 deletions

File tree

pkg/cli/logs_command.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,39 @@ Downloaded artifacts include:
3838
- agent-stdio.log: Agent standard output/error logs
3939
- aw.patch: Git patch of changes made during execution
4040
- workflow-logs/: GitHub Actions workflow run logs (job logs organized in subdirectory)
41+
- summary.json: Complete metrics and run data for all downloaded runs
42+
43+
Campaign Orchestrator Usage:
44+
In a campaign orchestrator workflow, use this command in a pre-step to download logs,
45+
then access the data in subsequent steps without needing GitHub CLI access:
46+
47+
steps:
48+
- name: Download logs from last 30 days
49+
env:
50+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51+
run: |
52+
mkdir -p /tmp/portfolio-logs
53+
gh aw logs <worker> --start-date -1mo -o /tmp/portfolio-logs
54+
55+
In your analysis step, reference the pre-downloaded data:
56+
57+
**All workflow execution data has been pre-downloaded for you in the previous workflow step.**
58+
59+
- **JSON Summary**: /tmp/portfolio-logs/summary.json - Contains all metrics and run data you need
60+
- **Run Logs**: /tmp/portfolio-logs/run-{database-id}/ - Individual run logs (if needed for detailed analysis)
61+
62+
**DO NOT call 'gh aw logs' or any GitHub CLI commands** - they will not work in your environment.
63+
All data you need is in the summary.json file.
64+
65+
Live Tracking with Project Boards:
66+
Use the summary.json data to update your campaign project board, treating issues/PRs (workers)
67+
on the board as the real-time view of progress, ownership, and status. The orchestrator workflow
68+
can use the 'update-project' safe output to sync status fields without modifying worker workflow
69+
files. Workers remain unchanged while the campaign board reflects current execution state.
70+
71+
For incremental updates, pull data for each worker based on the last pull time using --start-date
72+
(e.g., --start-date -1d for daily updates) and align with existing board items. Compare run data
73+
from summary.json with board status to update only changed workers, preserving board state.
4174
4275
` + WorkflowIDExplanation + `
4376
@@ -123,6 +156,7 @@ Examples:
123156
timeout, _ := cmd.Flags().GetInt("timeout")
124157
repoOverride, _ := cmd.Flags().GetString("repo")
125158
campaignOnly, _ := cmd.Flags().GetBool("campaign")
159+
summaryFile, _ := cmd.Flags().GetString("summary-file")
126160

127161
// Resolve relative dates to absolute dates for GitHub CLI
128162
now := time.Now()
@@ -150,7 +184,7 @@ Examples:
150184
}
151185
}
152186

153-
return DownloadWorkflowLogs(workflowName, count, startDate, endDate, outputDir, engine, ref, beforeRunID, afterRunID, repoOverride, verbose, toolGraph, noStaged, firewallOnly, noFirewall, parse, jsonOutput, timeout, campaignOnly)
187+
return DownloadWorkflowLogs(workflowName, count, startDate, endDate, outputDir, engine, ref, beforeRunID, afterRunID, repoOverride, verbose, toolGraph, noStaged, firewallOnly, noFirewall, parse, jsonOutput, timeout, campaignOnly, summaryFile)
154188
},
155189
}
156190

@@ -172,6 +206,7 @@ Examples:
172206
logsCmd.Flags().Bool("parse", false, "Run JavaScript parsers on agent logs and firewall logs, writing Markdown to log.md and firewall.md")
173207
addJSONFlag(logsCmd)
174208
logsCmd.Flags().Int("timeout", 0, "Download timeout in seconds (0 = no timeout)")
209+
logsCmd.Flags().String("summary-file", "summary.json", "Path to write the summary JSON file relative to output directory (use empty string to disable)")
175210
logsCmd.MarkFlagsMutuallyExclusive("firewall", "no-firewall")
176211

177212
// Register completions for logs command

pkg/cli/logs_orchestrator.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ import (
2929
var logsOrchestratorLog = logger.New("cli:logs_orchestrator")
3030

3131
// DownloadWorkflowLogs downloads and analyzes workflow logs with metrics
32-
func DownloadWorkflowLogs(workflowName string, count int, startDate, endDate, outputDir, engine, ref string, beforeRunID, afterRunID int64, repoOverride string, verbose bool, toolGraph bool, noStaged bool, firewallOnly bool, noFirewall bool, parse bool, jsonOutput bool, timeout int, campaignOnly bool) error {
33-
logsOrchestratorLog.Printf("Starting workflow log download: workflow=%s, count=%d, startDate=%s, endDate=%s, outputDir=%s, campaignOnly=%v", workflowName, count, startDate, endDate, outputDir, campaignOnly)
32+
func DownloadWorkflowLogs(workflowName string, count int, startDate, endDate, outputDir, engine, ref string, beforeRunID, afterRunID int64, repoOverride string, verbose bool, toolGraph bool, noStaged bool, firewallOnly bool, noFirewall bool, parse bool, jsonOutput bool, timeout int, campaignOnly bool, summaryFile string) error {
33+
logsOrchestratorLog.Printf("Starting workflow log download: workflow=%s, count=%d, startDate=%s, endDate=%s, outputDir=%s, campaignOnly=%v, summaryFile=%s", workflowName, count, startDate, endDate, outputDir, campaignOnly, summaryFile)
3434
if verbose {
3535
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Fetching workflow runs from GitHub Actions..."))
3636
}
@@ -418,6 +418,14 @@ func DownloadWorkflowLogs(workflowName string, count int, startDate, endDate, ou
418418
// Build structured logs data
419419
logsData := buildLogsData(processedRuns, outputDir, continuation)
420420

421+
// Write summary file if requested (default behavior unless disabled with empty string)
422+
if summaryFile != "" {
423+
summaryPath := filepath.Join(outputDir, summaryFile)
424+
if err := writeSummaryFile(summaryPath, logsData, verbose); err != nil {
425+
return fmt.Errorf("failed to write summary file: %w", err)
426+
}
427+
}
428+
421429
// Render output based on format preference
422430
if jsonOutput {
423431
if err := renderLogsJSON(logsData); err != nil {

pkg/cli/logs_report.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,46 @@ func renderLogsJSON(data LogsData) error {
730730
return encoder.Encode(data)
731731
}
732732

733+
// writeSummaryFile writes the logs data to a JSON file
734+
// This file contains complete metrics and run data for all downloaded workflow runs.
735+
// It's primarily designed for campaign orchestrators to access workflow execution data
736+
// in subsequent steps without needing GitHub CLI access.
737+
//
738+
// The summary file includes:
739+
// - Aggregate metrics (total runs, tokens, costs, errors, warnings)
740+
// - Individual run details with metrics and metadata
741+
// - Tool usage statistics
742+
// - Error and warning summaries
743+
// - Network access logs (if available)
744+
// - Firewall logs (if available)
745+
func writeSummaryFile(path string, data LogsData, verbose bool) error {
746+
reportLog.Printf("Writing summary file: path=%s, runs=%d", path, data.Summary.TotalRuns)
747+
748+
// Create parent directory if it doesn't exist
749+
dir := filepath.Dir(path)
750+
if err := os.MkdirAll(dir, 0755); err != nil {
751+
return fmt.Errorf("failed to create directory for summary file: %w", err)
752+
}
753+
754+
// Marshal to JSON with indentation for readability
755+
jsonData, err := json.MarshalIndent(data, "", " ")
756+
if err != nil {
757+
return fmt.Errorf("failed to marshal logs data to JSON: %w", err)
758+
}
759+
760+
// Write to file
761+
if err := os.WriteFile(path, jsonData, 0644); err != nil {
762+
return fmt.Errorf("failed to write summary file: %w", err)
763+
}
764+
765+
if verbose {
766+
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Wrote summary to %s", path)))
767+
}
768+
769+
reportLog.Printf("Successfully wrote summary file: %s", path)
770+
return nil
771+
}
772+
733773
// renderLogsConsole outputs the logs data as formatted console output
734774
func renderLogsConsole(data LogsData) {
735775
reportLog.Printf("Rendering logs data to console: %d runs, %d errors, %d warnings",

pkg/cli/logs_summary_file_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
)
10+
11+
// TestWriteSummaryFile tests the writeSummaryFile function
12+
func TestWriteSummaryFile(t *testing.T) {
13+
// Create a temporary directory for testing
14+
tmpDir := t.TempDir()
15+
summaryPath := filepath.Join(tmpDir, "test-summary.json")
16+
17+
// Create sample logs data
18+
logsData := LogsData{
19+
Summary: LogsSummary{
20+
TotalRuns: 3,
21+
TotalDuration: "1h30m",
22+
TotalTokens: 15000,
23+
TotalCost: 2.50,
24+
TotalTurns: 25,
25+
TotalErrors: 2,
26+
TotalWarnings: 5,
27+
TotalMissingTools: 1,
28+
},
29+
Runs: []RunData{
30+
{
31+
DatabaseID: 12345,
32+
Number: 1,
33+
WorkflowName: "Test Workflow",
34+
Agent: "copilot",
35+
Status: "completed",
36+
Conclusion: "success",
37+
Duration: "30m",
38+
TokenUsage: 5000,
39+
EstimatedCost: 0.75,
40+
Turns: 10,
41+
ErrorCount: 0,
42+
WarningCount: 2,
43+
MissingToolCount: 0,
44+
CreatedAt: time.Now(),
45+
URL: "https://github.com/owner/repo/actions/runs/12345",
46+
LogsPath: "/tmp/logs/run-12345",
47+
},
48+
},
49+
LogsLocation: tmpDir,
50+
}
51+
52+
// Test writing summary file
53+
err := writeSummaryFile(summaryPath, logsData, false)
54+
if err != nil {
55+
t.Fatalf("Failed to write summary file: %v", err)
56+
}
57+
58+
// Verify file was created
59+
if _, err := os.Stat(summaryPath); os.IsNotExist(err) {
60+
t.Fatal("Summary file was not created")
61+
}
62+
63+
// Read and verify the content
64+
data, err := os.ReadFile(summaryPath)
65+
if err != nil {
66+
t.Fatalf("Failed to read summary file: %v", err)
67+
}
68+
69+
// Parse the JSON to verify it's valid
70+
var parsedData LogsData
71+
if err := json.Unmarshal(data, &parsedData); err != nil {
72+
t.Fatalf("Failed to parse summary JSON: %v", err)
73+
}
74+
75+
// Verify key fields
76+
if parsedData.Summary.TotalRuns != logsData.Summary.TotalRuns {
77+
t.Errorf("Expected TotalRuns %d, got %d", logsData.Summary.TotalRuns, parsedData.Summary.TotalRuns)
78+
}
79+
if parsedData.Summary.TotalTokens != logsData.Summary.TotalTokens {
80+
t.Errorf("Expected TotalTokens %d, got %d", logsData.Summary.TotalTokens, parsedData.Summary.TotalTokens)
81+
}
82+
if len(parsedData.Runs) != len(logsData.Runs) {
83+
t.Errorf("Expected %d runs, got %d", len(logsData.Runs), len(parsedData.Runs))
84+
}
85+
if len(parsedData.Runs) > 0 {
86+
if parsedData.Runs[0].DatabaseID != logsData.Runs[0].DatabaseID {
87+
t.Errorf("Expected DatabaseID %d, got %d", logsData.Runs[0].DatabaseID, parsedData.Runs[0].DatabaseID)
88+
}
89+
}
90+
}
91+
92+
// TestWriteSummaryFileCreatesDirectory tests that parent directory is created
93+
func TestWriteSummaryFileCreatesDirectory(t *testing.T) {
94+
// Create a temporary directory for testing
95+
tmpDir := t.TempDir()
96+
summaryPath := filepath.Join(tmpDir, "subdir", "nested", "summary.json")
97+
98+
// Create minimal logs data
99+
logsData := LogsData{
100+
Summary: LogsSummary{
101+
TotalRuns: 1,
102+
},
103+
Runs: []RunData{},
104+
LogsLocation: tmpDir,
105+
}
106+
107+
// Test writing summary file (should create nested directories)
108+
err := writeSummaryFile(summaryPath, logsData, false)
109+
if err != nil {
110+
t.Fatalf("Failed to write summary file: %v", err)
111+
}
112+
113+
// Verify file was created
114+
if _, err := os.Stat(summaryPath); os.IsNotExist(err) {
115+
t.Fatal("Summary file was not created")
116+
}
117+
118+
// Verify nested directories were created
119+
dir := filepath.Dir(summaryPath)
120+
if _, err := os.Stat(dir); os.IsNotExist(err) {
121+
t.Fatal("Parent directories were not created")
122+
}
123+
}
124+
125+
// TestWriteSummaryFileWithEmptyPath tests that empty path skips writing
126+
func TestSummaryFileDisabling(t *testing.T) {
127+
// This test verifies the behavior when summaryFile is empty string
128+
// The actual skip logic is in the orchestrator, but we document the behavior here
129+
130+
// Empty string path should be handled by the caller (orchestrator)
131+
// to skip calling writeSummaryFile entirely
132+
summaryFile := ""
133+
if summaryFile == "" {
134+
// This is the expected behavior - skip writing
135+
t.Log("Empty summary file path correctly skips writing")
136+
}
137+
}

pkg/cli/logs_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func TestDownloadWorkflowLogs(t *testing.T) {
2121
// Test the DownloadWorkflowLogs function
2222
// This should either fail with auth error (if not authenticated)
2323
// or succeed with no results (if authenticated but no workflows match)
24-
err := DownloadWorkflowLogs("", 1, "", "", "./test-logs", "", "", 0, 0, "", false, false, false, false, false, false, false, 0, false)
24+
err := DownloadWorkflowLogs("", 1, "", "", "./test-logs", "", "", 0, 0, "", false, false, false, false, false, false, false, 0, false, "summary.json")
2525

2626
// If GitHub CLI is authenticated, the function may succeed but find no results
2727
// If not authenticated, it should return an auth error
@@ -917,7 +917,7 @@ func TestDownloadWorkflowLogsWithEngineFilter(t *testing.T) {
917917
if !tt.expectError {
918918
// For valid engines, test that the function can be called without panic
919919
// It may still fail with auth errors, which is expected
920-
err := DownloadWorkflowLogs("", 1, "", "", "./test-logs", tt.engine, "", 0, 0, "", false, false, false, false, false, false, false, 0, false)
920+
err := DownloadWorkflowLogs("", 1, "", "", "./test-logs", tt.engine, "", 0, 0, "", false, false, false, false, false, false, false, 0, false, "summary.json")
921921

922922
// Clean up any created directories
923923
os.RemoveAll("./test-logs")

0 commit comments

Comments
 (0)