From b4b03368b38df8cf5a5e92cf799cefb7ea4658eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:52:50 +0000 Subject: [PATCH 1/2] Initial plan From 2fe17729906310268451e2f7fefca70bd288fe7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:43:09 +0000 Subject: [PATCH 2/2] refactor: split large files to address architecture violations - Split gateway_logs.go into parser/mcp/policy sub-files - Split audit_report_render.go into tools/performance/policy/summary sub-files - Split compiler_safe_outputs_config.go handlers into domain-specific init() files - Introduce DownloadConfig struct to replace 25-parameter DownloadWorkflowLogs signature - Split logs_report.go into tools/mcp/domains sub-files - Split logs_orchestrator.go: extract downloadRunArtifactsConcurrent and filter functions Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1bd66d8e-d83e-4118-8753-9b558e2447d0 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/audit_report_render.go | 876 ------------------ pkg/cli/audit_report_render_performance.go | 129 +++ pkg/cli/audit_report_render_policy.go | 247 +++++ pkg/cli/audit_report_render_summary.go | 347 +++++++ pkg/cli/audit_report_render_tools.go | 209 +++++ pkg/cli/context_cancellation_test.go | 13 +- pkg/cli/gateway_logs.go | 754 +-------------- pkg/cli/gateway_logs_mcp.go | 208 +++++ pkg/cli/gateway_logs_parser.go | 498 ++++++++++ pkg/cli/gateway_logs_policy.go | 71 ++ pkg/cli/logs_ci_scenario_test.go | 36 +- pkg/cli/logs_command.go | 27 +- pkg/cli/logs_download_config.go | 82 ++ pkg/cli/logs_download_filter.go | 92 ++ pkg/cli/logs_download_runner.go | 415 +++++++++ pkg/cli/logs_download_test.go | 15 +- pkg/cli/logs_json_stderr_order_test.go | 72 +- pkg/cli/logs_orchestrator.go | 505 +--------- pkg/cli/logs_report.go | 590 ------------ pkg/cli/logs_report_domains.go | 169 ++++ pkg/cli/logs_report_mcp.go | 152 +++ pkg/cli/logs_report_tools.go | 304 ++++++ pkg/workflow/compiler_safe_outputs_config.go | 786 +--------------- ...compiler_safe_outputs_handlers_dispatch.go | 76 ++ .../compiler_safe_outputs_handlers_issue.go | 375 ++++++++ .../compiler_safe_outputs_handlers_misc.go | 203 ++++ .../compiler_safe_outputs_handlers_pr.go | 207 +++++ 27 files changed, 3907 insertions(+), 3551 deletions(-) create mode 100644 pkg/cli/audit_report_render_performance.go create mode 100644 pkg/cli/audit_report_render_policy.go create mode 100644 pkg/cli/audit_report_render_summary.go create mode 100644 pkg/cli/audit_report_render_tools.go create mode 100644 pkg/cli/gateway_logs_mcp.go create mode 100644 pkg/cli/gateway_logs_parser.go create mode 100644 pkg/cli/gateway_logs_policy.go create mode 100644 pkg/cli/logs_download_config.go create mode 100644 pkg/cli/logs_download_filter.go create mode 100644 pkg/cli/logs_download_runner.go create mode 100644 pkg/cli/logs_report_domains.go create mode 100644 pkg/cli/logs_report_mcp.go create mode 100644 pkg/cli/logs_report_tools.go create mode 100644 pkg/workflow/compiler_safe_outputs_handlers_dispatch.go create mode 100644 pkg/workflow/compiler_safe_outputs_handlers_issue.go create mode 100644 pkg/workflow/compiler_safe_outputs_handlers_misc.go create mode 100644 pkg/workflow/compiler_safe_outputs_handlers_pr.go diff --git a/pkg/cli/audit_report_render.go b/pkg/cli/audit_report_render.go index d59cb602cbf..bfdcfb4ff10 100644 --- a/pkg/cli/audit_report_render.go +++ b/pkg/cli/audit_report_render.go @@ -3,18 +3,10 @@ package cli import ( "encoding/json" "fmt" - "math" "os" "path/filepath" - "sort" - "strconv" - "strings" - "time" "github.com/github/gh-aw/pkg/console" - "github.com/github/gh-aw/pkg/sliceutil" - "github.com/github/gh-aw/pkg/stringutil" - "github.com/github/gh-aw/pkg/timeutil" ) // renderJSON outputs the audit data as JSON @@ -269,871 +261,3 @@ func renderConsole(data AuditData, logsPath string) { fmt.Fprintf(os.Stderr, " %s\n", absPath) fmt.Fprintln(os.Stderr) } - -func renderAuditComparison(comparison *AuditComparisonData) { - if comparison == nil { - return - } - - if !comparison.BaselineFound || comparison.Baseline == nil || comparison.Delta == nil || comparison.Classification == nil { - fmt.Fprintln(os.Stderr, " No suitable successful run was available for baseline comparison.") - fmt.Fprintln(os.Stderr) - return - } - - fmt.Fprintf(os.Stderr, " Baseline: run %d", comparison.Baseline.RunID) - if comparison.Baseline.Conclusion != "" { - fmt.Fprintf(os.Stderr, " (%s)", comparison.Baseline.Conclusion) - } - fmt.Fprintln(os.Stderr) - if comparison.Baseline.Selection != "" { - fmt.Fprintf(os.Stderr, " Selection: %s\n", strings.ReplaceAll(comparison.Baseline.Selection, "_", " ")) - } - if len(comparison.Baseline.MatchedOn) > 0 { - fmt.Fprintf(os.Stderr, " Matched on: %s\n", strings.Join(comparison.Baseline.MatchedOn, ", ")) - } - fmt.Fprintf(os.Stderr, " Classification: %s\n", comparison.Classification.Label) - fmt.Fprintln(os.Stderr, " Changes:") - - if comparison.Delta.Turns.Changed { - fmt.Fprintf(os.Stderr, " - Turns: %d -> %d\n", comparison.Delta.Turns.Before, comparison.Delta.Turns.After) - } - if comparison.Delta.Posture.Changed { - fmt.Fprintf(os.Stderr, " - Posture: %s -> %s\n", comparison.Delta.Posture.Before, comparison.Delta.Posture.After) - } - if comparison.Delta.BlockedRequests.Changed { - fmt.Fprintf(os.Stderr, " - Blocked requests: %d -> %d\n", comparison.Delta.BlockedRequests.Before, comparison.Delta.BlockedRequests.After) - } - if comparison.Delta.MCPFailure != nil && comparison.Delta.MCPFailure.NewlyPresent { - fmt.Fprintf(os.Stderr, " - New MCP failure: %s\n", strings.Join(comparison.Delta.MCPFailure.After, ", ")) - } - if len(comparison.Classification.ReasonCodes) == 0 { - fmt.Fprintln(os.Stderr, " - No meaningful behavior change from the selected successful baseline") - } - if comparison.Recommendation != nil && comparison.Recommendation.Action != "" { - fmt.Fprintf(os.Stderr, " Recommended action: %s\n", comparison.Recommendation.Action) - } - fmt.Fprintln(os.Stderr) -} - -// renderOverview renders the overview section using the new rendering system -func renderOverview(overview OverviewData) { - // Format Status with optional Conclusion - statusLine := overview.Status - if overview.Conclusion != "" && overview.Status == "completed" { - statusLine = fmt.Sprintf("%s (%s)", overview.Status, overview.Conclusion) - } - - display := OverviewDisplay{ - RunID: overview.RunID, - Workflow: overview.WorkflowName, - Status: statusLine, - Duration: overview.Duration, - Event: overview.Event, - Branch: overview.Branch, - URL: overview.URL, - Files: overview.LogsPath, - } - - fmt.Fprint(os.Stderr, console.RenderStruct(display)) -} - -// renderMetrics renders the metrics section using the new rendering system -func renderMetrics(metrics MetricsData) { - fmt.Fprint(os.Stderr, console.RenderStruct(metrics)) -} - -type taskDomainDisplay struct { - Domain string `console:"header:Domain"` - Reason string `console:"header:Reason"` -} - -type behaviorFingerprintDisplay struct { - Execution string `console:"header:Execution"` - Tools string `console:"header:Tools"` - Actuation string `console:"header:Actuation"` - Resource string `console:"header:Resources"` - Dispatch string `console:"header:Dispatch"` -} - -func renderTaskDomain(domain *TaskDomainInfo) { - if domain == nil { - return - } - fmt.Fprint(os.Stderr, console.RenderStruct(taskDomainDisplay{ - Domain: domain.Label, - Reason: domain.Reason, - })) -} - -func renderBehaviorFingerprint(fingerprint *BehaviorFingerprint) { - if fingerprint == nil { - return - } - fmt.Fprint(os.Stderr, console.RenderStruct(behaviorFingerprintDisplay{ - Execution: fingerprint.ExecutionStyle, - Tools: fingerprint.ToolBreadth, - Actuation: fingerprint.ActuationStyle, - Resource: fingerprint.ResourceProfile, - Dispatch: fingerprint.DispatchMode, - })) -} - -func renderAgenticAssessments(assessments []AgenticAssessment) { - for _, assessment := range assessments { - severity := strings.ToUpper(assessment.Severity) - fmt.Fprintf(os.Stderr, " [%s] %s\n", severity, assessment.Summary) - if assessment.Evidence != "" { - fmt.Fprintf(os.Stderr, " Evidence: %s\n", assessment.Evidence) - } - if assessment.Recommendation != "" { - fmt.Fprintf(os.Stderr, " Recommendation: %s\n", assessment.Recommendation) - } - fmt.Fprintln(os.Stderr) - } -} - -// renderJobsTable renders the jobs as a table using console.RenderTable -func renderJobsTable(jobs []JobData) { - auditReportLog.Printf("Rendering jobs table with %d jobs", len(jobs)) - config := console.TableConfig{ - Headers: []string{"Name", "Status", "Conclusion", "Duration"}, - Rows: make([][]string, 0, len(jobs)), - } - - for _, job := range jobs { - conclusion := job.Conclusion - if conclusion == "" { - conclusion = "-" - } - duration := job.Duration - if duration == "" { - duration = "-" - } - - row := []string{ - stringutil.Truncate(job.Name, 40), - job.Status, - conclusion, - duration, - } - config.Rows = append(config.Rows, row) - } - - fmt.Fprint(os.Stderr, console.RenderTable(config)) -} - -// renderToolUsageTable renders tool usage as a table with custom formatting -func renderToolUsageTable(toolUsage []ToolUsageInfo) { - auditReportLog.Printf("Rendering tool usage table with %d tools", len(toolUsage)) - config := console.TableConfig{ - Headers: []string{"Tool", "Calls", "Max Input", "Max Output", "Max Duration"}, - Rows: make([][]string, 0, len(toolUsage)), - } - - for _, tool := range toolUsage { - inputStr := "N/A" - if tool.MaxInputSize > 0 { - inputStr = console.FormatNumber(tool.MaxInputSize) - } - outputStr := "N/A" - if tool.MaxOutputSize > 0 { - outputStr = console.FormatNumber(tool.MaxOutputSize) - } - durationStr := "N/A" - if tool.MaxDuration != "" { - durationStr = tool.MaxDuration - } - - row := []string{ - stringutil.Truncate(tool.Name, 40), - strconv.Itoa(tool.CallCount), - inputStr, - outputStr, - durationStr, - } - config.Rows = append(config.Rows, row) - } - - fmt.Fprint(os.Stderr, console.RenderTable(config)) -} - -// renderMCPToolUsageTable renders MCP tool usage with detailed statistics -func renderMCPToolUsageTable(mcpData *MCPToolUsageData) { - auditReportLog.Printf("Rendering MCP tool usage table with %d tools", len(mcpData.Summary)) - - // Render server-level statistics first - if len(mcpData.Servers) > 0 { - fmt.Fprintln(os.Stderr, " Server Statistics:") - fmt.Fprintln(os.Stderr) - - serverConfig := console.TableConfig{ - Headers: []string{"Server", "Requests", "Tool Calls", "Total Input", "Total Output", "Avg Duration", "Errors"}, - Rows: make([][]string, 0, len(mcpData.Servers)), - } - - for _, server := range mcpData.Servers { - inputStr := console.FormatFileSize(int64(server.TotalInputSize)) - outputStr := console.FormatFileSize(int64(server.TotalOutputSize)) - durationStr := server.AvgDuration - if durationStr == "" { - durationStr = "N/A" - } - errorStr := strconv.Itoa(server.ErrorCount) - if server.ErrorCount == 0 { - errorStr = "-" - } - - row := []string{ - stringutil.Truncate(server.ServerName, 25), - strconv.Itoa(server.RequestCount), - strconv.Itoa(server.ToolCallCount), - inputStr, - outputStr, - durationStr, - errorStr, - } - serverConfig.Rows = append(serverConfig.Rows, row) - } - - fmt.Fprint(os.Stderr, console.RenderTable(serverConfig)) - fmt.Fprintln(os.Stderr) - } - - // Render tool-level statistics - if len(mcpData.Summary) > 0 { - fmt.Fprintln(os.Stderr, " Tool Statistics:") - fmt.Fprintln(os.Stderr) - - toolConfig := console.TableConfig{ - Headers: []string{"Server", "Tool", "Calls", "Total In", "Total Out", "Max In", "Max Out"}, - Rows: make([][]string, 0, len(mcpData.Summary)), - } - - for _, tool := range mcpData.Summary { - totalInStr := console.FormatFileSize(int64(tool.TotalInputSize)) - totalOutStr := console.FormatFileSize(int64(tool.TotalOutputSize)) - maxInStr := console.FormatFileSize(int64(tool.MaxInputSize)) - maxOutStr := console.FormatFileSize(int64(tool.MaxOutputSize)) - - row := []string{ - stringutil.Truncate(tool.ServerName, 20), - stringutil.Truncate(tool.ToolName, 30), - strconv.Itoa(tool.CallCount), - totalInStr, - totalOutStr, - maxInStr, - maxOutStr, - } - toolConfig.Rows = append(toolConfig.Rows, row) - } - - fmt.Fprint(os.Stderr, console.RenderTable(toolConfig)) - } - - // Render guard policy summary - if mcpData.GuardPolicySummary != nil && mcpData.GuardPolicySummary.TotalBlocked > 0 { - renderGuardPolicySummary(mcpData.GuardPolicySummary) - } -} - -// renderGuardPolicySummary renders the guard policy enforcement summary -func renderGuardPolicySummary(summary *GuardPolicySummary) { - auditReportLog.Printf("Rendering guard policy summary: %d total blocked", summary.TotalBlocked) - - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, console.FormatWarningMessage( - fmt.Sprintf("Guard Policy: %d tool call(s) blocked", summary.TotalBlocked))) - fmt.Fprintln(os.Stderr) - - // Breakdown by reason - fmt.Fprintln(os.Stderr, " Block Reasons:") - if summary.IntegrityBlocked > 0 { - fmt.Fprintf(os.Stderr, " Integrity below minimum : %d\n", summary.IntegrityBlocked) - } - if summary.RepoScopeBlocked > 0 { - fmt.Fprintf(os.Stderr, " Repository not allowed : %d\n", summary.RepoScopeBlocked) - } - if summary.AccessDenied > 0 { - fmt.Fprintf(os.Stderr, " Access denied : %d\n", summary.AccessDenied) - } - if summary.BlockedUserDenied > 0 { - fmt.Fprintf(os.Stderr, " Blocked user : %d\n", summary.BlockedUserDenied) - } - if summary.PermissionDenied > 0 { - fmt.Fprintf(os.Stderr, " Insufficient permissions: %d\n", summary.PermissionDenied) - } - if summary.PrivateRepoDenied > 0 { - fmt.Fprintf(os.Stderr, " Private repo denied : %d\n", summary.PrivateRepoDenied) - } - fmt.Fprintln(os.Stderr) - - // Most frequently blocked tools - if len(summary.BlockedToolCounts) > 0 { - toolNames := sliceutil.MapToSlice(summary.BlockedToolCounts) - sort.Slice(toolNames, func(i, j int) bool { - return summary.BlockedToolCounts[toolNames[i]] > summary.BlockedToolCounts[toolNames[j]] - }) - - toolRows := make([][]string, 0, len(toolNames)) - for _, name := range toolNames { - toolRows = append(toolRows, []string{name, strconv.Itoa(summary.BlockedToolCounts[name])}) - } - fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ - Title: "Most Blocked Tools", - Headers: []string{"Tool", "Blocked"}, - Rows: toolRows, - })) - } - - // Guard policy event details - if len(summary.Events) > 0 { - fmt.Fprintln(os.Stderr) - eventRows := make([][]string, 0, len(summary.Events)) - for _, evt := range summary.Events { - message := evt.Message - if len(message) > 60 { - message = message[:57] + "..." - } - repo := evt.Repository - if repo == "" { - repo = "-" - } - eventRows = append(eventRows, []string{ - stringutil.Truncate(evt.ServerID, 20), - stringutil.Truncate(evt.ToolName, 25), - evt.Reason, - message, - repo, - }) - } - fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ - Title: "Guard Policy Events", - Headers: []string{"Server", "Tool", "Reason", "Message", "Repository"}, - Rows: eventRows, - })) - } -} - -// renderFirewallAnalysis renders firewall analysis with summary and domain breakdown -func renderFirewallAnalysis(analysis *FirewallAnalysis) { - auditReportLog.Printf("Rendering firewall analysis: total=%d, allowed=%d, blocked=%d, allowed_domains=%d, blocked_domains=%d", - analysis.TotalRequests, analysis.AllowedRequests, analysis.BlockedRequests, len(analysis.AllowedDomains), len(analysis.BlockedDomains)) - // Summary statistics - fmt.Fprintf(os.Stderr, " Total Requests : %d\n", analysis.TotalRequests) - fmt.Fprintf(os.Stderr, " Allowed : %d\n", analysis.AllowedRequests) - fmt.Fprintf(os.Stderr, " Blocked : %d\n", analysis.BlockedRequests) - fmt.Fprintln(os.Stderr) - - // Allowed domains - if len(analysis.AllowedDomains) > 0 { - fmt.Fprintln(os.Stderr, " Allowed Domains:") - for _, domain := range analysis.AllowedDomains { - if stats, ok := analysis.RequestsByDomain[domain]; ok { - fmt.Fprintf(os.Stderr, " ✓ %s (%d requests)\n", domain, stats.Allowed) - } - } - fmt.Fprintln(os.Stderr) - } - - // Blocked domains - if len(analysis.BlockedDomains) > 0 { - fmt.Fprintln(os.Stderr, " Blocked Domains:") - for _, domain := range analysis.BlockedDomains { - if stats, ok := analysis.RequestsByDomain[domain]; ok { - fmt.Fprintf(os.Stderr, " ✗ %s (%d requests)\n", domain, stats.Blocked) - } - } - fmt.Fprintln(os.Stderr) - } -} - -// renderRedactedDomainsAnalysis renders redacted domains analysis -func renderRedactedDomainsAnalysis(analysis *RedactedDomainsAnalysis) { - auditReportLog.Printf("Rendering redacted domains analysis: total_domains=%d", analysis.TotalDomains) - // Summary statistics - fmt.Fprintf(os.Stderr, " Total Domains Redacted: %d\n", analysis.TotalDomains) - fmt.Fprintln(os.Stderr) - - // List domains - if len(analysis.Domains) > 0 { - fmt.Fprintln(os.Stderr, " Redacted Domains:") - for _, domain := range analysis.Domains { - fmt.Fprintf(os.Stderr, " 🔒 %s\n", domain) - } - fmt.Fprintln(os.Stderr) - } -} - -// renderCreatedItemsTable renders the list of items created in GitHub by safe output handlers -// as a table with clickable URLs for easy auditing. -func renderCreatedItemsTable(items []CreatedItemReport) { - auditReportLog.Printf("Rendering created items table with %d item(s)", len(items)) - config := console.TableConfig{ - Headers: []string{"Type", "Repo", "Number", "Temp ID", "URL"}, - Rows: make([][]string, 0, len(items)), - } - - for _, item := range items { - numberStr := "" - if item.Number > 0 { - numberStr = strconv.Itoa(item.Number) - } - - row := []string{ - item.Type, - item.Repo, - numberStr, - item.TemporaryID, - item.URL, - } - config.Rows = append(config.Rows, row) - } - - fmt.Fprint(os.Stderr, console.RenderTable(config)) - fmt.Fprintln(os.Stderr) -} - -// renderKeyFindings renders key findings with colored severity indicators -func renderKeyFindings(findings []Finding) { - auditReportLog.Printf("Rendering key findings: total=%d", len(findings)) - // Group findings by severity for better presentation - critical := sliceutil.Filter(findings, func(f Finding) bool { return f.Severity == "critical" }) - high := sliceutil.Filter(findings, func(f Finding) bool { return f.Severity == "high" }) - medium := sliceutil.Filter(findings, func(f Finding) bool { return f.Severity == "medium" }) - low := sliceutil.Filter(findings, func(f Finding) bool { return f.Severity == "low" }) - info := sliceutil.Filter(findings, func(f Finding) bool { - return f.Severity != "critical" && f.Severity != "high" && f.Severity != "medium" && f.Severity != "low" - }) - - // Render critical findings first - for _, finding := range critical { - fmt.Fprintf(os.Stderr, " 🔴 %s [%s]\n", console.FormatErrorMessage(finding.Title), finding.Category) - fmt.Fprintf(os.Stderr, " %s\n", finding.Description) - if finding.Impact != "" { - fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) - } - fmt.Fprintln(os.Stderr) - } - - // Then high severity - for _, finding := range high { - fmt.Fprintf(os.Stderr, " 🟠 %s [%s]\n", console.FormatWarningMessage(finding.Title), finding.Category) - fmt.Fprintf(os.Stderr, " %s\n", finding.Description) - if finding.Impact != "" { - fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) - } - fmt.Fprintln(os.Stderr) - } - - // Medium severity - for _, finding := range medium { - fmt.Fprintf(os.Stderr, " 🟡 %s [%s]\n", finding.Title, finding.Category) - fmt.Fprintf(os.Stderr, " %s\n", finding.Description) - if finding.Impact != "" { - fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) - } - fmt.Fprintln(os.Stderr) - } - - // Low severity - for _, finding := range low { - fmt.Fprintf(os.Stderr, " â„šī¸ %s [%s]\n", finding.Title, finding.Category) - fmt.Fprintf(os.Stderr, " %s\n", finding.Description) - if finding.Impact != "" { - fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) - } - fmt.Fprintln(os.Stderr) - } - - // Info findings - for _, finding := range info { - fmt.Fprintf(os.Stderr, " ✅ %s [%s]\n", console.FormatSuccessMessage(finding.Title), finding.Category) - fmt.Fprintf(os.Stderr, " %s\n", finding.Description) - if finding.Impact != "" { - fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) - } - fmt.Fprintln(os.Stderr) - } -} - -// renderRecommendations renders actionable recommendations -func renderRecommendations(recommendations []Recommendation) { - auditReportLog.Printf("Rendering recommendations: total=%d", len(recommendations)) - // Group by priority - high := sliceutil.Filter(recommendations, func(r Recommendation) bool { return r.Priority == "high" }) - medium := sliceutil.Filter(recommendations, func(r Recommendation) bool { return r.Priority == "medium" }) - low := sliceutil.Filter(recommendations, func(r Recommendation) bool { return r.Priority != "high" && r.Priority != "medium" }) - - // Render high priority first - for i, rec := range high { - fmt.Fprintf(os.Stderr, " %d. [HIGH] %s\n", i+1, console.FormatWarningMessage(rec.Action)) - fmt.Fprintf(os.Stderr, " Reason: %s\n", rec.Reason) - if rec.Example != "" { - fmt.Fprintf(os.Stderr, " Example: %s\n", rec.Example) - } - fmt.Fprintln(os.Stderr) - } - - // Medium priority - startIdx := len(high) + 1 - for i, rec := range medium { - fmt.Fprintf(os.Stderr, " %d. [MEDIUM] %s\n", startIdx+i, rec.Action) - fmt.Fprintf(os.Stderr, " Reason: %s\n", rec.Reason) - if rec.Example != "" { - fmt.Fprintf(os.Stderr, " Example: %s\n", rec.Example) - } - fmt.Fprintln(os.Stderr) - } - - // Low priority - startIdx += len(medium) - for i, rec := range low { - fmt.Fprintf(os.Stderr, " %d. [LOW] %s\n", startIdx+i, rec.Action) - fmt.Fprintf(os.Stderr, " Reason: %s\n", rec.Reason) - if rec.Example != "" { - fmt.Fprintf(os.Stderr, " Example: %s\n", rec.Example) - } - fmt.Fprintln(os.Stderr) - } -} - -// renderPerformanceMetrics renders performance metrics -func renderPerformanceMetrics(metrics *PerformanceMetrics) { - auditReportLog.Printf("Rendering performance metrics: tokens_per_min=%.1f, cost_efficiency=%s, most_used_tool=%s", - metrics.TokensPerMinute, metrics.CostEfficiency, metrics.MostUsedTool) - if metrics.TokensPerMinute > 0 { - fmt.Fprintf(os.Stderr, " Tokens per Minute: %.1f\n", metrics.TokensPerMinute) - } - - if metrics.CostEfficiency != "" { - efficiencyDisplay := metrics.CostEfficiency - switch metrics.CostEfficiency { - case "excellent", "good": - efficiencyDisplay = console.FormatSuccessMessage(metrics.CostEfficiency) - case "moderate": - efficiencyDisplay = console.FormatWarningMessage(metrics.CostEfficiency) - case "poor": - efficiencyDisplay = console.FormatErrorMessage(metrics.CostEfficiency) - } - fmt.Fprintf(os.Stderr, " Cost Efficiency: %s\n", efficiencyDisplay) - } - - if metrics.AvgToolDuration != "" { - fmt.Fprintf(os.Stderr, " Average Tool Duration: %s\n", metrics.AvgToolDuration) - } - - if metrics.MostUsedTool != "" { - fmt.Fprintf(os.Stderr, " Most Used Tool: %s\n", metrics.MostUsedTool) - } - - if metrics.NetworkRequests > 0 { - fmt.Fprintf(os.Stderr, " Network Requests: %d\n", metrics.NetworkRequests) - } - - fmt.Fprintln(os.Stderr) -} - -// renderPolicyAnalysis renders the enriched firewall policy analysis with rule attribution -func renderPolicyAnalysis(analysis *PolicyAnalysis) { - auditReportLog.Printf("Rendering policy analysis: rules=%d, denied=%d", len(analysis.RuleHits), analysis.DeniedCount) - - // Policy summary using RenderStruct - display := PolicySummaryDisplay{ - Policy: analysis.PolicySummary, - TotalRequests: analysis.TotalRequests, - Allowed: analysis.AllowedCount, - Denied: analysis.DeniedCount, - UniqueDomains: analysis.UniqueDomains, - } - fmt.Fprint(os.Stderr, console.RenderStruct(display)) - fmt.Fprintln(os.Stderr) - - // Rule hit table - if len(analysis.RuleHits) > 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Policy Rules:")) - fmt.Fprintln(os.Stderr) - - ruleConfig := console.TableConfig{ - Headers: []string{"Rule", "Action", "Description", "Hits"}, - Rows: make([][]string, 0, len(analysis.RuleHits)), - } - - for _, rh := range analysis.RuleHits { - row := []string{ - stringutil.Truncate(rh.Rule.ID, 30), - rh.Rule.Action, - stringutil.Truncate(rh.Rule.Description, 50), - strconv.Itoa(rh.Hits), - } - ruleConfig.Rows = append(ruleConfig.Rows, row) - } - - fmt.Fprint(os.Stderr, console.RenderTable(ruleConfig)) - fmt.Fprintln(os.Stderr) - } - - // Denied requests detail - if len(analysis.DeniedRequests) > 0 { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Denied Requests (%d):", len(analysis.DeniedRequests)))) - fmt.Fprintln(os.Stderr) - - deniedConfig := console.TableConfig{ - Headers: []string{"Time", "Domain", "Rule", "Reason"}, - Rows: make([][]string, 0, len(analysis.DeniedRequests)), - } - - for _, req := range analysis.DeniedRequests { - timeStr := formatUnixTimestamp(req.Timestamp) - row := []string{ - timeStr, - stringutil.Truncate(req.Host, 40), - stringutil.Truncate(req.RuleID, 25), - stringutil.Truncate(req.Reason, 40), - } - deniedConfig.Rows = append(deniedConfig.Rows, row) - } - - fmt.Fprint(os.Stderr, console.RenderTable(deniedConfig)) - fmt.Fprintln(os.Stderr) - } -} - -// formatUnixTimestamp converts a Unix timestamp (float64) to a human-readable time string (HH:MM:SS). -func formatUnixTimestamp(ts float64) string { - if ts <= 0 { - return "-" - } - sec := int64(math.Floor(ts)) - nsec := int64((ts - float64(sec)) * 1e9) - t := time.Unix(sec, nsec).UTC() - return t.Format("15:04:05") -} - -// renderEngineConfig renders engine configuration details -func renderEngineConfig(config *EngineConfig) { - if config == nil { - return - } - fmt.Fprintf(os.Stderr, " Engine ID: %s\n", config.EngineID) - if config.EngineName != "" { - fmt.Fprintf(os.Stderr, " Engine Name: %s\n", config.EngineName) - } - if config.Model != "" { - fmt.Fprintf(os.Stderr, " Model: %s\n", config.Model) - } - if config.Version != "" { - fmt.Fprintf(os.Stderr, " Version: %s\n", config.Version) - } - if config.CLIVersion != "" { - fmt.Fprintf(os.Stderr, " CLI Version: %s\n", config.CLIVersion) - } - if config.FirewallVersion != "" { - fmt.Fprintf(os.Stderr, " Firewall Version: %s\n", config.FirewallVersion) - } - if config.TriggerEvent != "" { - fmt.Fprintf(os.Stderr, " Trigger Event: %s\n", config.TriggerEvent) - } - if config.Repository != "" { - fmt.Fprintf(os.Stderr, " Repository: %s\n", config.Repository) - } - if len(config.MCPServers) > 0 { - fmt.Fprintf(os.Stderr, " MCP Servers: %s\n", strings.Join(config.MCPServers, ", ")) - } - fmt.Fprintln(os.Stderr) -} - -// renderPromptAnalysis renders prompt analysis metrics -func renderPromptAnalysis(analysis *PromptAnalysis) { - if analysis == nil { - return - } - fmt.Fprintf(os.Stderr, " Prompt Size: %s chars\n", console.FormatNumber(analysis.PromptSize)) - if analysis.PromptFile != "" { - fmt.Fprintf(os.Stderr, " Prompt File: %s\n", analysis.PromptFile) - } - fmt.Fprintln(os.Stderr) -} - -// renderSessionAnalysis renders session and agent performance metrics -func renderSessionAnalysis(session *SessionAnalysis) { - if session == nil { - return - } - if session.WallTime != "" { - fmt.Fprintf(os.Stderr, " Wall Time: %s\n", session.WallTime) - } - if session.TurnCount > 0 { - fmt.Fprintf(os.Stderr, " Turn Count: %d\n", session.TurnCount) - } - if session.AvgTurnDuration != "" { - fmt.Fprintf(os.Stderr, " Avg Turn Duration: %s\n", session.AvgTurnDuration) - } - if session.TokensPerMinute > 0 { - fmt.Fprintf(os.Stderr, " Tokens/Minute: %.1f\n", session.TokensPerMinute) - } - if session.NoopCount > 0 { - fmt.Fprintf(os.Stderr, " Noop Count: %d\n", session.NoopCount) - } - if session.TimeoutDetected { - fmt.Fprintf(os.Stderr, " Timeout Detected: %s\n", console.FormatWarningMessage("Yes")) - } else { - fmt.Fprintf(os.Stderr, " Timeout Detected: %s\n", console.FormatSuccessMessage("No")) - } - fmt.Fprintln(os.Stderr) -} - -// renderMCPServerHealth renders MCP server health summary -func renderMCPServerHealth(health *MCPServerHealth) { - if health == nil { - return - } - fmt.Fprintf(os.Stderr, " %s\n", health.Summary) - if health.TotalRequests > 0 { - fmt.Fprintf(os.Stderr, " Total Requests: %d\n", health.TotalRequests) - fmt.Fprintf(os.Stderr, " Total Errors: %d\n", health.TotalErrors) - fmt.Fprintf(os.Stderr, " Error Rate: %.1f%%\n", health.ErrorRate) - } - fmt.Fprintln(os.Stderr) - - // Server health table - if len(health.Servers) > 0 { - config := console.TableConfig{ - Headers: []string{"Server", "Requests", "Tool Calls", "Errors", "Error Rate", "Avg Latency", "Status"}, - Rows: make([][]string, 0, len(health.Servers)), - } - for _, server := range health.Servers { - row := []string{ - server.ServerName, - strconv.Itoa(server.RequestCount), - strconv.Itoa(server.ToolCalls), - strconv.Itoa(server.ErrorCount), - server.ErrorRateStr, - server.AvgLatency, - server.Status, - } - config.Rows = append(config.Rows, row) - } - fmt.Fprint(os.Stderr, console.RenderTable(config)) - } - - // Slowest tool calls - if len(health.SlowestCalls) > 0 { - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, " Slowest Tool Calls:") - config := console.TableConfig{ - Headers: []string{"Server", "Tool", "Duration"}, - Rows: make([][]string, 0, len(health.SlowestCalls)), - } - for _, call := range health.SlowestCalls { - row := []string{call.ServerName, call.ToolName, call.Duration} - config.Rows = append(config.Rows, row) - } - fmt.Fprint(os.Stderr, console.RenderTable(config)) - } - - fmt.Fprintln(os.Stderr) -} - -// renderSafeOutputSummary renders safe output summary with type breakdown -func renderSafeOutputSummary(summary *SafeOutputSummary) { - if summary == nil { - return - } - fmt.Fprintf(os.Stderr, " Total Items: %d\n", summary.TotalItems) - fmt.Fprintf(os.Stderr, " Summary: %s\n", summary.Summary) - fmt.Fprintln(os.Stderr) - - // Type breakdown table - if len(summary.TypeDetails) > 0 { - config := console.TableConfig{ - Headers: []string{"Type", "Count"}, - Rows: make([][]string, 0, len(summary.TypeDetails)), - } - for _, detail := range summary.TypeDetails { - row := []string{detail.Type, strconv.Itoa(detail.Count)} - config.Rows = append(config.Rows, row) - } - fmt.Fprint(os.Stderr, console.RenderTable(config)) - fmt.Fprintln(os.Stderr) - } -} - -// renderTokenUsage displays token usage data from the firewall proxy -func renderTokenUsage(summary *TokenUsageSummary) { - totalTokens := summary.TotalTokens() - cacheTokens := summary.TotalCacheReadTokens + summary.TotalCacheWriteTokens - - fmt.Fprintf(os.Stderr, " Total: %s tokens (%s input, %s output, %s cache)\n", - console.FormatNumber(totalTokens), - console.FormatNumber(summary.TotalInputTokens), - console.FormatNumber(summary.TotalOutputTokens), - console.FormatNumber(cacheTokens)) - fmt.Fprintf(os.Stderr, " Requests: %d (avg %s)\n", - summary.TotalRequests, timeutil.FormatDurationMs(summary.AvgDurationMs())) - if summary.CacheEfficiency > 0 { - fmt.Fprintf(os.Stderr, " Cache hit: %.1f%%\n", summary.CacheEfficiency*100) - } - fmt.Fprintln(os.Stderr) - - rows := summary.ModelRows() - if len(rows) > 0 { - config := console.TableConfig{ - Headers: []string{"Model", "Provider", "Input", "Output", "Cache Read", "Cache Write", "Requests", "Avg Duration"}, - Rows: make([][]string, 0, len(rows)), - } - for _, row := range rows { - config.Rows = append(config.Rows, []string{ - row.Model, - row.Provider, - console.FormatNumber(row.InputTokens), - console.FormatNumber(row.OutputTokens), - console.FormatNumber(row.CacheReadTokens), - console.FormatNumber(row.CacheWriteTokens), - strconv.Itoa(row.Requests), - row.AvgDuration, - }) - } - fmt.Fprint(os.Stderr, console.RenderTable(config)) - fmt.Fprintln(os.Stderr) - } -} - -// renderGitHubRateLimitUsage displays GitHub API quota consumption for the run. -func renderGitHubRateLimitUsage(usage *GitHubRateLimitUsage) { - if usage == nil { - return - } - - // Summary line - summary := "Total GitHub API calls: " + console.FormatNumber(usage.TotalRequestsMade) - if usage.CoreLimit > 0 { - summary += fmt.Sprintf(" | Core quota consumed: %s / %s (remaining: %s)", - console.FormatNumber(usage.CoreConsumed), - console.FormatNumber(usage.CoreLimit), - console.FormatNumber(usage.CoreRemaining), - ) - } - fmt.Fprintf(os.Stderr, " %s\n\n", summary) - - // Per-resource breakdown table (only when there are multiple resources or non-core resources) - rows := usage.ResourceRows() - if len(rows) == 0 { - return - } - cfg := console.TableConfig{ - Headers: []string{"Resource", "API Calls", "Quota Consumed", "Remaining", "Limit"}, - Rows: make([][]string, 0, len(rows)), - } - for _, row := range rows { - cfg.Rows = append(cfg.Rows, []string{ - row.Resource, - console.FormatNumber(row.RequestsMade), - console.FormatNumber(row.QuotaConsumed), - console.FormatNumber(row.FinalRemaining), - console.FormatNumber(row.Limit), - }) - } - fmt.Fprint(os.Stderr, console.RenderTable(cfg)) - fmt.Fprintln(os.Stderr) -} diff --git a/pkg/cli/audit_report_render_performance.go b/pkg/cli/audit_report_render_performance.go new file mode 100644 index 00000000000..ae5a66a5f37 --- /dev/null +++ b/pkg/cli/audit_report_render_performance.go @@ -0,0 +1,129 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (audit_report_render_performance.go) contains console rendering functions +// for performance metrics, token usage, and GitHub rate limit sections of the audit report. + +package cli + +import ( + "fmt" + "os" + "strconv" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/timeutil" +) + +// renderPerformanceMetrics renders performance metrics +func renderPerformanceMetrics(metrics *PerformanceMetrics) { + auditReportLog.Printf("Rendering performance metrics: tokens_per_min=%.1f, cost_efficiency=%s, most_used_tool=%s", + metrics.TokensPerMinute, metrics.CostEfficiency, metrics.MostUsedTool) + if metrics.TokensPerMinute > 0 { + fmt.Fprintf(os.Stderr, " Tokens per Minute: %.1f\n", metrics.TokensPerMinute) + } + + if metrics.CostEfficiency != "" { + efficiencyDisplay := metrics.CostEfficiency + switch metrics.CostEfficiency { + case "excellent", "good": + efficiencyDisplay = console.FormatSuccessMessage(metrics.CostEfficiency) + case "moderate": + efficiencyDisplay = console.FormatWarningMessage(metrics.CostEfficiency) + case "poor": + efficiencyDisplay = console.FormatErrorMessage(metrics.CostEfficiency) + } + fmt.Fprintf(os.Stderr, " Cost Efficiency: %s\n", efficiencyDisplay) + } + + if metrics.AvgToolDuration != "" { + fmt.Fprintf(os.Stderr, " Average Tool Duration: %s\n", metrics.AvgToolDuration) + } + + if metrics.MostUsedTool != "" { + fmt.Fprintf(os.Stderr, " Most Used Tool: %s\n", metrics.MostUsedTool) + } + + if metrics.NetworkRequests > 0 { + fmt.Fprintf(os.Stderr, " Network Requests: %d\n", metrics.NetworkRequests) + } + + fmt.Fprintln(os.Stderr) +} + +// renderTokenUsage displays token usage data from the firewall proxy +func renderTokenUsage(summary *TokenUsageSummary) { + totalTokens := summary.TotalTokens() + cacheTokens := summary.TotalCacheReadTokens + summary.TotalCacheWriteTokens + + fmt.Fprintf(os.Stderr, " Total: %s tokens (%s input, %s output, %s cache)\n", + console.FormatNumber(totalTokens), + console.FormatNumber(summary.TotalInputTokens), + console.FormatNumber(summary.TotalOutputTokens), + console.FormatNumber(cacheTokens)) + fmt.Fprintf(os.Stderr, " Requests: %d (avg %s)\n", + summary.TotalRequests, timeutil.FormatDurationMs(summary.AvgDurationMs())) + if summary.CacheEfficiency > 0 { + fmt.Fprintf(os.Stderr, " Cache hit: %.1f%%\n", summary.CacheEfficiency*100) + } + fmt.Fprintln(os.Stderr) + + rows := summary.ModelRows() + if len(rows) > 0 { + config := console.TableConfig{ + Headers: []string{"Model", "Provider", "Input", "Output", "Cache Read", "Cache Write", "Requests", "Avg Duration"}, + Rows: make([][]string, 0, len(rows)), + } + for _, row := range rows { + config.Rows = append(config.Rows, []string{ + row.Model, + row.Provider, + console.FormatNumber(row.InputTokens), + console.FormatNumber(row.OutputTokens), + console.FormatNumber(row.CacheReadTokens), + console.FormatNumber(row.CacheWriteTokens), + strconv.Itoa(row.Requests), + row.AvgDuration, + }) + } + fmt.Fprint(os.Stderr, console.RenderTable(config)) + fmt.Fprintln(os.Stderr) + } +} + +// renderGitHubRateLimitUsage displays GitHub API quota consumption for the run. +func renderGitHubRateLimitUsage(usage *GitHubRateLimitUsage) { + if usage == nil { + return + } + + // Summary line + summary := "Total GitHub API calls: " + console.FormatNumber(usage.TotalRequestsMade) + if usage.CoreLimit > 0 { + summary += fmt.Sprintf(" | Core quota consumed: %s / %s (remaining: %s)", + console.FormatNumber(usage.CoreConsumed), + console.FormatNumber(usage.CoreLimit), + console.FormatNumber(usage.CoreRemaining), + ) + } + fmt.Fprintf(os.Stderr, " %s\n\n", summary) + + // Per-resource breakdown table (only when there are multiple resources or non-core resources) + rows := usage.ResourceRows() + if len(rows) == 0 { + return + } + cfg := console.TableConfig{ + Headers: []string{"Resource", "API Calls", "Quota Consumed", "Remaining", "Limit"}, + Rows: make([][]string, 0, len(rows)), + } + for _, row := range rows { + cfg.Rows = append(cfg.Rows, []string{ + row.Resource, + console.FormatNumber(row.RequestsMade), + console.FormatNumber(row.QuotaConsumed), + console.FormatNumber(row.FinalRemaining), + console.FormatNumber(row.Limit), + }) + } + fmt.Fprint(os.Stderr, console.RenderTable(cfg)) + fmt.Fprintln(os.Stderr) +} diff --git a/pkg/cli/audit_report_render_policy.go b/pkg/cli/audit_report_render_policy.go new file mode 100644 index 00000000000..77af4650b27 --- /dev/null +++ b/pkg/cli/audit_report_render_policy.go @@ -0,0 +1,247 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (audit_report_render_policy.go) contains console rendering functions +// for policy analysis, firewall analysis, safe output summaries, and guard policy +// sections of the audit report. + +package cli + +import ( + "fmt" + "math" + "os" + "sort" + "strconv" + "time" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/sliceutil" + "github.com/github/gh-aw/pkg/stringutil" +) + +// formatUnixTimestamp converts a Unix timestamp (float64) to a human-readable time string (HH:MM:SS). +func formatUnixTimestamp(ts float64) string { + if ts <= 0 { + return "-" + } + sec := int64(math.Floor(ts)) + nsec := int64((ts - float64(sec)) * 1e9) + t := time.Unix(sec, nsec).UTC() + return t.Format("15:04:05") +} + +// renderGuardPolicySummary renders the guard policy enforcement summary +func renderGuardPolicySummary(summary *GuardPolicySummary) { + auditReportLog.Printf("Rendering guard policy summary: %d total blocked", summary.TotalBlocked) + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage( + fmt.Sprintf("Guard Policy: %d tool call(s) blocked", summary.TotalBlocked))) + fmt.Fprintln(os.Stderr) + + // Breakdown by reason + fmt.Fprintln(os.Stderr, " Block Reasons:") + if summary.IntegrityBlocked > 0 { + fmt.Fprintf(os.Stderr, " Integrity below minimum : %d\n", summary.IntegrityBlocked) + } + if summary.RepoScopeBlocked > 0 { + fmt.Fprintf(os.Stderr, " Repository not allowed : %d\n", summary.RepoScopeBlocked) + } + if summary.AccessDenied > 0 { + fmt.Fprintf(os.Stderr, " Access denied : %d\n", summary.AccessDenied) + } + if summary.BlockedUserDenied > 0 { + fmt.Fprintf(os.Stderr, " Blocked user : %d\n", summary.BlockedUserDenied) + } + if summary.PermissionDenied > 0 { + fmt.Fprintf(os.Stderr, " Insufficient permissions: %d\n", summary.PermissionDenied) + } + if summary.PrivateRepoDenied > 0 { + fmt.Fprintf(os.Stderr, " Private repo denied : %d\n", summary.PrivateRepoDenied) + } + fmt.Fprintln(os.Stderr) + + // Most frequently blocked tools + if len(summary.BlockedToolCounts) > 0 { + toolNames := sliceutil.MapToSlice(summary.BlockedToolCounts) + sort.Slice(toolNames, func(i, j int) bool { + return summary.BlockedToolCounts[toolNames[i]] > summary.BlockedToolCounts[toolNames[j]] + }) + + toolRows := make([][]string, 0, len(toolNames)) + for _, name := range toolNames { + toolRows = append(toolRows, []string{name, strconv.Itoa(summary.BlockedToolCounts[name])}) + } + fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ + Title: "Most Blocked Tools", + Headers: []string{"Tool", "Blocked"}, + Rows: toolRows, + })) + } + + // Guard policy event details + if len(summary.Events) > 0 { + fmt.Fprintln(os.Stderr) + eventRows := make([][]string, 0, len(summary.Events)) + for _, evt := range summary.Events { + message := evt.Message + if len(message) > 60 { + message = message[:57] + "..." + } + repo := evt.Repository + if repo == "" { + repo = "-" + } + eventRows = append(eventRows, []string{ + stringutil.Truncate(evt.ServerID, 20), + stringutil.Truncate(evt.ToolName, 25), + evt.Reason, + message, + repo, + }) + } + fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ + Title: "Guard Policy Events", + Headers: []string{"Server", "Tool", "Reason", "Message", "Repository"}, + Rows: eventRows, + })) + } +} + +// renderFirewallAnalysis renders firewall analysis with summary and domain breakdown +func renderFirewallAnalysis(analysis *FirewallAnalysis) { + auditReportLog.Printf("Rendering firewall analysis: total=%d, allowed=%d, blocked=%d, allowed_domains=%d, blocked_domains=%d", + analysis.TotalRequests, analysis.AllowedRequests, analysis.BlockedRequests, len(analysis.AllowedDomains), len(analysis.BlockedDomains)) + // Summary statistics + fmt.Fprintf(os.Stderr, " Total Requests : %d\n", analysis.TotalRequests) + fmt.Fprintf(os.Stderr, " Allowed : %d\n", analysis.AllowedRequests) + fmt.Fprintf(os.Stderr, " Blocked : %d\n", analysis.BlockedRequests) + fmt.Fprintln(os.Stderr) + + // Allowed domains + if len(analysis.AllowedDomains) > 0 { + fmt.Fprintln(os.Stderr, " Allowed Domains:") + for _, domain := range analysis.AllowedDomains { + if stats, ok := analysis.RequestsByDomain[domain]; ok { + fmt.Fprintf(os.Stderr, " ✓ %s (%d requests)\n", domain, stats.Allowed) + } + } + fmt.Fprintln(os.Stderr) + } + + // Blocked domains + if len(analysis.BlockedDomains) > 0 { + fmt.Fprintln(os.Stderr, " Blocked Domains:") + for _, domain := range analysis.BlockedDomains { + if stats, ok := analysis.RequestsByDomain[domain]; ok { + fmt.Fprintf(os.Stderr, " ✗ %s (%d requests)\n", domain, stats.Blocked) + } + } + fmt.Fprintln(os.Stderr) + } +} + +// renderRedactedDomainsAnalysis renders redacted domains analysis +func renderRedactedDomainsAnalysis(analysis *RedactedDomainsAnalysis) { + auditReportLog.Printf("Rendering redacted domains analysis: total_domains=%d", analysis.TotalDomains) + // Summary statistics + fmt.Fprintf(os.Stderr, " Total Domains Redacted: %d\n", analysis.TotalDomains) + fmt.Fprintln(os.Stderr) + + // List domains + if len(analysis.Domains) > 0 { + fmt.Fprintln(os.Stderr, " Redacted Domains:") + for _, domain := range analysis.Domains { + fmt.Fprintf(os.Stderr, " 🔒 %s\n", domain) + } + fmt.Fprintln(os.Stderr) + } +} + +// renderPolicyAnalysis renders the enriched firewall policy analysis with rule attribution +func renderPolicyAnalysis(analysis *PolicyAnalysis) { + auditReportLog.Printf("Rendering policy analysis: rules=%d, denied=%d", len(analysis.RuleHits), analysis.DeniedCount) + + // Policy summary using RenderStruct + display := PolicySummaryDisplay{ + Policy: analysis.PolicySummary, + TotalRequests: analysis.TotalRequests, + Allowed: analysis.AllowedCount, + Denied: analysis.DeniedCount, + UniqueDomains: analysis.UniqueDomains, + } + fmt.Fprint(os.Stderr, console.RenderStruct(display)) + fmt.Fprintln(os.Stderr) + + // Rule hit table + if len(analysis.RuleHits) > 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Policy Rules:")) + fmt.Fprintln(os.Stderr) + + ruleConfig := console.TableConfig{ + Headers: []string{"Rule", "Action", "Description", "Hits"}, + Rows: make([][]string, 0, len(analysis.RuleHits)), + } + + for _, rh := range analysis.RuleHits { + row := []string{ + stringutil.Truncate(rh.Rule.ID, 30), + rh.Rule.Action, + stringutil.Truncate(rh.Rule.Description, 50), + strconv.Itoa(rh.Hits), + } + ruleConfig.Rows = append(ruleConfig.Rows, row) + } + + fmt.Fprint(os.Stderr, console.RenderTable(ruleConfig)) + fmt.Fprintln(os.Stderr) + } + + // Denied requests detail + if len(analysis.DeniedRequests) > 0 { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Denied Requests (%d):", len(analysis.DeniedRequests)))) + fmt.Fprintln(os.Stderr) + + deniedConfig := console.TableConfig{ + Headers: []string{"Time", "Domain", "Rule", "Reason"}, + Rows: make([][]string, 0, len(analysis.DeniedRequests)), + } + + for _, req := range analysis.DeniedRequests { + timeStr := formatUnixTimestamp(req.Timestamp) + row := []string{ + timeStr, + stringutil.Truncate(req.Host, 40), + stringutil.Truncate(req.RuleID, 25), + stringutil.Truncate(req.Reason, 40), + } + deniedConfig.Rows = append(deniedConfig.Rows, row) + } + + fmt.Fprint(os.Stderr, console.RenderTable(deniedConfig)) + fmt.Fprintln(os.Stderr) + } +} + +// renderSafeOutputSummary renders safe output summary with type breakdown +func renderSafeOutputSummary(summary *SafeOutputSummary) { + if summary == nil { + return + } + fmt.Fprintf(os.Stderr, " Total Items: %d\n", summary.TotalItems) + fmt.Fprintf(os.Stderr, " Summary: %s\n", summary.Summary) + fmt.Fprintln(os.Stderr) + + // Type breakdown table + if len(summary.TypeDetails) > 0 { + config := console.TableConfig{ + Headers: []string{"Type", "Count"}, + Rows: make([][]string, 0, len(summary.TypeDetails)), + } + for _, detail := range summary.TypeDetails { + row := []string{detail.Type, strconv.Itoa(detail.Count)} + config.Rows = append(config.Rows, row) + } + fmt.Fprint(os.Stderr, console.RenderTable(config)) + fmt.Fprintln(os.Stderr) + } +} diff --git a/pkg/cli/audit_report_render_summary.go b/pkg/cli/audit_report_render_summary.go new file mode 100644 index 00000000000..426602af5e4 --- /dev/null +++ b/pkg/cli/audit_report_render_summary.go @@ -0,0 +1,347 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (audit_report_render_summary.go) contains console rendering functions +// for overview, comparison, task domain, behavior fingerprint, jobs table, +// key findings, recommendations, engine configuration, and session analysis +// sections of the audit report. + +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/sliceutil" + "github.com/github/gh-aw/pkg/stringutil" +) + +func renderAuditComparison(comparison *AuditComparisonData) { + if comparison == nil { + return + } + + if !comparison.BaselineFound || comparison.Baseline == nil || comparison.Delta == nil || comparison.Classification == nil { + fmt.Fprintln(os.Stderr, " No suitable successful run was available for baseline comparison.") + fmt.Fprintln(os.Stderr) + return + } + + fmt.Fprintf(os.Stderr, " Baseline: run %d", comparison.Baseline.RunID) + if comparison.Baseline.Conclusion != "" { + fmt.Fprintf(os.Stderr, " (%s)", comparison.Baseline.Conclusion) + } + fmt.Fprintln(os.Stderr) + if comparison.Baseline.Selection != "" { + fmt.Fprintf(os.Stderr, " Selection: %s\n", strings.ReplaceAll(comparison.Baseline.Selection, "_", " ")) + } + if len(comparison.Baseline.MatchedOn) > 0 { + fmt.Fprintf(os.Stderr, " Matched on: %s\n", strings.Join(comparison.Baseline.MatchedOn, ", ")) + } + fmt.Fprintf(os.Stderr, " Classification: %s\n", comparison.Classification.Label) + fmt.Fprintln(os.Stderr, " Changes:") + + if comparison.Delta.Turns.Changed { + fmt.Fprintf(os.Stderr, " - Turns: %d -> %d\n", comparison.Delta.Turns.Before, comparison.Delta.Turns.After) + } + if comparison.Delta.Posture.Changed { + fmt.Fprintf(os.Stderr, " - Posture: %s -> %s\n", comparison.Delta.Posture.Before, comparison.Delta.Posture.After) + } + if comparison.Delta.BlockedRequests.Changed { + fmt.Fprintf(os.Stderr, " - Blocked requests: %d -> %d\n", comparison.Delta.BlockedRequests.Before, comparison.Delta.BlockedRequests.After) + } + if comparison.Delta.MCPFailure != nil && comparison.Delta.MCPFailure.NewlyPresent { + fmt.Fprintf(os.Stderr, " - New MCP failure: %s\n", strings.Join(comparison.Delta.MCPFailure.After, ", ")) + } + if len(comparison.Classification.ReasonCodes) == 0 { + fmt.Fprintln(os.Stderr, " - No meaningful behavior change from the selected successful baseline") + } + if comparison.Recommendation != nil && comparison.Recommendation.Action != "" { + fmt.Fprintf(os.Stderr, " Recommended action: %s\n", comparison.Recommendation.Action) + } + fmt.Fprintln(os.Stderr) +} + +// renderOverview renders the overview section using the new rendering system +func renderOverview(overview OverviewData) { + // Format Status with optional Conclusion + statusLine := overview.Status + if overview.Conclusion != "" && overview.Status == "completed" { + statusLine = fmt.Sprintf("%s (%s)", overview.Status, overview.Conclusion) + } + + display := OverviewDisplay{ + RunID: overview.RunID, + Workflow: overview.WorkflowName, + Status: statusLine, + Duration: overview.Duration, + Event: overview.Event, + Branch: overview.Branch, + URL: overview.URL, + Files: overview.LogsPath, + } + + fmt.Fprint(os.Stderr, console.RenderStruct(display)) +} + +// renderMetrics renders the metrics section using the new rendering system +func renderMetrics(metrics MetricsData) { + fmt.Fprint(os.Stderr, console.RenderStruct(metrics)) +} + +type taskDomainDisplay struct { + Domain string `console:"header:Domain"` + Reason string `console:"header:Reason"` +} + +type behaviorFingerprintDisplay struct { + Execution string `console:"header:Execution"` + Tools string `console:"header:Tools"` + Actuation string `console:"header:Actuation"` + Resource string `console:"header:Resources"` + Dispatch string `console:"header:Dispatch"` +} + +func renderTaskDomain(domain *TaskDomainInfo) { + if domain == nil { + return + } + fmt.Fprint(os.Stderr, console.RenderStruct(taskDomainDisplay{ + Domain: domain.Label, + Reason: domain.Reason, + })) +} + +func renderBehaviorFingerprint(fingerprint *BehaviorFingerprint) { + if fingerprint == nil { + return + } + fmt.Fprint(os.Stderr, console.RenderStruct(behaviorFingerprintDisplay{ + Execution: fingerprint.ExecutionStyle, + Tools: fingerprint.ToolBreadth, + Actuation: fingerprint.ActuationStyle, + Resource: fingerprint.ResourceProfile, + Dispatch: fingerprint.DispatchMode, + })) +} + +func renderAgenticAssessments(assessments []AgenticAssessment) { + for _, assessment := range assessments { + severity := strings.ToUpper(assessment.Severity) + fmt.Fprintf(os.Stderr, " [%s] %s\n", severity, assessment.Summary) + if assessment.Evidence != "" { + fmt.Fprintf(os.Stderr, " Evidence: %s\n", assessment.Evidence) + } + if assessment.Recommendation != "" { + fmt.Fprintf(os.Stderr, " Recommendation: %s\n", assessment.Recommendation) + } + fmt.Fprintln(os.Stderr) + } +} + +// renderJobsTable renders the jobs as a table using console.RenderTable +func renderJobsTable(jobs []JobData) { + auditReportLog.Printf("Rendering jobs table with %d jobs", len(jobs)) + config := console.TableConfig{ + Headers: []string{"Name", "Status", "Conclusion", "Duration"}, + Rows: make([][]string, 0, len(jobs)), + } + + for _, job := range jobs { + conclusion := job.Conclusion + if conclusion == "" { + conclusion = "-" + } + duration := job.Duration + if duration == "" { + duration = "-" + } + + row := []string{ + stringutil.Truncate(job.Name, 40), + job.Status, + conclusion, + duration, + } + config.Rows = append(config.Rows, row) + } + + fmt.Fprint(os.Stderr, console.RenderTable(config)) +} + +// renderKeyFindings renders key findings with colored severity indicators +func renderKeyFindings(findings []Finding) { + auditReportLog.Printf("Rendering key findings: total=%d", len(findings)) + // Group findings by severity for better presentation + critical := sliceutil.Filter(findings, func(f Finding) bool { return f.Severity == "critical" }) + high := sliceutil.Filter(findings, func(f Finding) bool { return f.Severity == "high" }) + medium := sliceutil.Filter(findings, func(f Finding) bool { return f.Severity == "medium" }) + low := sliceutil.Filter(findings, func(f Finding) bool { return f.Severity == "low" }) + info := sliceutil.Filter(findings, func(f Finding) bool { + return f.Severity != "critical" && f.Severity != "high" && f.Severity != "medium" && f.Severity != "low" + }) + + // Render critical findings first + for _, finding := range critical { + fmt.Fprintf(os.Stderr, " 🔴 %s [%s]\n", console.FormatErrorMessage(finding.Title), finding.Category) + fmt.Fprintf(os.Stderr, " %s\n", finding.Description) + if finding.Impact != "" { + fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) + } + fmt.Fprintln(os.Stderr) + } + + // Then high severity + for _, finding := range high { + fmt.Fprintf(os.Stderr, " 🟠 %s [%s]\n", console.FormatWarningMessage(finding.Title), finding.Category) + fmt.Fprintf(os.Stderr, " %s\n", finding.Description) + if finding.Impact != "" { + fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) + } + fmt.Fprintln(os.Stderr) + } + + // Medium severity + for _, finding := range medium { + fmt.Fprintf(os.Stderr, " 🟡 %s [%s]\n", finding.Title, finding.Category) + fmt.Fprintf(os.Stderr, " %s\n", finding.Description) + if finding.Impact != "" { + fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) + } + fmt.Fprintln(os.Stderr) + } + + // Low severity + for _, finding := range low { + fmt.Fprintf(os.Stderr, " â„šī¸ %s [%s]\n", finding.Title, finding.Category) + fmt.Fprintf(os.Stderr, " %s\n", finding.Description) + if finding.Impact != "" { + fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) + } + fmt.Fprintln(os.Stderr) + } + + // Info findings + for _, finding := range info { + fmt.Fprintf(os.Stderr, " ✅ %s [%s]\n", console.FormatSuccessMessage(finding.Title), finding.Category) + fmt.Fprintf(os.Stderr, " %s\n", finding.Description) + if finding.Impact != "" { + fmt.Fprintf(os.Stderr, " Impact: %s\n", finding.Impact) + } + fmt.Fprintln(os.Stderr) + } +} + +// renderRecommendations renders actionable recommendations +func renderRecommendations(recommendations []Recommendation) { + auditReportLog.Printf("Rendering recommendations: total=%d", len(recommendations)) + // Group by priority + high := sliceutil.Filter(recommendations, func(r Recommendation) bool { return r.Priority == "high" }) + medium := sliceutil.Filter(recommendations, func(r Recommendation) bool { return r.Priority == "medium" }) + low := sliceutil.Filter(recommendations, func(r Recommendation) bool { return r.Priority != "high" && r.Priority != "medium" }) + + // Render high priority first + for i, rec := range high { + fmt.Fprintf(os.Stderr, " %d. [HIGH] %s\n", i+1, console.FormatWarningMessage(rec.Action)) + fmt.Fprintf(os.Stderr, " Reason: %s\n", rec.Reason) + if rec.Example != "" { + fmt.Fprintf(os.Stderr, " Example: %s\n", rec.Example) + } + fmt.Fprintln(os.Stderr) + } + + // Medium priority + startIdx := len(high) + 1 + for i, rec := range medium { + fmt.Fprintf(os.Stderr, " %d. [MEDIUM] %s\n", startIdx+i, rec.Action) + fmt.Fprintf(os.Stderr, " Reason: %s\n", rec.Reason) + if rec.Example != "" { + fmt.Fprintf(os.Stderr, " Example: %s\n", rec.Example) + } + fmt.Fprintln(os.Stderr) + } + + // Low priority + startIdx += len(medium) + for i, rec := range low { + fmt.Fprintf(os.Stderr, " %d. [LOW] %s\n", startIdx+i, rec.Action) + fmt.Fprintf(os.Stderr, " Reason: %s\n", rec.Reason) + if rec.Example != "" { + fmt.Fprintf(os.Stderr, " Example: %s\n", rec.Example) + } + fmt.Fprintln(os.Stderr) + } +} + +// renderEngineConfig renders engine configuration details +func renderEngineConfig(config *EngineConfig) { + if config == nil { + return + } + fmt.Fprintf(os.Stderr, " Engine ID: %s\n", config.EngineID) + if config.EngineName != "" { + fmt.Fprintf(os.Stderr, " Engine Name: %s\n", config.EngineName) + } + if config.Model != "" { + fmt.Fprintf(os.Stderr, " Model: %s\n", config.Model) + } + if config.Version != "" { + fmt.Fprintf(os.Stderr, " Version: %s\n", config.Version) + } + if config.CLIVersion != "" { + fmt.Fprintf(os.Stderr, " CLI Version: %s\n", config.CLIVersion) + } + if config.FirewallVersion != "" { + fmt.Fprintf(os.Stderr, " Firewall Version: %s\n", config.FirewallVersion) + } + if config.TriggerEvent != "" { + fmt.Fprintf(os.Stderr, " Trigger Event: %s\n", config.TriggerEvent) + } + if config.Repository != "" { + fmt.Fprintf(os.Stderr, " Repository: %s\n", config.Repository) + } + if len(config.MCPServers) > 0 { + fmt.Fprintf(os.Stderr, " MCP Servers: %s\n", strings.Join(config.MCPServers, ", ")) + } + fmt.Fprintln(os.Stderr) +} + +// renderPromptAnalysis renders prompt analysis metrics +func renderPromptAnalysis(analysis *PromptAnalysis) { + if analysis == nil { + return + } + fmt.Fprintf(os.Stderr, " Prompt Size: %s chars\n", console.FormatNumber(analysis.PromptSize)) + if analysis.PromptFile != "" { + fmt.Fprintf(os.Stderr, " Prompt File: %s\n", analysis.PromptFile) + } + fmt.Fprintln(os.Stderr) +} + +// renderSessionAnalysis renders session and agent performance metrics +func renderSessionAnalysis(session *SessionAnalysis) { + if session == nil { + return + } + if session.WallTime != "" { + fmt.Fprintf(os.Stderr, " Wall Time: %s\n", session.WallTime) + } + if session.TurnCount > 0 { + fmt.Fprintf(os.Stderr, " Turn Count: %d\n", session.TurnCount) + } + if session.AvgTurnDuration != "" { + fmt.Fprintf(os.Stderr, " Avg Turn Duration: %s\n", session.AvgTurnDuration) + } + if session.TokensPerMinute > 0 { + fmt.Fprintf(os.Stderr, " Tokens/Minute: %.1f\n", session.TokensPerMinute) + } + if session.NoopCount > 0 { + fmt.Fprintf(os.Stderr, " Noop Count: %d\n", session.NoopCount) + } + if session.TimeoutDetected { + fmt.Fprintf(os.Stderr, " Timeout Detected: %s\n", console.FormatWarningMessage("Yes")) + } else { + fmt.Fprintf(os.Stderr, " Timeout Detected: %s\n", console.FormatSuccessMessage("No")) + } + fmt.Fprintln(os.Stderr) +} diff --git a/pkg/cli/audit_report_render_tools.go b/pkg/cli/audit_report_render_tools.go new file mode 100644 index 00000000000..015457ae3a7 --- /dev/null +++ b/pkg/cli/audit_report_render_tools.go @@ -0,0 +1,209 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (audit_report_render_tools.go) contains console rendering functions +// for tool-usage and MCP data sections of the audit report. + +package cli + +import ( + "fmt" + "os" + "strconv" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/stringutil" +) + +// renderToolUsageTable renders tool usage as a table with custom formatting +func renderToolUsageTable(toolUsage []ToolUsageInfo) { + auditReportLog.Printf("Rendering tool usage table with %d tools", len(toolUsage)) + config := console.TableConfig{ + Headers: []string{"Tool", "Calls", "Max Input", "Max Output", "Max Duration"}, + Rows: make([][]string, 0, len(toolUsage)), + } + + for _, tool := range toolUsage { + inputStr := "N/A" + if tool.MaxInputSize > 0 { + inputStr = console.FormatNumber(tool.MaxInputSize) + } + outputStr := "N/A" + if tool.MaxOutputSize > 0 { + outputStr = console.FormatNumber(tool.MaxOutputSize) + } + durationStr := "N/A" + if tool.MaxDuration != "" { + durationStr = tool.MaxDuration + } + + row := []string{ + stringutil.Truncate(tool.Name, 40), + strconv.Itoa(tool.CallCount), + inputStr, + outputStr, + durationStr, + } + config.Rows = append(config.Rows, row) + } + + fmt.Fprint(os.Stderr, console.RenderTable(config)) +} + +// renderMCPToolUsageTable renders MCP tool usage with detailed statistics +func renderMCPToolUsageTable(mcpData *MCPToolUsageData) { + auditReportLog.Printf("Rendering MCP tool usage table with %d tools", len(mcpData.Summary)) + + // Render server-level statistics first + if len(mcpData.Servers) > 0 { + fmt.Fprintln(os.Stderr, " Server Statistics:") + fmt.Fprintln(os.Stderr) + + serverConfig := console.TableConfig{ + Headers: []string{"Server", "Requests", "Tool Calls", "Total Input", "Total Output", "Avg Duration", "Errors"}, + Rows: make([][]string, 0, len(mcpData.Servers)), + } + + for _, server := range mcpData.Servers { + inputStr := console.FormatFileSize(int64(server.TotalInputSize)) + outputStr := console.FormatFileSize(int64(server.TotalOutputSize)) + durationStr := server.AvgDuration + if durationStr == "" { + durationStr = "N/A" + } + errorStr := strconv.Itoa(server.ErrorCount) + if server.ErrorCount == 0 { + errorStr = "-" + } + + row := []string{ + stringutil.Truncate(server.ServerName, 25), + strconv.Itoa(server.RequestCount), + strconv.Itoa(server.ToolCallCount), + inputStr, + outputStr, + durationStr, + errorStr, + } + serverConfig.Rows = append(serverConfig.Rows, row) + } + + fmt.Fprint(os.Stderr, console.RenderTable(serverConfig)) + fmt.Fprintln(os.Stderr) + } + + // Render tool-level statistics + if len(mcpData.Summary) > 0 { + fmt.Fprintln(os.Stderr, " Tool Statistics:") + fmt.Fprintln(os.Stderr) + + toolConfig := console.TableConfig{ + Headers: []string{"Server", "Tool", "Calls", "Total In", "Total Out", "Max In", "Max Out"}, + Rows: make([][]string, 0, len(mcpData.Summary)), + } + + for _, tool := range mcpData.Summary { + totalInStr := console.FormatFileSize(int64(tool.TotalInputSize)) + totalOutStr := console.FormatFileSize(int64(tool.TotalOutputSize)) + maxInStr := console.FormatFileSize(int64(tool.MaxInputSize)) + maxOutStr := console.FormatFileSize(int64(tool.MaxOutputSize)) + + row := []string{ + stringutil.Truncate(tool.ServerName, 20), + stringutil.Truncate(tool.ToolName, 30), + strconv.Itoa(tool.CallCount), + totalInStr, + totalOutStr, + maxInStr, + maxOutStr, + } + toolConfig.Rows = append(toolConfig.Rows, row) + } + + fmt.Fprint(os.Stderr, console.RenderTable(toolConfig)) + } + + // Render guard policy summary + if mcpData.GuardPolicySummary != nil && mcpData.GuardPolicySummary.TotalBlocked > 0 { + renderGuardPolicySummary(mcpData.GuardPolicySummary) + } +} + +// renderCreatedItemsTable renders the list of items created in GitHub by safe output handlers +// as a table with clickable URLs for easy auditing. +func renderCreatedItemsTable(items []CreatedItemReport) { + auditReportLog.Printf("Rendering created items table with %d item(s)", len(items)) + config := console.TableConfig{ + Headers: []string{"Type", "Repo", "Number", "Temp ID", "URL"}, + Rows: make([][]string, 0, len(items)), + } + + for _, item := range items { + numberStr := "" + if item.Number > 0 { + numberStr = strconv.Itoa(item.Number) + } + + row := []string{ + item.Type, + item.Repo, + numberStr, + item.TemporaryID, + item.URL, + } + config.Rows = append(config.Rows, row) + } + + fmt.Fprint(os.Stderr, console.RenderTable(config)) + fmt.Fprintln(os.Stderr) +} + +// renderMCPServerHealth renders MCP server health summary +func renderMCPServerHealth(health *MCPServerHealth) { + if health == nil { + return + } + fmt.Fprintf(os.Stderr, " %s\n", health.Summary) + if health.TotalRequests > 0 { + fmt.Fprintf(os.Stderr, " Total Requests: %d\n", health.TotalRequests) + fmt.Fprintf(os.Stderr, " Total Errors: %d\n", health.TotalErrors) + fmt.Fprintf(os.Stderr, " Error Rate: %.1f%%\n", health.ErrorRate) + } + fmt.Fprintln(os.Stderr) + + // Server health table + if len(health.Servers) > 0 { + config := console.TableConfig{ + Headers: []string{"Server", "Requests", "Tool Calls", "Errors", "Error Rate", "Avg Latency", "Status"}, + Rows: make([][]string, 0, len(health.Servers)), + } + for _, server := range health.Servers { + row := []string{ + server.ServerName, + strconv.Itoa(server.RequestCount), + strconv.Itoa(server.ToolCalls), + strconv.Itoa(server.ErrorCount), + server.ErrorRateStr, + server.AvgLatency, + server.Status, + } + config.Rows = append(config.Rows, row) + } + fmt.Fprint(os.Stderr, console.RenderTable(config)) + } + + // Slowest tool calls + if len(health.SlowestCalls) > 0 { + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, " Slowest Tool Calls:") + config := console.TableConfig{ + Headers: []string{"Server", "Tool", "Duration"}, + Rows: make([][]string, 0, len(health.SlowestCalls)), + } + for _, call := range health.SlowestCalls { + row := []string{call.ServerName, call.ToolName, call.Duration} + config.Rows = append(config.Rows, row) + } + fmt.Fprint(os.Stderr, console.RenderTable(config)) + } + + fmt.Fprintln(os.Stderr) +} diff --git a/pkg/cli/context_cancellation_test.go b/pkg/cli/context_cancellation_test.go index 22e697b5727..732d0759076 100644 --- a/pkg/cli/context_cancellation_test.go +++ b/pkg/cli/context_cancellation_test.go @@ -71,7 +71,11 @@ func TestDownloadWorkflowLogsWithCancellation(t *testing.T) { cancel() // Try to download logs with a cancelled context - err := DownloadWorkflowLogs(ctx, "", 10, "", "", "/tmp/test-logs", "", "", 0, 0, "", false, false, false, false, false, false, false, 0, "", "", false, false, "", nil) + err := DownloadWorkflowLogs(ctx, DownloadConfig{ + WorkflowName: "", + Count: 10, + OutputDir: "/tmp/test-logs", + }) // Should return context.Canceled error assert.ErrorIs(t, err, context.Canceled, "Should return context.Canceled error when context is cancelled") @@ -111,7 +115,12 @@ func TestDownloadWorkflowLogsTimeoutRespected(t *testing.T) { start := time.Now() // Use a workflow name that doesn't exist to avoid actual network calls - _ = DownloadWorkflowLogs(ctx, "nonexistent-workflow-12345", 100, "", "", "/tmp/test-logs", "", "", 0, 0, "", false, false, false, false, false, false, false, 1, "", "", false, false, "", nil) + _ = DownloadWorkflowLogs(ctx, DownloadConfig{ + WorkflowName: "nonexistent-workflow-12345", + Count: 100, + OutputDir: "/tmp/test-logs", + Timeout: 1, + }) elapsed := time.Since(start) // Should complete within reasonable time (give 5 seconds buffer for test overhead) diff --git a/pkg/cli/gateway_logs.go b/pkg/cli/gateway_logs.go index 48dbb184515..94bb6a7604c 100644 --- a/pkg/cli/gateway_logs.go +++ b/pkg/cli/gateway_logs.go @@ -1,13 +1,16 @@ // This file provides command-line interface functionality for gh-aw. -// This file (gateway_logs.go) contains functions for parsing and analyzing -// MCP gateway logs from gateway.jsonl or rpc-messages.jsonl files. +// This file (gateway_logs.go) contains types, constants, and entry-point functions +// for parsing and analyzing MCP gateway logs. // // Key responsibilities: -// - Parsing gateway.jsonl JSONL format logs (preferred) -// - Parsing rpc-messages.jsonl JSONL format logs (canonical fallback) -// - Extracting server and tool usage metrics -// - Aggregating gateway statistics -// - Rendering gateway metrics tables +// - Type definitions for gateway log entries, metrics, and RPC messages +// - Entry-point for parsing gateway logs (parseGatewayLogs) +// - Helper utilities: getOrCreateServer, getOrCreateTool, calculateGatewayAggregates +// +// Related files: +// - gateway_logs_parser.go — parseRPCMessages, processGatewayLogEntry, buildToolCallsFromRPCMessages +// - gateway_logs_mcp.go — extractMCPToolUsageData +// - gateway_logs_policy.go — guard policy error classification and summary building package cli @@ -18,13 +21,11 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/timeutil" ) var gatewayLogsLog = logger.New("cli:gateway_logs") @@ -99,31 +100,8 @@ type GuardPolicyEvent struct { Repository string `json:"repository,omitempty"` // Repository involved (for repo scope blocks) } -// isGuardPolicyErrorCode returns true if the JSON-RPC error code indicates a -// guard policy enforcement decision. -func isGuardPolicyErrorCode(code int) bool { - return code >= guardPolicyErrorCodeIntegrityBelowMin && code <= guardPolicyErrorCodeAccessDenied -} - -// guardPolicyReasonFromCode returns a human-readable reason string for a guard policy error code. -func guardPolicyReasonFromCode(code int) string { - switch code { - case guardPolicyErrorCodeAccessDenied: - return "access_denied" - case guardPolicyErrorCodeRepoNotAllowed: - return "repo_not_allowed" - case guardPolicyErrorCodeInsufficientPerms: - return "insufficient_permissions" - case guardPolicyErrorCodePrivateRepoDenied: - return "private_repo_denied" - case guardPolicyErrorCodeBlockedUser: - return "blocked_user" - case guardPolicyErrorCodeIntegrityBelowMin: - return "integrity_below_minimum" - default: - return "unknown" - } -} +// isGuardPolicyErrorCode and guardPolicyReasonFromCode are in gateway_logs_policy.go. +// buildGuardPolicySummary is in gateway_logs_policy.go. // GatewayServerMetrics represents usage metrics for a single MCP server type GatewayServerMetrics struct { @@ -226,212 +204,6 @@ type rpcPendingRequest struct { Timestamp time.Time } -// parseRPCMessages parses a rpc-messages.jsonl file and extracts GatewayMetrics. -// This is the canonical fallback when gateway.jsonl is not available. -func parseRPCMessages(logPath string, verbose bool) (*GatewayMetrics, error) { - gatewayLogsLog.Printf("Parsing rpc-messages.jsonl from: %s", logPath) - - file, err := os.Open(logPath) - if err != nil { - return nil, fmt.Errorf("failed to open rpc-messages.jsonl: %w", err) - } - defer file.Close() - - metrics := &GatewayMetrics{ - Servers: make(map[string]*GatewayServerMetrics), - } - - // Track pending requests by (serverID, id) for duration calculation. - // Key format: "/" - pendingRequests := make(map[string]*rpcPendingRequest) - - scanner := bufio.NewScanner(file) - // Increase scanner buffer for large payloads - buf := make([]byte, maxScannerBufferSize) - scanner.Buffer(buf, maxScannerBufferSize) - lineNum := 0 - - for scanner.Scan() { - lineNum++ - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - var entry RPCMessageEntry - if err := json.Unmarshal([]byte(line), &entry); err != nil { - gatewayLogsLog.Printf("Failed to parse rpc-messages.jsonl line %d: %v", lineNum, err) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage( - fmt.Sprintf("Failed to parse rpc-messages.jsonl line %d: %v", lineNum, err))) - } - continue - } - - // Update time range - if entry.Timestamp != "" { - if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { - if metrics.StartTime.IsZero() || t.Before(metrics.StartTime) { - metrics.StartTime = t - } - if metrics.EndTime.IsZero() || t.After(metrics.EndTime) { - metrics.EndTime = t - } - } - } - - if entry.ServerID == "" { - continue - } - - switch { - case entry.Type == "DIFC_FILTERED": - // DIFC integrity/secrecy filter event — not a REQUEST or RESPONSE - metrics.TotalFiltered++ - server := getOrCreateServer(metrics, entry.ServerID) - server.FilteredCount++ - metrics.FilteredEvents = append(metrics.FilteredEvents, DifcFilteredEvent{ - Timestamp: entry.Timestamp, - ServerID: entry.ServerID, - ToolName: entry.ToolName, - Description: entry.Description, - Reason: entry.Reason, - SecrecyTags: entry.SecrecyTags, - IntegrityTags: entry.IntegrityTags, - AuthorAssociation: entry.AuthorAssociation, - AuthorLogin: entry.AuthorLogin, - HTMLURL: entry.HTMLURL, - Number: entry.Number, - }) - - case entry.Direction == "OUT" && entry.Type == "REQUEST": - // Outgoing request from AI engine to MCP server - var req rpcRequestPayload - if err := json.Unmarshal(entry.Payload, &req); err != nil { - continue - } - if req.Method != "tools/call" { - continue - } - - // Extract tool name - var params rpcToolCallParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.Name == "" { - continue - } - - metrics.TotalRequests++ - server := getOrCreateServer(metrics, entry.ServerID) - server.RequestCount++ - metrics.TotalToolCalls++ - server.ToolCallCount++ - - tool := getOrCreateTool(server, params.Name) - tool.CallCount++ - - // Store pending request for duration calculation - if req.ID != nil && entry.Timestamp != "" { - if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { - key := fmt.Sprintf("%s/%v", entry.ServerID, req.ID) - pendingRequests[key] = &rpcPendingRequest{ - ServerID: entry.ServerID, - ToolName: params.Name, - Timestamp: t, - } - } - } - - case entry.Direction == "IN" && entry.Type == "RESPONSE": - // Incoming response from MCP server to AI engine - var resp rpcResponsePayload - if err := json.Unmarshal(entry.Payload, &resp); err != nil { - continue - } - - // Track errors and detect guard policy blocks - if resp.Error != nil { - metrics.TotalErrors++ - server := getOrCreateServer(metrics, entry.ServerID) - server.ErrorCount++ - - // Detect guard policy enforcement errors - if isGuardPolicyErrorCode(resp.Error.Code) { - metrics.TotalGuardBlocked++ - server.GuardPolicyBlocked++ - - // Determine tool name from pending request if available - toolName := "" - if resp.ID != nil { - key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) - if pending, ok := pendingRequests[key]; ok { - toolName = pending.ToolName - } - } - - reason := guardPolicyReasonFromCode(resp.Error.Code) - if resp.Error.Data != nil && resp.Error.Data.Reason != "" { - reason = resp.Error.Data.Reason - } - - evt := GuardPolicyEvent{ - Timestamp: entry.Timestamp, - ServerID: entry.ServerID, - ToolName: toolName, - ErrorCode: resp.Error.Code, - Reason: reason, - Message: resp.Error.Message, - } - if resp.Error.Data != nil { - evt.Details = resp.Error.Data.Details - evt.Repository = resp.Error.Data.Repository - } - metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, evt) - } - } - - // Calculate duration by matching with pending request - if resp.ID != nil && entry.Timestamp != "" { - key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) - if pending, ok := pendingRequests[key]; ok { - delete(pendingRequests, key) - if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { - durationMs := float64(t.Sub(pending.Timestamp).Milliseconds()) - if durationMs >= 0 { - server := getOrCreateServer(metrics, entry.ServerID) - server.TotalDuration += durationMs - metrics.TotalDuration += durationMs - - tool := getOrCreateTool(server, pending.ToolName) - tool.TotalDuration += durationMs - if tool.MaxDuration == 0 || durationMs > tool.MaxDuration { - tool.MaxDuration = durationMs - } - if tool.MinDuration == 0 || durationMs < tool.MinDuration { - tool.MinDuration = durationMs - } - - if resp.Error != nil { - tool.ErrorCount++ - } - } - } - } - } - } - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading rpc-messages.jsonl: %w", err) - } - - calculateGatewayAggregates(metrics) - - gatewayLogsLog.Printf("Successfully parsed rpc-messages.jsonl: %d servers, %d total requests", - len(metrics.Servers), metrics.TotalRequests) - - return metrics, nil -} - // findRPCMessagesPath returns the path to rpc-messages.jsonl if it exists, or "" if not found. func findRPCMessagesPath(logDir string) string { // Check mcp-logs subdirectory (standard location) @@ -525,136 +297,6 @@ func parseGatewayLogs(logDir string, verbose bool) (*GatewayMetrics, error) { return metrics, nil } -// processGatewayLogEntry processes a single log entry and updates metrics -func processGatewayLogEntry(entry *GatewayLogEntry, metrics *GatewayMetrics, verbose bool) { - // Parse timestamp for time range (supports both RFC3339 and RFC3339Nano) - if entry.Timestamp != "" { - t, err := time.Parse(time.RFC3339Nano, entry.Timestamp) - if err != nil { - t, err = time.Parse(time.RFC3339, entry.Timestamp) - } - if err == nil { - if metrics.StartTime.IsZero() || t.Before(metrics.StartTime) { - metrics.StartTime = t - } - if metrics.EndTime.IsZero() || t.After(metrics.EndTime) { - metrics.EndTime = t - } - } - } - - // Handle DIFC_FILTERED events - if entry.Type == "DIFC_FILTERED" { - metrics.TotalFiltered++ - // DIFC_FILTERED events use server_id; fall back to server_name for compatibility - serverKey := entry.ServerID - if serverKey == "" { - serverKey = entry.ServerName - } - if serverKey != "" { - server := getOrCreateServer(metrics, serverKey) - server.FilteredCount++ - } - metrics.FilteredEvents = append(metrics.FilteredEvents, DifcFilteredEvent{ - Timestamp: entry.Timestamp, - ServerID: serverKey, - ToolName: entry.ToolName, - Description: entry.Description, - Reason: entry.Reason, - SecrecyTags: entry.SecrecyTags, - IntegrityTags: entry.IntegrityTags, - AuthorAssociation: entry.AuthorAssociation, - AuthorLogin: entry.AuthorLogin, - HTMLURL: entry.HTMLURL, - Number: entry.Number, - }) - return - } - - // Handle GUARD_POLICY_BLOCKED events from gateway.jsonl - if entry.Type == "GUARD_POLICY_BLOCKED" { - metrics.TotalGuardBlocked++ - serverKey := entry.ServerID - if serverKey == "" { - serverKey = entry.ServerName - } - if serverKey != "" { - server := getOrCreateServer(metrics, serverKey) - server.GuardPolicyBlocked++ - } - metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, GuardPolicyEvent{ - Timestamp: entry.Timestamp, - ServerID: serverKey, - ToolName: entry.ToolName, - Reason: entry.Reason, - Message: entry.Message, - Details: entry.Description, - }) - return - } - - // Track errors - if entry.Status == "error" || entry.Error != "" { - metrics.TotalErrors++ - if entry.ServerName != "" { - server := getOrCreateServer(metrics, entry.ServerName) - server.ErrorCount++ - - if entry.ToolName != "" { - tool := getOrCreateTool(server, entry.ToolName) - tool.ErrorCount++ - } - } - } - - // Process based on event type - switch entry.Event { - case "request", "tool_call", "rpc_call": - metrics.TotalRequests++ - - if entry.ServerName != "" { - server := getOrCreateServer(metrics, entry.ServerName) - server.RequestCount++ - - if entry.Duration > 0 { - server.TotalDuration += entry.Duration - metrics.TotalDuration += entry.Duration - } - - // Track tool calls - if entry.ToolName != "" || entry.Method != "" { - toolName := entry.ToolName - if toolName == "" { - toolName = entry.Method - } - - metrics.TotalToolCalls++ - server.ToolCallCount++ - - tool := getOrCreateTool(server, toolName) - tool.CallCount++ - - if entry.Duration > 0 { - tool.TotalDuration += entry.Duration - if tool.MaxDuration == 0 || entry.Duration > tool.MaxDuration { - tool.MaxDuration = entry.Duration - } - if tool.MinDuration == 0 || entry.Duration < tool.MinDuration { - tool.MinDuration = entry.Duration - } - } - - if entry.InputSize > 0 { - tool.TotalInputSize += entry.InputSize - } - if entry.OutputSize > 0 { - tool.TotalOutputSize += entry.OutputSize - } - } - } - } -} - // getOrCreateServer gets or creates a server metrics entry func getOrCreateServer(metrics *GatewayMetrics, serverName string) *GatewayServerMetrics { if server, exists := metrics.Servers[serverName]; exists { @@ -692,375 +334,3 @@ func calculateGatewayAggregates(metrics *GatewayMetrics) { } } } - -// buildToolCallsFromRPCMessages reads rpc-messages.jsonl and builds MCPToolCall records. -// Duration is computed by pairing outgoing requests with incoming responses. -// Input/output sizes are not available in rpc-messages.jsonl and will be 0. -func buildToolCallsFromRPCMessages(logPath string) ([]MCPToolCall, error) { - file, err := os.Open(logPath) - if err != nil { - return nil, fmt.Errorf("failed to open rpc-messages.jsonl: %w", err) - } - defer file.Close() - - type pendingCall struct { - serverID string - toolName string - timestamp time.Time - } - pending := make(map[string]*pendingCall) // key: "/" - - // Collect requests first to pair with responses - type rawEntry struct { - entry RPCMessageEntry - req rpcRequestPayload - resp rpcResponsePayload - valid bool - } - var entries []rawEntry - - scanner := bufio.NewScanner(file) - buf := make([]byte, maxScannerBufferSize) - scanner.Buffer(buf, maxScannerBufferSize) - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - var e RPCMessageEntry - if err := json.Unmarshal([]byte(line), &e); err != nil { - continue - } - entries = append(entries, rawEntry{entry: e, valid: true}) - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading rpc-messages.jsonl: %w", err) - } - - // Second pass: build MCPToolCall records. - // Declared before first pass so requests without IDs can be appended immediately. - var toolCalls []MCPToolCall - processedKeys := make(map[string]bool) - - // First pass: index outgoing tool-call requests by (serverID, id) - for i := range entries { - e := &entries[i] - if e.entry.Direction != "OUT" || e.entry.Type != "REQUEST" { - continue - } - if err := json.Unmarshal(e.entry.Payload, &e.req); err != nil || e.req.Method != "tools/call" { - continue - } - var params rpcToolCallParams - if err := json.Unmarshal(e.req.Params, ¶ms); err != nil || params.Name == "" { - continue - } - if e.req.ID == nil { - // Requests without an ID cannot be matched to responses. - // Emit the tool call immediately with "unknown" status so it appears - // in the tool_calls list (same as parseRPCMessages counts it in the summary). - toolCalls = append(toolCalls, MCPToolCall{ - Timestamp: e.entry.Timestamp, - ServerName: e.entry.ServerID, - ToolName: params.Name, - Status: "unknown", - }) - continue - } - t, err := time.Parse(time.RFC3339Nano, e.entry.Timestamp) - if err != nil { - continue - } - key := fmt.Sprintf("%s/%v", e.entry.ServerID, e.req.ID) - pending[key] = &pendingCall{ - serverID: e.entry.ServerID, - toolName: params.Name, - timestamp: t, - } - } - - // Second pass: pair responses with pending requests to compute durations - - for i := range entries { - e := &entries[i] - switch { - case e.entry.Direction == "OUT" && e.entry.Type == "REQUEST": - // Outgoing tool-call request – we'll emit the record when we see the response - // (or after if no response found) - case e.entry.Direction == "IN" && e.entry.Type == "RESPONSE": - if err := json.Unmarshal(e.entry.Payload, &e.resp); err != nil { - continue - } - if e.resp.ID == nil { - continue - } - key := fmt.Sprintf("%s/%v", e.entry.ServerID, e.resp.ID) - p, ok := pending[key] - if !ok { - continue - } - processedKeys[key] = true - - call := MCPToolCall{ - Timestamp: p.timestamp.Format(time.RFC3339Nano), - ServerName: p.serverID, - ToolName: p.toolName, - Status: "success", - } - if e.resp.Error != nil { - call.Status = "error" - call.Error = e.resp.Error.Message - } - if t, err := time.Parse(time.RFC3339Nano, e.entry.Timestamp); err == nil { - d := t.Sub(p.timestamp) - if d >= 0 { - call.Duration = timeutil.FormatDuration(d) - } - } - toolCalls = append(toolCalls, call) - } - } - - // Emit any requests that never received a response - for key, p := range pending { - if !processedKeys[key] { - toolCalls = append(toolCalls, MCPToolCall{ - Timestamp: p.timestamp.Format(time.RFC3339Nano), - ServerName: p.serverID, - ToolName: p.toolName, - Status: "unknown", - }) - } - } - - return toolCalls, nil -} - -// extractMCPToolUsageData creates detailed MCP tool usage data from gateway metrics -func extractMCPToolUsageData(logDir string, verbose bool) (*MCPToolUsageData, error) { - // Parse gateway logs (falls back to rpc-messages.jsonl automatically) - gatewayMetrics, err := parseGatewayLogs(logDir, verbose) - if err != nil { - // Return nil if no log file exists (not an error for workflows without MCP) - if strings.Contains(err.Error(), "not found") { - return nil, nil - } - return nil, fmt.Errorf("failed to parse gateway logs: %w", err) - } - - if gatewayMetrics == nil || len(gatewayMetrics.Servers) == 0 { - return nil, nil - } - - mcpData := &MCPToolUsageData{ - Summary: []MCPToolSummary{}, - ToolCalls: []MCPToolCall{}, - Servers: []MCPServerStats{}, - FilteredEvents: gatewayMetrics.FilteredEvents, - } - - // Build guard policy summary if there are guard policy events - if len(gatewayMetrics.GuardPolicyEvents) > 0 { - mcpData.GuardPolicySummary = buildGuardPolicySummary(gatewayMetrics) - } - - // Read the log file again to get individual tool call records. - // Prefer gateway.jsonl; fall back to rpc-messages.jsonl when not available. - gatewayLogPath := filepath.Join(logDir, "gateway.jsonl") - usingRPCMessages := false - - if _, err := os.Stat(gatewayLogPath); os.IsNotExist(err) { - mcpLogsPath := filepath.Join(logDir, "mcp-logs", "gateway.jsonl") - if _, err := os.Stat(mcpLogsPath); os.IsNotExist(err) { - // Fall back to rpc-messages.jsonl - rpcPath := findRPCMessagesPath(logDir) - if rpcPath == "" { - return nil, errors.New("gateway.jsonl not found") - } - gatewayLogPath = rpcPath - usingRPCMessages = true - } else { - gatewayLogPath = mcpLogsPath - } - } - - if usingRPCMessages { - // Build tool call records from rpc-messages.jsonl - toolCalls, err := buildToolCallsFromRPCMessages(gatewayLogPath) - if err != nil { - return nil, fmt.Errorf("failed to read rpc-messages.jsonl: %w", err) - } - mcpData.ToolCalls = toolCalls - } else { - file, err := os.Open(gatewayLogPath) - if err != nil { - return nil, fmt.Errorf("failed to open gateway.jsonl: %w", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - buf := make([]byte, maxScannerBufferSize) - scanner.Buffer(buf, maxScannerBufferSize) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - var entry GatewayLogEntry - if err := json.Unmarshal([]byte(line), &entry); err != nil { - continue // Skip malformed lines - } - - // Only process tool call events - if entry.Event == "tool_call" || entry.Event == "rpc_call" || entry.Event == "request" { - toolName := entry.ToolName - if toolName == "" { - toolName = entry.Method - } - - // Skip entries without tool information - if entry.ServerName == "" || toolName == "" { - continue - } - - // Create individual tool call record - toolCall := MCPToolCall{ - Timestamp: entry.Timestamp, - ServerName: entry.ServerName, - ToolName: toolName, - Method: entry.Method, - InputSize: entry.InputSize, - OutputSize: entry.OutputSize, - Status: entry.Status, - Error: entry.Error, - } - - if entry.Duration > 0 { - toolCall.Duration = timeutil.FormatDuration(time.Duration(entry.Duration * float64(time.Millisecond))) - } - - mcpData.ToolCalls = append(mcpData.ToolCalls, toolCall) - } - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading gateway.jsonl: %w", err) - } - } - - // Build summary statistics from aggregated metrics - for serverName, serverMetrics := range gatewayMetrics.Servers { - // Server-level stats - serverStats := MCPServerStats{ - ServerName: serverName, - RequestCount: serverMetrics.RequestCount, - ToolCallCount: serverMetrics.ToolCallCount, - TotalInputSize: 0, - TotalOutputSize: 0, - ErrorCount: serverMetrics.ErrorCount, - } - - if serverMetrics.RequestCount > 0 { - avgDur := serverMetrics.TotalDuration / float64(serverMetrics.RequestCount) - serverStats.AvgDuration = timeutil.FormatDuration(time.Duration(avgDur * float64(time.Millisecond))) - } - - // Tool-level stats - for toolName, toolMetrics := range serverMetrics.Tools { - summary := MCPToolSummary{ - ServerName: serverName, - ToolName: toolName, - CallCount: toolMetrics.CallCount, - TotalInputSize: toolMetrics.TotalInputSize, - TotalOutputSize: toolMetrics.TotalOutputSize, - MaxInputSize: 0, // Will be calculated below - MaxOutputSize: 0, // Will be calculated below - ErrorCount: toolMetrics.ErrorCount, - } - - if toolMetrics.AvgDuration > 0 { - summary.AvgDuration = timeutil.FormatDuration(time.Duration(toolMetrics.AvgDuration * float64(time.Millisecond))) - } - if toolMetrics.MaxDuration > 0 { - summary.MaxDuration = timeutil.FormatDuration(time.Duration(toolMetrics.MaxDuration * float64(time.Millisecond))) - } - - // Calculate max input/output sizes from individual tool calls - for _, tc := range mcpData.ToolCalls { - if tc.ServerName == serverName && tc.ToolName == toolName { - if tc.InputSize > summary.MaxInputSize { - summary.MaxInputSize = tc.InputSize - } - if tc.OutputSize > summary.MaxOutputSize { - summary.MaxOutputSize = tc.OutputSize - } - } - } - - mcpData.Summary = append(mcpData.Summary, summary) - - // Update server totals - serverStats.TotalInputSize += toolMetrics.TotalInputSize - serverStats.TotalOutputSize += toolMetrics.TotalOutputSize - } - - mcpData.Servers = append(mcpData.Servers, serverStats) - } - - // Sort summaries by server name, then tool name - sort.Slice(mcpData.Summary, func(i, j int) bool { - if mcpData.Summary[i].ServerName != mcpData.Summary[j].ServerName { - return mcpData.Summary[i].ServerName < mcpData.Summary[j].ServerName - } - return mcpData.Summary[i].ToolName < mcpData.Summary[j].ToolName - }) - - // Sort servers by name - sort.Slice(mcpData.Servers, func(i, j int) bool { - return mcpData.Servers[i].ServerName < mcpData.Servers[j].ServerName - }) - - return mcpData, nil -} - -// buildGuardPolicySummary creates a GuardPolicySummary from GatewayMetrics. -func buildGuardPolicySummary(metrics *GatewayMetrics) *GuardPolicySummary { - summary := &GuardPolicySummary{ - TotalBlocked: metrics.TotalGuardBlocked, - Events: metrics.GuardPolicyEvents, - BlockedToolCounts: make(map[string]int), - BlockedServerCounts: make(map[string]int), - } - - for _, evt := range metrics.GuardPolicyEvents { - // Categorize by error code - switch evt.ErrorCode { - case guardPolicyErrorCodeIntegrityBelowMin: - summary.IntegrityBlocked++ - case guardPolicyErrorCodeRepoNotAllowed: - summary.RepoScopeBlocked++ - case guardPolicyErrorCodeAccessDenied: - summary.AccessDenied++ - case guardPolicyErrorCodeBlockedUser: - summary.BlockedUserDenied++ - case guardPolicyErrorCodeInsufficientPerms: - summary.PermissionDenied++ - case guardPolicyErrorCodePrivateRepoDenied: - summary.PrivateRepoDenied++ - } - - // Track per-tool blocked counts - if evt.ToolName != "" { - summary.BlockedToolCounts[evt.ToolName]++ - } - - // Track per-server blocked counts - if evt.ServerID != "" { - summary.BlockedServerCounts[evt.ServerID]++ - } - } - - return summary -} diff --git a/pkg/cli/gateway_logs_mcp.go b/pkg/cli/gateway_logs_mcp.go new file mode 100644 index 00000000000..5109d918ce4 --- /dev/null +++ b/pkg/cli/gateway_logs_mcp.go @@ -0,0 +1,208 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (gateway_logs_mcp.go) contains MCP tool usage extraction logic +// for MCP gateway logs — aggregating per-server and per-tool statistics +// into MCPToolUsageData for report building. + +package cli + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/github/gh-aw/pkg/timeutil" +) + +// extractMCPToolUsageData creates detailed MCP tool usage data from gateway metrics +func extractMCPToolUsageData(logDir string, verbose bool) (*MCPToolUsageData, error) { + // Parse gateway logs (falls back to rpc-messages.jsonl automatically) + gatewayMetrics, err := parseGatewayLogs(logDir, verbose) + if err != nil { + // Return nil if no log file exists (not an error for workflows without MCP) + if strings.Contains(err.Error(), "not found") { + return nil, nil + } + return nil, fmt.Errorf("failed to parse gateway logs: %w", err) + } + + if gatewayMetrics == nil || len(gatewayMetrics.Servers) == 0 { + return nil, nil + } + + mcpData := &MCPToolUsageData{ + Summary: []MCPToolSummary{}, + ToolCalls: []MCPToolCall{}, + Servers: []MCPServerStats{}, + FilteredEvents: gatewayMetrics.FilteredEvents, + } + + // Build guard policy summary if there are guard policy events + if len(gatewayMetrics.GuardPolicyEvents) > 0 { + mcpData.GuardPolicySummary = buildGuardPolicySummary(gatewayMetrics) + } + + // Read the log file again to get individual tool call records. + // Prefer gateway.jsonl; fall back to rpc-messages.jsonl when not available. + gatewayLogPath := filepath.Join(logDir, "gateway.jsonl") + usingRPCMessages := false + + if _, err := os.Stat(gatewayLogPath); os.IsNotExist(err) { + mcpLogsPath := filepath.Join(logDir, "mcp-logs", "gateway.jsonl") + if _, err := os.Stat(mcpLogsPath); os.IsNotExist(err) { + // Fall back to rpc-messages.jsonl + rpcPath := findRPCMessagesPath(logDir) + if rpcPath == "" { + return nil, errors.New("gateway.jsonl not found") + } + gatewayLogPath = rpcPath + usingRPCMessages = true + } else { + gatewayLogPath = mcpLogsPath + } + } + + if usingRPCMessages { + // Build tool call records from rpc-messages.jsonl + toolCalls, err := buildToolCallsFromRPCMessages(gatewayLogPath) + if err != nil { + return nil, fmt.Errorf("failed to read rpc-messages.jsonl: %w", err) + } + mcpData.ToolCalls = toolCalls + } else { + file, err := os.Open(gatewayLogPath) + if err != nil { + return nil, fmt.Errorf("failed to open gateway.jsonl: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + buf := make([]byte, maxScannerBufferSize) + scanner.Buffer(buf, maxScannerBufferSize) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var entry GatewayLogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + continue // Skip malformed lines + } + + // Only process tool call events + if entry.Event == "tool_call" || entry.Event == "rpc_call" || entry.Event == "request" { + toolName := entry.ToolName + if toolName == "" { + toolName = entry.Method + } + + // Skip entries without tool information + if entry.ServerName == "" || toolName == "" { + continue + } + + // Create individual tool call record + toolCall := MCPToolCall{ + Timestamp: entry.Timestamp, + ServerName: entry.ServerName, + ToolName: toolName, + Method: entry.Method, + InputSize: entry.InputSize, + OutputSize: entry.OutputSize, + Status: entry.Status, + Error: entry.Error, + } + + if entry.Duration > 0 { + toolCall.Duration = timeutil.FormatDuration(time.Duration(entry.Duration * float64(time.Millisecond))) + } + + mcpData.ToolCalls = append(mcpData.ToolCalls, toolCall) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading gateway.jsonl: %w", err) + } + } + + // Build summary statistics from aggregated metrics + for serverName, serverMetrics := range gatewayMetrics.Servers { + // Server-level stats + serverStats := MCPServerStats{ + ServerName: serverName, + RequestCount: serverMetrics.RequestCount, + ToolCallCount: serverMetrics.ToolCallCount, + TotalInputSize: 0, + TotalOutputSize: 0, + ErrorCount: serverMetrics.ErrorCount, + } + + if serverMetrics.RequestCount > 0 { + avgDur := serverMetrics.TotalDuration / float64(serverMetrics.RequestCount) + serverStats.AvgDuration = timeutil.FormatDuration(time.Duration(avgDur * float64(time.Millisecond))) + } + + // Tool-level stats + for toolName, toolMetrics := range serverMetrics.Tools { + summary := MCPToolSummary{ + ServerName: serverName, + ToolName: toolName, + CallCount: toolMetrics.CallCount, + TotalInputSize: toolMetrics.TotalInputSize, + TotalOutputSize: toolMetrics.TotalOutputSize, + MaxInputSize: 0, // Will be calculated below + MaxOutputSize: 0, // Will be calculated below + ErrorCount: toolMetrics.ErrorCount, + } + + if toolMetrics.AvgDuration > 0 { + summary.AvgDuration = timeutil.FormatDuration(time.Duration(toolMetrics.AvgDuration * float64(time.Millisecond))) + } + if toolMetrics.MaxDuration > 0 { + summary.MaxDuration = timeutil.FormatDuration(time.Duration(toolMetrics.MaxDuration * float64(time.Millisecond))) + } + + // Calculate max input/output sizes from individual tool calls + for _, tc := range mcpData.ToolCalls { + if tc.ServerName == serverName && tc.ToolName == toolName { + if tc.InputSize > summary.MaxInputSize { + summary.MaxInputSize = tc.InputSize + } + if tc.OutputSize > summary.MaxOutputSize { + summary.MaxOutputSize = tc.OutputSize + } + } + } + + mcpData.Summary = append(mcpData.Summary, summary) + + // Update server totals + serverStats.TotalInputSize += toolMetrics.TotalInputSize + serverStats.TotalOutputSize += toolMetrics.TotalOutputSize + } + + mcpData.Servers = append(mcpData.Servers, serverStats) + } + + // Sort summaries by server name, then tool name + sort.Slice(mcpData.Summary, func(i, j int) bool { + if mcpData.Summary[i].ServerName != mcpData.Summary[j].ServerName { + return mcpData.Summary[i].ServerName < mcpData.Summary[j].ServerName + } + return mcpData.Summary[i].ToolName < mcpData.Summary[j].ToolName + }) + + // Sort servers by name + sort.Slice(mcpData.Servers, func(i, j int) bool { + return mcpData.Servers[i].ServerName < mcpData.Servers[j].ServerName + }) + + return mcpData, nil +} diff --git a/pkg/cli/gateway_logs_parser.go b/pkg/cli/gateway_logs_parser.go new file mode 100644 index 00000000000..505228f837c --- /dev/null +++ b/pkg/cli/gateway_logs_parser.go @@ -0,0 +1,498 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (gateway_logs_parser.go) contains log parsing functions for +// MCP gateway logs — parsing rpc-messages.jsonl and gateway.jsonl formats, +// and building MCPToolCall records from RPC message traces. + +package cli + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/timeutil" +) + +// parseRPCMessages parses a rpc-messages.jsonl file and extracts GatewayMetrics. +// This is the canonical fallback when gateway.jsonl is not available. +func parseRPCMessages(logPath string, verbose bool) (*GatewayMetrics, error) { + gatewayLogsLog.Printf("Parsing rpc-messages.jsonl from: %s", logPath) + + file, err := os.Open(logPath) + if err != nil { + return nil, fmt.Errorf("failed to open rpc-messages.jsonl: %w", err) + } + defer file.Close() + + metrics := &GatewayMetrics{ + Servers: make(map[string]*GatewayServerMetrics), + } + + // Track pending requests by (serverID, id) for duration calculation. + // Key format: "/" + pendingRequests := make(map[string]*rpcPendingRequest) + + scanner := bufio.NewScanner(file) + // Increase scanner buffer for large payloads + buf := make([]byte, maxScannerBufferSize) + scanner.Buffer(buf, maxScannerBufferSize) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var entry RPCMessageEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + gatewayLogsLog.Printf("Failed to parse rpc-messages.jsonl line %d: %v", lineNum, err) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage( + fmt.Sprintf("Failed to parse rpc-messages.jsonl line %d: %v", lineNum, err))) + } + continue + } + + // Update time range + if entry.Timestamp != "" { + if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { + if metrics.StartTime.IsZero() || t.Before(metrics.StartTime) { + metrics.StartTime = t + } + if metrics.EndTime.IsZero() || t.After(metrics.EndTime) { + metrics.EndTime = t + } + } + } + + if entry.ServerID == "" { + continue + } + + switch { + case entry.Type == "DIFC_FILTERED": + // DIFC integrity/secrecy filter event — not a REQUEST or RESPONSE + metrics.TotalFiltered++ + server := getOrCreateServer(metrics, entry.ServerID) + server.FilteredCount++ + metrics.FilteredEvents = append(metrics.FilteredEvents, DifcFilteredEvent{ + Timestamp: entry.Timestamp, + ServerID: entry.ServerID, + ToolName: entry.ToolName, + Description: entry.Description, + Reason: entry.Reason, + SecrecyTags: entry.SecrecyTags, + IntegrityTags: entry.IntegrityTags, + AuthorAssociation: entry.AuthorAssociation, + AuthorLogin: entry.AuthorLogin, + HTMLURL: entry.HTMLURL, + Number: entry.Number, + }) + + case entry.Direction == "OUT" && entry.Type == "REQUEST": + // Outgoing request from AI engine to MCP server + var req rpcRequestPayload + if err := json.Unmarshal(entry.Payload, &req); err != nil { + continue + } + if req.Method != "tools/call" { + continue + } + + // Extract tool name + var params rpcToolCallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.Name == "" { + continue + } + + metrics.TotalRequests++ + server := getOrCreateServer(metrics, entry.ServerID) + server.RequestCount++ + metrics.TotalToolCalls++ + server.ToolCallCount++ + + tool := getOrCreateTool(server, params.Name) + tool.CallCount++ + + // Store pending request for duration calculation + if req.ID != nil && entry.Timestamp != "" { + if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { + key := fmt.Sprintf("%s/%v", entry.ServerID, req.ID) + pendingRequests[key] = &rpcPendingRequest{ + ServerID: entry.ServerID, + ToolName: params.Name, + Timestamp: t, + } + } + } + + case entry.Direction == "IN" && entry.Type == "RESPONSE": + // Incoming response from MCP server to AI engine + var resp rpcResponsePayload + if err := json.Unmarshal(entry.Payload, &resp); err != nil { + continue + } + + // Track errors and detect guard policy blocks + if resp.Error != nil { + metrics.TotalErrors++ + server := getOrCreateServer(metrics, entry.ServerID) + server.ErrorCount++ + + // Detect guard policy enforcement errors + if isGuardPolicyErrorCode(resp.Error.Code) { + metrics.TotalGuardBlocked++ + server.GuardPolicyBlocked++ + + // Determine tool name from pending request if available + toolName := "" + if resp.ID != nil { + key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) + if pending, ok := pendingRequests[key]; ok { + toolName = pending.ToolName + } + } + + reason := guardPolicyReasonFromCode(resp.Error.Code) + if resp.Error.Data != nil && resp.Error.Data.Reason != "" { + reason = resp.Error.Data.Reason + } + + evt := GuardPolicyEvent{ + Timestamp: entry.Timestamp, + ServerID: entry.ServerID, + ToolName: toolName, + ErrorCode: resp.Error.Code, + Reason: reason, + Message: resp.Error.Message, + } + if resp.Error.Data != nil { + evt.Details = resp.Error.Data.Details + evt.Repository = resp.Error.Data.Repository + } + metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, evt) + } + } + + // Calculate duration by matching with pending request + if resp.ID != nil && entry.Timestamp != "" { + key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) + if pending, ok := pendingRequests[key]; ok { + delete(pendingRequests, key) + if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { + durationMs := float64(t.Sub(pending.Timestamp).Milliseconds()) + if durationMs >= 0 { + server := getOrCreateServer(metrics, entry.ServerID) + server.TotalDuration += durationMs + metrics.TotalDuration += durationMs + + tool := getOrCreateTool(server, pending.ToolName) + tool.TotalDuration += durationMs + if tool.MaxDuration == 0 || durationMs > tool.MaxDuration { + tool.MaxDuration = durationMs + } + if tool.MinDuration == 0 || durationMs < tool.MinDuration { + tool.MinDuration = durationMs + } + + if resp.Error != nil { + tool.ErrorCount++ + } + } + } + } + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading rpc-messages.jsonl: %w", err) + } + + calculateGatewayAggregates(metrics) + + gatewayLogsLog.Printf("Successfully parsed rpc-messages.jsonl: %d servers, %d total requests", + len(metrics.Servers), metrics.TotalRequests) + + return metrics, nil +} + +// processGatewayLogEntry processes a single log entry and updates metrics +func processGatewayLogEntry(entry *GatewayLogEntry, metrics *GatewayMetrics, verbose bool) { + // Parse timestamp for time range (supports both RFC3339 and RFC3339Nano) + if entry.Timestamp != "" { + t, err := time.Parse(time.RFC3339Nano, entry.Timestamp) + if err != nil { + t, err = time.Parse(time.RFC3339, entry.Timestamp) + } + if err == nil { + if metrics.StartTime.IsZero() || t.Before(metrics.StartTime) { + metrics.StartTime = t + } + if metrics.EndTime.IsZero() || t.After(metrics.EndTime) { + metrics.EndTime = t + } + } + } + + // Handle DIFC_FILTERED events + if entry.Type == "DIFC_FILTERED" { + metrics.TotalFiltered++ + // DIFC_FILTERED events use server_id; fall back to server_name for compatibility + serverKey := entry.ServerID + if serverKey == "" { + serverKey = entry.ServerName + } + if serverKey != "" { + server := getOrCreateServer(metrics, serverKey) + server.FilteredCount++ + } + metrics.FilteredEvents = append(metrics.FilteredEvents, DifcFilteredEvent{ + Timestamp: entry.Timestamp, + ServerID: serverKey, + ToolName: entry.ToolName, + Description: entry.Description, + Reason: entry.Reason, + SecrecyTags: entry.SecrecyTags, + IntegrityTags: entry.IntegrityTags, + AuthorAssociation: entry.AuthorAssociation, + AuthorLogin: entry.AuthorLogin, + HTMLURL: entry.HTMLURL, + Number: entry.Number, + }) + return + } + + // Handle GUARD_POLICY_BLOCKED events from gateway.jsonl + if entry.Type == "GUARD_POLICY_BLOCKED" { + metrics.TotalGuardBlocked++ + serverKey := entry.ServerID + if serverKey == "" { + serverKey = entry.ServerName + } + if serverKey != "" { + server := getOrCreateServer(metrics, serverKey) + server.GuardPolicyBlocked++ + } + metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, GuardPolicyEvent{ + Timestamp: entry.Timestamp, + ServerID: serverKey, + ToolName: entry.ToolName, + Reason: entry.Reason, + Message: entry.Message, + Details: entry.Description, + }) + return + } + + // Track errors + if entry.Status == "error" || entry.Error != "" { + metrics.TotalErrors++ + if entry.ServerName != "" { + server := getOrCreateServer(metrics, entry.ServerName) + server.ErrorCount++ + + if entry.ToolName != "" { + tool := getOrCreateTool(server, entry.ToolName) + tool.ErrorCount++ + } + } + } + + // Process based on event type + switch entry.Event { + case "request", "tool_call", "rpc_call": + metrics.TotalRequests++ + + if entry.ServerName != "" { + server := getOrCreateServer(metrics, entry.ServerName) + server.RequestCount++ + + if entry.Duration > 0 { + server.TotalDuration += entry.Duration + metrics.TotalDuration += entry.Duration + } + + // Track tool calls + if entry.ToolName != "" || entry.Method != "" { + toolName := entry.ToolName + if toolName == "" { + toolName = entry.Method + } + + metrics.TotalToolCalls++ + server.ToolCallCount++ + + tool := getOrCreateTool(server, toolName) + tool.CallCount++ + + if entry.Duration > 0 { + tool.TotalDuration += entry.Duration + if tool.MaxDuration == 0 || entry.Duration > tool.MaxDuration { + tool.MaxDuration = entry.Duration + } + if tool.MinDuration == 0 || entry.Duration < tool.MinDuration { + tool.MinDuration = entry.Duration + } + } + + if entry.InputSize > 0 { + tool.TotalInputSize += entry.InputSize + } + if entry.OutputSize > 0 { + tool.TotalOutputSize += entry.OutputSize + } + } + } + } +} + +// buildToolCallsFromRPCMessages reads rpc-messages.jsonl and builds MCPToolCall records. +// Duration is computed by pairing outgoing requests with incoming responses. +// Input/output sizes are not available in rpc-messages.jsonl and will be 0. +func buildToolCallsFromRPCMessages(logPath string) ([]MCPToolCall, error) { + file, err := os.Open(logPath) + if err != nil { + return nil, fmt.Errorf("failed to open rpc-messages.jsonl: %w", err) + } + defer file.Close() + + type pendingCall struct { + serverID string + toolName string + timestamp time.Time + } + pending := make(map[string]*pendingCall) // key: "/" + + // Collect requests first to pair with responses + type rawEntry struct { + entry RPCMessageEntry + req rpcRequestPayload + resp rpcResponsePayload + valid bool + } + var entries []rawEntry + + scanner := bufio.NewScanner(file) + buf := make([]byte, maxScannerBufferSize) + scanner.Buffer(buf, maxScannerBufferSize) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var e RPCMessageEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + continue + } + entries = append(entries, rawEntry{entry: e, valid: true}) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading rpc-messages.jsonl: %w", err) + } + + // Second pass: build MCPToolCall records. + // Declared before first pass so requests without IDs can be appended immediately. + var toolCalls []MCPToolCall + processedKeys := make(map[string]bool) + + // First pass: index outgoing tool-call requests by (serverID, id) + for i := range entries { + e := &entries[i] + if e.entry.Direction != "OUT" || e.entry.Type != "REQUEST" { + continue + } + if err := json.Unmarshal(e.entry.Payload, &e.req); err != nil || e.req.Method != "tools/call" { + continue + } + var params rpcToolCallParams + if err := json.Unmarshal(e.req.Params, ¶ms); err != nil || params.Name == "" { + continue + } + if e.req.ID == nil { + // Requests without an ID cannot be matched to responses. + // Emit the tool call immediately with "unknown" status so it appears + // in the tool_calls list (same as parseRPCMessages counts it in the summary). + toolCalls = append(toolCalls, MCPToolCall{ + Timestamp: e.entry.Timestamp, + ServerName: e.entry.ServerID, + ToolName: params.Name, + Status: "unknown", + }) + continue + } + t, err := time.Parse(time.RFC3339Nano, e.entry.Timestamp) + if err != nil { + continue + } + key := fmt.Sprintf("%s/%v", e.entry.ServerID, e.req.ID) + pending[key] = &pendingCall{ + serverID: e.entry.ServerID, + toolName: params.Name, + timestamp: t, + } + } + + // Second pass: pair responses with pending requests to compute durations + + for i := range entries { + e := &entries[i] + switch { + case e.entry.Direction == "OUT" && e.entry.Type == "REQUEST": + // Outgoing tool-call request – we'll emit the record when we see the response + // (or after if no response found) + case e.entry.Direction == "IN" && e.entry.Type == "RESPONSE": + if err := json.Unmarshal(e.entry.Payload, &e.resp); err != nil { + continue + } + if e.resp.ID == nil { + continue + } + key := fmt.Sprintf("%s/%v", e.entry.ServerID, e.resp.ID) + p, ok := pending[key] + if !ok { + continue + } + processedKeys[key] = true + + call := MCPToolCall{ + Timestamp: p.timestamp.Format(time.RFC3339Nano), + ServerName: p.serverID, + ToolName: p.toolName, + Status: "success", + } + if e.resp.Error != nil { + call.Status = "error" + call.Error = e.resp.Error.Message + } + if t, err := time.Parse(time.RFC3339Nano, e.entry.Timestamp); err == nil { + d := t.Sub(p.timestamp) + if d >= 0 { + call.Duration = timeutil.FormatDuration(d) + } + } + toolCalls = append(toolCalls, call) + } + } + + // Emit any requests that never received a response + for key, p := range pending { + if !processedKeys[key] { + toolCalls = append(toolCalls, MCPToolCall{ + Timestamp: p.timestamp.Format(time.RFC3339Nano), + ServerName: p.serverID, + ToolName: p.toolName, + Status: "unknown", + }) + } + } + + return toolCalls, nil +} diff --git a/pkg/cli/gateway_logs_policy.go b/pkg/cli/gateway_logs_policy.go new file mode 100644 index 00000000000..e135aecfa9a --- /dev/null +++ b/pkg/cli/gateway_logs_policy.go @@ -0,0 +1,71 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (gateway_logs_policy.go) contains guard policy enforcement logic +// for MCP gateway logs — error code classification and policy summary building. + +package cli + +// isGuardPolicyErrorCode returns true if the JSON-RPC error code indicates a +// guard policy enforcement decision. +func isGuardPolicyErrorCode(code int) bool { + return code >= guardPolicyErrorCodeIntegrityBelowMin && code <= guardPolicyErrorCodeAccessDenied +} + +// guardPolicyReasonFromCode returns a human-readable reason string for a guard policy error code. +func guardPolicyReasonFromCode(code int) string { + switch code { + case guardPolicyErrorCodeAccessDenied: + return "access_denied" + case guardPolicyErrorCodeRepoNotAllowed: + return "repo_not_allowed" + case guardPolicyErrorCodeInsufficientPerms: + return "insufficient_permissions" + case guardPolicyErrorCodePrivateRepoDenied: + return "private_repo_denied" + case guardPolicyErrorCodeBlockedUser: + return "blocked_user" + case guardPolicyErrorCodeIntegrityBelowMin: + return "integrity_below_minimum" + default: + return "unknown" + } +} + +// buildGuardPolicySummary creates a GuardPolicySummary from GatewayMetrics. +func buildGuardPolicySummary(metrics *GatewayMetrics) *GuardPolicySummary { + summary := &GuardPolicySummary{ + TotalBlocked: metrics.TotalGuardBlocked, + Events: metrics.GuardPolicyEvents, + BlockedToolCounts: make(map[string]int), + BlockedServerCounts: make(map[string]int), + } + + for _, evt := range metrics.GuardPolicyEvents { + // Categorize by error code + switch evt.ErrorCode { + case guardPolicyErrorCodeIntegrityBelowMin: + summary.IntegrityBlocked++ + case guardPolicyErrorCodeRepoNotAllowed: + summary.RepoScopeBlocked++ + case guardPolicyErrorCodeAccessDenied: + summary.AccessDenied++ + case guardPolicyErrorCodeBlockedUser: + summary.BlockedUserDenied++ + case guardPolicyErrorCodeInsufficientPerms: + summary.PermissionDenied++ + case guardPolicyErrorCodePrivateRepoDenied: + summary.PrivateRepoDenied++ + } + + // Track per-tool blocked counts + if evt.ToolName != "" { + summary.BlockedToolCounts[evt.ToolName]++ + } + + // Track per-server blocked counts + if evt.ServerID != "" { + summary.BlockedServerCounts[evt.ServerID]++ + } + } + + return summary +} diff --git a/pkg/cli/logs_ci_scenario_test.go b/pkg/cli/logs_ci_scenario_test.go index 69d9db19440..57e927eca69 100644 --- a/pkg/cli/logs_ci_scenario_test.go +++ b/pkg/cli/logs_ci_scenario_test.go @@ -29,33 +29,15 @@ func TestLogsJSONOutputWithNoRuns(t *testing.T) { // Call DownloadWorkflowLogs with parameters that will result in no matching runs // We use a non-existent workflow name to ensure no results - err := DownloadWorkflowLogs( - ctx, - "nonexistent-workflow-12345", // Workflow that doesn't exist - 2, // count - "", // startDate - "", // endDate - tmpDir, // outputDir - "copilot", // engine - "", // ref - 0, // beforeRunID - 0, // afterRunID - "", // repoOverride - false, // verbose - false, // toolGraph - false, // noStaged - false, // firewallOnly - false, // noFirewall - false, // parse - true, // jsonOutput - THIS IS KEY - 10, // timeout - "summary.json", // summaryFile - "", // safeOutputType - false, // filteredIntegrity - false, // train - "", // format - nil, // artifactSets - ) + err := DownloadWorkflowLogs(ctx, DownloadConfig{ + WorkflowName: "nonexistent-workflow-12345", // Workflow that doesn't exist + Count: 2, + OutputDir: tmpDir, + Engine: "copilot", + JSONOutput: true, // THIS IS KEY + Timeout: 10, + SummaryFile: "summary.json", + }) // Restore stdout and read output w.Close() diff --git a/pkg/cli/logs_command.go b/pkg/cli/logs_command.go index 4dd7634783d..b7cc0578621 100644 --- a/pkg/cli/logs_command.go +++ b/pkg/cli/logs_command.go @@ -184,7 +184,32 @@ Examples: logsCommandLog.Printf("Executing logs download: workflow=%s, count=%d, engine=%s, train=%v", workflowName, count, engine, train) - return DownloadWorkflowLogs(cmd.Context(), workflowName, count, startDate, endDate, outputDir, engine, ref, beforeRunID, afterRunID, repoOverride, verbose, toolGraph, noStaged, firewallOnly, noFirewall, parse, jsonOutput, timeout, summaryFile, safeOutputType, filteredIntegrity, train, format, artifacts) + return DownloadWorkflowLogs(cmd.Context(), DownloadConfig{ + WorkflowName: workflowName, + Count: count, + StartDate: startDate, + EndDate: endDate, + OutputDir: outputDir, + Engine: engine, + Ref: ref, + BeforeRunID: beforeRunID, + AfterRunID: afterRunID, + RepoOverride: repoOverride, + Verbose: verbose, + ToolGraph: toolGraph, + NoStaged: noStaged, + FirewallOnly: firewallOnly, + NoFirewall: noFirewall, + Parse: parse, + JSONOutput: jsonOutput, + Timeout: timeout, + SummaryFile: summaryFile, + SafeOutputType: safeOutputType, + FilteredIntegrity: filteredIntegrity, + Train: train, + Format: format, + ArtifactSets: artifacts, + }) }, } diff --git a/pkg/cli/logs_download_config.go b/pkg/cli/logs_download_config.go new file mode 100644 index 00000000000..62df4078358 --- /dev/null +++ b/pkg/cli/logs_download_config.go @@ -0,0 +1,82 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (logs_download_config.go) defines the DownloadConfig struct +// used to pass parameters to DownloadWorkflowLogs. + +package cli + +// DownloadConfig holds all parameters for DownloadWorkflowLogs, +// replacing the previous 25-parameter function signature. +type DownloadConfig struct { + // WorkflowName filters to a specific workflow by name (or file path). + // Leave empty to scan all agentic workflows. + WorkflowName string + + // Count is the maximum number of workflow runs to return. + Count int + + // StartDate is the earliest created_at timestamp (RFC 3339 / date string). + StartDate string + + // EndDate is the latest created_at timestamp (RFC 3339 / date string). + EndDate string + + // OutputDir is the local directory where artifacts are downloaded. + OutputDir string + + // Engine filters runs by AI engine name (e.g. "copilot", "claude"). + Engine string + + // Ref filters runs by Git ref (branch or tag). + Ref string + + // BeforeRunID restricts results to runs with ID less than this value. + BeforeRunID int64 + + // AfterRunID restricts results to runs with ID greater than this value. + AfterRunID int64 + + // RepoOverride overrides the target repository (owner/repo). + RepoOverride string + + // Verbose enables verbose diagnostic output to stderr. + Verbose bool + + // ToolGraph enables tool dependency graph rendering. + ToolGraph bool + + // NoStaged skips staged runs (runs created by the staging system). + NoStaged bool + + // FirewallOnly restricts output to runs that contain firewall data. + FirewallOnly bool + + // NoFirewall excludes runs that contain firewall data. + NoFirewall bool + + // Parse enables structured parsing of log content. + Parse bool + + // JSONOutput emits output as JSON to stdout instead of console tables. + JSONOutput bool + + // Timeout is the maximum run time in minutes (0 = unlimited). + Timeout int + + // SummaryFile is the path to write a JSON summary file. + SummaryFile string + + // SafeOutputType filters runs to those containing a specific safe-output type. + SafeOutputType string + + // FilteredIntegrity restricts output to runs with DIFC-filtered events. + FilteredIntegrity bool + + // Train enables training-data export mode. + Train bool + + // Format selects the output format for console rendering. + Format string + + // ArtifactSets is an optional list of artifact set names to download. + ArtifactSets []string +} diff --git a/pkg/cli/logs_download_filter.go b/pkg/cli/logs_download_filter.go new file mode 100644 index 00000000000..4ea7c20eed3 --- /dev/null +++ b/pkg/cli/logs_download_filter.go @@ -0,0 +1,92 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (logs_download_filter.go) contains filter predicate functions used +// by DownloadWorkflowLogs to decide whether to include a given run. + +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/stringutil" +) + +// runContainsSafeOutputType checks if a run's agent_output.json contains a specific safe output type +func runContainsSafeOutputType(runDir string, safeOutputType string, verbose bool) (bool, error) { + logsOrchestratorLog.Printf("Checking run for safe output type: dir=%s, type=%s", runDir, safeOutputType) + // Normalize the type for comparison (convert dashes to underscores) + normalizedType := stringutil.NormalizeSafeOutputIdentifier(safeOutputType) + + // Look for agent_output.json in the run directory + agentOutputPath := filepath.Join(runDir, constants.AgentOutputFilename) + + // Support both new flattened form and old directory form + if stat, err := os.Stat(agentOutputPath); err != nil || stat.IsDir() { + // Try old structure + oldPath := filepath.Join(runDir, constants.AgentOutputArtifactName, constants.AgentOutputArtifactName) + if _, err := os.Stat(oldPath); err == nil { + agentOutputPath = oldPath + } else { + // No agent_output.json found + return false, nil + } + } + + // Read the file + content, err := os.ReadFile(agentOutputPath) + if err != nil { + // File doesn't exist or can't be read + return false, nil + } + + // Parse the JSON + var safeOutput struct { + Items []json.RawMessage `json:"items"` + } + + if err := json.Unmarshal(content, &safeOutput); err != nil { + return false, fmt.Errorf("failed to parse agent_output.json: %w", err) + } + + // Check each item for the specified type + for _, itemRaw := range safeOutput.Items { + var item struct { + Type string `json:"type"` + } + + if err := json.Unmarshal(itemRaw, &item); err != nil { + continue // Skip malformed items + } + + // Normalize the item type for comparison + normalizedItemType := stringutil.NormalizeSafeOutputIdentifier(item.Type) + + if normalizedItemType == normalizedType { + return true, nil + } + } + + return false, nil +} + +// runHasDifcFilteredItems checks if a run's gateway logs contain any DIFC_FILTERED events. +// It parses the gateway logs (falling back to rpc-messages.jsonl when gateway.jsonl is absent) +// and returns true when at least one DIFC integrity- or secrecy-filtered event is present. +func runHasDifcFilteredItems(runDir string, verbose bool) (bool, error) { + logsOrchestratorLog.Printf("Checking run for DIFC filtered items: dir=%s", runDir) + + gatewayMetrics, err := parseGatewayLogs(runDir, verbose) + if err != nil { + // No gateway log file present — not an error for workflows without MCP + return false, nil + } + + if gatewayMetrics == nil { + return false, nil + } + + return gatewayMetrics.TotalFiltered > 0, nil +} diff --git a/pkg/cli/logs_download_runner.go b/pkg/cli/logs_download_runner.go new file mode 100644 index 00000000000..cf618b5cf2d --- /dev/null +++ b/pkg/cli/logs_download_runner.go @@ -0,0 +1,415 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (logs_download_runner.go) contains the concurrent artifact download runner +// used by DownloadWorkflowLogs to download multiple runs in parallel. + +package cli + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" + "github.com/sourcegraph/conc/pool" +) + +// downloadRunArtifactsConcurrent downloads artifacts for multiple workflow runs concurrently +func downloadRunArtifactsConcurrent(ctx context.Context, runs []WorkflowRun, outputDir string, verbose bool, maxRuns int, repoOverride string, artifactFilter []string) []DownloadResult { + logsOrchestratorLog.Printf("Starting concurrent artifact download: runs=%d, outputDir=%s, maxRuns=%d", len(runs), outputDir, maxRuns) + if len(runs) == 0 { + return []DownloadResult{} + } + + // Process all runs in the batch to account for caching and filtering + // The maxRuns parameter indicates how many successful results we need, but we may need to + // process more runs to account for: + // 1. Cached runs that may fail filters (engine, firewall, etc.) + // 2. Runs that may be skipped due to errors + // 3. Runs without artifacts + // + // By processing all runs in the batch, we ensure that the count parameter correctly + // reflects the number of matching logs (both downloaded and cached), not just attempts. + actualRuns := runs + + totalRuns := len(actualRuns) + + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Processing %d runs in parallel...", totalRuns))) + } + + // Create progress bar for tracking run processing (only in non-verbose, non-CI mode) + // In CI environments \r is treated as a newline, producing excessive output for each update. + var progressBar *console.ProgressBar + if !verbose && !IsRunningInCI() { + progressBar = console.NewProgressBar(int64(totalRuns)) + fmt.Fprintf(os.Stderr, "Processing runs: %s\r", progressBar.Update(0)) + } + + // Use atomic counter for thread-safe progress tracking + var completedCount int64 + + // Get configured max concurrent downloads (default or from environment variable) + maxConcurrent := getMaxConcurrentDownloads() + + // Parse repoOverride into owner/repo once for cross-repo artifact download + var dlOwner, dlRepo string + if repoOverride != "" { + parts := strings.SplitN(repoOverride, "/", 2) + if len(parts) == 2 { + dlOwner = parts[0] + dlRepo = parts[1] + } + } + + // Configure concurrent download pool with bounded parallelism and context cancellation. + // The conc pool automatically handles panic recovery and prevents goroutine leaks. + // WithContext enables graceful cancellation via Ctrl+C. + p := pool.NewWithResults[DownloadResult](). + WithContext(ctx). + WithMaxGoroutines(maxConcurrent) + + // Each download task runs concurrently with context awareness. + // Context cancellation (e.g., via Ctrl+C) will stop all in-flight downloads gracefully. + // Panics are automatically recovered by the pool and re-raised with full stack traces + // after all tasks complete. This ensures one failing download doesn't break others. + for _, run := range actualRuns { + p.Go(func(ctx context.Context) (DownloadResult, error) { + // Check for context cancellation before starting download + select { + case <-ctx.Done(): + return DownloadResult{ + Run: run, + Skipped: true, + Error: ctx.Err(), + }, nil + default: + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Processing run %d (%s)...", run.DatabaseID, run.Status))) + } + + // Download artifacts and logs for this run + runOutputDir := filepath.Join(outputDir, fmt.Sprintf("run-%d", run.DatabaseID)) + + // Try to load cached summary first + if summary, ok := loadRunSummary(runOutputDir, verbose); ok { + // Valid cached summary exists, use it directly + result := DownloadResult{ + Run: summary.Run, + Metrics: summary.Metrics, + AwContext: summary.AwContext, + TaskDomain: summary.TaskDomain, + BehaviorFingerprint: summary.BehaviorFingerprint, + AgenticAssessments: summary.AgenticAssessments, + AccessAnalysis: summary.AccessAnalysis, + FirewallAnalysis: summary.FirewallAnalysis, + RedactedDomainsAnalysis: summary.RedactedDomainsAnalysis, + MissingTools: summary.MissingTools, + MissingData: summary.MissingData, + Noops: summary.Noops, + MCPFailures: summary.MCPFailures, + MCPToolUsage: summary.MCPToolUsage, + TokenUsage: summary.TokenUsage, + GitHubRateLimitUsage: summary.GitHubRateLimitUsage, + JobDetails: summary.JobDetails, + LogsPath: runOutputDir, + Cached: true, // Mark as cached + } + // Update progress counter + completed := atomic.AddInt64(&completedCount, 1) + if progressBar != nil { + fmt.Fprintf(os.Stderr, "Processing runs: %s\r", progressBar.Update(completed)) + } + return result, nil + } + + // No cached summary or version mismatch - download and process + err := downloadRunArtifacts(run.DatabaseID, runOutputDir, verbose, dlOwner, dlRepo, "", artifactFilter) + + result := DownloadResult{ + Run: run, + LogsPath: runOutputDir, + } + + if err != nil { + // Check if this is a "no artifacts" case + if errors.Is(err, ErrNoArtifacts) { + // For runs with important conclusions (timed_out, failure, cancelled), + // still process them even without artifacts to show the failure in reports + if isFailureConclusion(run.Conclusion) { + // Don't skip - we want these to appear in the report + // Just use empty metrics + result.Metrics = LogMetrics{} + + // Try to fetch job details to get error count + if failedJobCount, jobErr := fetchJobStatuses(run.DatabaseID, verbose); jobErr == nil { + run.ErrorCount = failedJobCount + } + } else { + // For other runs (success, neutral, etc.) without artifacts, skip them + result.Skipped = true + result.Error = err + } + } else { + result.Error = err + } + } else { + // Extract metrics from logs + metrics, metricsErr := extractLogMetrics(runOutputDir, verbose) + if metricsErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract metrics for run %d: %v", run.DatabaseID, metricsErr))) + } + // Don't fail the whole download for metrics errors + metrics = LogMetrics{} + } + result.Metrics = metrics + + // Update run with metrics so fingerprint computation uses the same data + // as the audit tool, which also derives these fields from extracted log metrics. + result.Run.TokenUsage = metrics.TokenUsage + result.Run.EstimatedCost = metrics.EstimatedCost + result.Run.Turns = metrics.Turns + result.Run.LogsPath = runOutputDir + + // Calculate duration and billable minutes from GitHub API timestamps. + // This mirrors the identical computation in audit.go so that + // processedRun.Run.Duration is consistent across both tools. + if !result.Run.StartedAt.IsZero() && !result.Run.UpdatedAt.IsZero() { + result.Run.Duration = result.Run.UpdatedAt.Sub(result.Run.StartedAt) + result.Run.ActionMinutes = math.Ceil(result.Run.Duration.Minutes()) + } + + // Analyze access logs if available + accessAnalysis, accessErr := analyzeAccessLogs(runOutputDir, verbose) + if accessErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze access logs for run %d: %v", run.DatabaseID, accessErr))) + } + } + result.AccessAnalysis = accessAnalysis + + // Analyze firewall/gateway data only when the agent artifact was downloaded. + // Firewall audit logs are now included in the unified agent artifact. + // Skip silently when the artifact was intentionally excluded from the filter to + // avoid spurious "not found" warnings in verbose mode. + hasFirewallArtifact := artifactMatchesFilter(constants.AgentArtifactName, artifactFilter) + + // Analyze firewall logs if available + var firewallAnalysis *FirewallAnalysis + if hasFirewallArtifact { + var firewallErr error + firewallAnalysis, firewallErr = analyzeFirewallLogs(runOutputDir, verbose) + if firewallErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze firewall logs for run %d: %v", run.DatabaseID, firewallErr))) + } + } + } + result.FirewallAnalysis = firewallAnalysis + + // Analyze redacted domains if available + redactedDomainsAnalysis, redactedErr := analyzeRedactedDomains(runOutputDir, verbose) + if redactedErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze redacted domains for run %d: %v", run.DatabaseID, redactedErr))) + } + } + result.RedactedDomainsAnalysis = redactedDomainsAnalysis + + // Extract missing tools if available + missingTools, missingErr := extractMissingToolsFromRun(runOutputDir, run, verbose) + if missingErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract missing tools for run %d: %v", run.DatabaseID, missingErr))) + } + } + result.MissingTools = missingTools + + // Extract missing data if available + missingData, missingDataErr := extractMissingDataFromRun(runOutputDir, run, verbose) + if missingDataErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract missing data for run %d: %v", run.DatabaseID, missingDataErr))) + } + } + result.MissingData = missingData + + // Extract noops if available + noops, noopErr := extractNoopsFromRun(runOutputDir, run, verbose) + if noopErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract noops for run %d: %v", run.DatabaseID, noopErr))) + } + } + result.Noops = noops + + // Extract MCP failures if available + mcpFailures, mcpErr := extractMCPFailuresFromRun(runOutputDir, run, verbose) + if mcpErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract MCP failures for run %d: %v", run.DatabaseID, mcpErr))) + } + } + result.MCPFailures = mcpFailures + + // Extract MCP tool usage data from gateway logs if available. + // Gated on hasFirewallArtifact since gateway.jsonl lives in the agent artifact. + var mcpToolUsage *MCPToolUsageData + if hasFirewallArtifact { + var mcpToolErr error + mcpToolUsage, mcpToolErr = extractMCPToolUsageData(runOutputDir, verbose) + if mcpToolErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract MCP tool usage for run %d: %v", run.DatabaseID, mcpToolErr))) + } + } + } + result.MCPToolUsage = mcpToolUsage + + // Analyze token usage from firewall proxy logs. + // Gated on hasFirewallArtifact since token-usage.jsonl lives in the agent artifact. + var tokenUsage *TokenUsageSummary + if hasFirewallArtifact { + var tokenErr error + tokenUsage, tokenErr = analyzeTokenUsage(runOutputDir, verbose) + if tokenErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze token usage for run %d: %v", run.DatabaseID, tokenErr))) + } + } + } + result.TokenUsage = tokenUsage + + // Propagate effective tokens from the firewall proxy summary when available + if tokenUsage != nil && tokenUsage.TotalEffectiveTokens > 0 { + result.Run.EffectiveTokens = tokenUsage.TotalEffectiveTokens + } + + // Analyze GitHub API rate limit consumption from github_rate_limits.jsonl + rateLimitUsage, rlErr := analyzeGitHubRateLimits(runOutputDir, verbose) + if rlErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze GitHub rate limit usage for run %d: %v", run.DatabaseID, rlErr))) + } + } + result.GitHubRateLimitUsage = rateLimitUsage + + // Count safe output items created in GitHub (from manifest artifact) + result.Run.SafeItemsCount = len(extractCreatedItemsFromManifest(runOutputDir)) + + // Fetch job details for the summary + jobDetails, jobErr := fetchJobDetails(run.DatabaseID, verbose) + if jobErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to fetch job details for run %d: %v", run.DatabaseID, jobErr))) + } + } + + // List all artifacts + artifacts, listErr := listArtifacts(runOutputDir) + if listErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to list artifacts for run %d: %v", run.DatabaseID, listErr))) + } + } + + processedRun := ProcessedRun{ + Run: result.Run, + AccessAnalysis: accessAnalysis, + FirewallAnalysis: firewallAnalysis, + RedactedDomainsAnalysis: redactedDomainsAnalysis, + MissingTools: missingTools, + MissingData: missingData, + Noops: noops, + MCPFailures: mcpFailures, + MCPToolUsage: mcpToolUsage, + TokenUsage: tokenUsage, + GitHubRateLimitUsage: rateLimitUsage, + JobDetails: jobDetails, + } + awContext, _, _, taskDomain, behaviorFingerprint, agenticAssessments := deriveRunAgenticAnalysis(processedRun, metrics) + result.AwContext = awContext + result.TaskDomain = taskDomain + result.BehaviorFingerprint = behaviorFingerprint + result.AgenticAssessments = agenticAssessments + + // Create and save run summary + summary := &RunSummary{ + CLIVersion: GetVersion(), + RunID: run.DatabaseID, + ProcessedAt: time.Now(), + Run: result.Run, + Metrics: metrics, + AwContext: result.AwContext, + TaskDomain: result.TaskDomain, + BehaviorFingerprint: result.BehaviorFingerprint, + AgenticAssessments: result.AgenticAssessments, + AccessAnalysis: accessAnalysis, + FirewallAnalysis: firewallAnalysis, + RedactedDomainsAnalysis: redactedDomainsAnalysis, + MissingTools: missingTools, + MissingData: missingData, + Noops: noops, + MCPFailures: mcpFailures, + MCPToolUsage: mcpToolUsage, + TokenUsage: tokenUsage, + GitHubRateLimitUsage: rateLimitUsage, + ArtifactsList: artifacts, + JobDetails: jobDetails, + } + + if saveErr := saveRunSummary(runOutputDir, summary, verbose); saveErr != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save run summary for run %d: %v", run.DatabaseID, saveErr))) + } + } + } + + // Update progress counter for completed downloads + completed := atomic.AddInt64(&completedCount, 1) + if progressBar != nil { + fmt.Fprintf(os.Stderr, "Processing runs: %s\r", progressBar.Update(completed)) + } + + return result, nil + }) + } + + // Wait blocks until all downloads complete, context is cancelled, or panic occurs. + // With context support, the pool guarantees: + // - All goroutines finish gracefully on cancellation (no leaks) + // - Panics are propagated with stack traces + // - Partial results are returned when context is cancelled + // - Results are collected in submission order + results, err := p.Wait() + + // Handle context cancellation + if err != nil && verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Download interrupted: %v", err))) + } + + // Clear progress bar silently - detailed summary shown at the end + if progressBar != nil { + console.ClearLine() // Clear the line + } + + if verbose { + successCount := 0 + for _, result := range results { + if result.Error == nil && !result.Skipped { + successCount++ + } + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Completed parallel processing: %d successful, %d total", successCount, len(results)))) + } + + return results +} diff --git a/pkg/cli/logs_download_test.go b/pkg/cli/logs_download_test.go index ce62b3d92c4..88055e65823 100644 --- a/pkg/cli/logs_download_test.go +++ b/pkg/cli/logs_download_test.go @@ -21,7 +21,12 @@ func TestDownloadWorkflowLogs(t *testing.T) { // Test the DownloadWorkflowLogs function // This should either fail with auth error (if not authenticated) // or succeed with no results (if authenticated but no workflows match) - err := DownloadWorkflowLogs(context.Background(), "", 1, "", "", "./test-logs", "", "", 0, 0, "", false, false, false, false, false, false, false, 0, "summary.json", "", false, false, "", nil) + err := DownloadWorkflowLogs(context.Background(), DownloadConfig{ + WorkflowName: "", + Count: 1, + OutputDir: "./test-logs", + SummaryFile: "summary.json", + }) // If GitHub CLI is authenticated, the function may succeed but find no results // If not authenticated, it should return an auth error @@ -360,7 +365,13 @@ func TestDownloadWorkflowLogsWithEngineFilter(t *testing.T) { if !tt.expectError { // For valid engines, test that the function can be called without panic // It may still fail with auth errors, which is expected - err := DownloadWorkflowLogs(context.Background(), "", 1, "", "", "./test-logs", tt.engine, "", 0, 0, "", false, false, false, false, false, false, false, 0, "summary.json", "", false, false, "", nil) + err := DownloadWorkflowLogs(context.Background(), DownloadConfig{ + WorkflowName: "", + Count: 1, + OutputDir: "./test-logs", + Engine: tt.engine, + SummaryFile: "summary.json", + }) // Clean up any created directories os.RemoveAll("./test-logs") diff --git a/pkg/cli/logs_json_stderr_order_test.go b/pkg/cli/logs_json_stderr_order_test.go index 3443ad4cb16..9e6a7c01fa7 100644 --- a/pkg/cli/logs_json_stderr_order_test.go +++ b/pkg/cli/logs_json_stderr_order_test.go @@ -36,33 +36,15 @@ func TestLogsJSONOutputBeforeStderr(t *testing.T) { // Call DownloadWorkflowLogs with parameters that will result in no matching runs // This should trigger the warning message path - err := DownloadWorkflowLogs( - ctx, - "nonexistent-workflow-test-12345", // Workflow that doesn't exist - 2, // count - "", // startDate - "", // endDate - tmpDir, // outputDir - "copilot", // engine - "", // ref - 0, // beforeRunID - 0, // afterRunID - "", // repoOverride - false, // verbose - false, // toolGraph - false, // noStaged - false, // firewallOnly - false, // noFirewall - false, // parse - true, // jsonOutput - THIS IS KEY - 10, // timeout - "summary.json", // summaryFile - "", // safeOutputType - false, // filteredIntegrity - false, // train - "", // format - nil, // artifactSets - ) + err := DownloadWorkflowLogs(ctx, DownloadConfig{ + WorkflowName: "nonexistent-workflow-test-12345", // Workflow that doesn't exist + Count: 2, + OutputDir: tmpDir, + Engine: "copilot", + JSONOutput: true, // THIS IS KEY + Timeout: 10, + SummaryFile: "summary.json", + }) // Close writers first stdoutW.Close() @@ -161,33 +143,15 @@ func TestLogsJSONAndStderrRedirected(t *testing.T) { // Call DownloadWorkflowLogs ctx := context.Background() - err := DownloadWorkflowLogs( - ctx, - "nonexistent-workflow-ci-test-67890", - 2, - "", - "", - tmpDir, - "copilot", - "", - 0, - 0, - "", - false, - false, - false, - false, - false, - false, - true, // jsonOutput - 10, - "summary.json", - "", // safeOutputType - false, // filteredIntegrity - false, // train - "", // format - nil, // artifactSets - ) + err := DownloadWorkflowLogs(ctx, DownloadConfig{ + WorkflowName: "nonexistent-workflow-ci-test-67890", + Count: 2, + OutputDir: tmpDir, + Engine: "copilot", + JSONOutput: true, // jsonOutput + Timeout: 10, + SummaryFile: "summary.json", + }) // Close the writer w.Close() diff --git a/pkg/cli/logs_orchestrator.go b/pkg/cli/logs_orchestrator.go index 5c4daedcdce..54aeea7b130 100644 --- a/pkg/cli/logs_orchestrator.go +++ b/pkg/cli/logs_orchestrator.go @@ -13,23 +13,18 @@ package cli import ( "context" - "encoding/json" - "errors" "fmt" "math" "os" "path/filepath" "strings" - "sync/atomic" "time" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/envutil" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/stringutil" "github.com/github/gh-aw/pkg/workflow" - "github.com/sourcegraph/conc/pool" ) var logsOrchestratorLog = logger.New("cli:logs_orchestrator") @@ -41,8 +36,33 @@ func getMaxConcurrentDownloads() int { return envutil.GetIntFromEnv("GH_AW_MAX_CONCURRENT_DOWNLOADS", MaxConcurrentDownloads, 1, 100, logsOrchestratorLog) } -// DownloadWorkflowLogs downloads and analyzes workflow logs with metrics -func DownloadWorkflowLogs(ctx context.Context, 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, summaryFile string, safeOutputType string, filteredIntegrity bool, train bool, format string, artifactSets []string) error { +// DownloadWorkflowLogs downloads and analyzes workflow logs with metrics. +// All configuration is passed via the cfg DownloadConfig parameter. +func DownloadWorkflowLogs(ctx context.Context, cfg DownloadConfig) error { + workflowName := cfg.WorkflowName + count := cfg.Count + startDate := cfg.StartDate + endDate := cfg.EndDate + outputDir := cfg.OutputDir + engine := cfg.Engine + ref := cfg.Ref + beforeRunID := cfg.BeforeRunID + afterRunID := cfg.AfterRunID + repoOverride := cfg.RepoOverride + verbose := cfg.Verbose + toolGraph := cfg.ToolGraph + noStaged := cfg.NoStaged + firewallOnly := cfg.FirewallOnly + noFirewall := cfg.NoFirewall + parse := cfg.Parse + jsonOutput := cfg.JSONOutput + timeout := cfg.Timeout + summaryFile := cfg.SummaryFile + safeOutputType := cfg.SafeOutputType + filteredIntegrity := cfg.FilteredIntegrity + train := cfg.Train + format := cfg.Format + artifactSets := cfg.ArtifactSets logsOrchestratorLog.Printf("Starting workflow log download: workflow=%s, count=%d, startDate=%s, endDate=%s, outputDir=%s, summaryFile=%s, safeOutputType=%s, filteredIntegrity=%v, train=%v, format=%s, artifactSets=%v", workflowName, count, startDate, endDate, outputDir, summaryFile, safeOutputType, filteredIntegrity, train, format, artifactSets) // Validate and resolve artifact sets into a concrete filter (list of artifact base names). @@ -602,474 +622,3 @@ func DownloadWorkflowLogs(ctx context.Context, workflowName string, count int, s return nil } - -// downloadRunArtifactsConcurrent downloads artifacts for multiple workflow runs concurrently -func downloadRunArtifactsConcurrent(ctx context.Context, runs []WorkflowRun, outputDir string, verbose bool, maxRuns int, repoOverride string, artifactFilter []string) []DownloadResult { - logsOrchestratorLog.Printf("Starting concurrent artifact download: runs=%d, outputDir=%s, maxRuns=%d", len(runs), outputDir, maxRuns) - if len(runs) == 0 { - return []DownloadResult{} - } - - // Process all runs in the batch to account for caching and filtering - // The maxRuns parameter indicates how many successful results we need, but we may need to - // process more runs to account for: - // 1. Cached runs that may fail filters (engine, firewall, etc.) - // 2. Runs that may be skipped due to errors - // 3. Runs without artifacts - // - // By processing all runs in the batch, we ensure that the count parameter correctly - // reflects the number of matching logs (both downloaded and cached), not just attempts. - actualRuns := runs - - totalRuns := len(actualRuns) - - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Processing %d runs in parallel...", totalRuns))) - } - - // Create progress bar for tracking run processing (only in non-verbose, non-CI mode) - // In CI environments \r is treated as a newline, producing excessive output for each update. - var progressBar *console.ProgressBar - if !verbose && !IsRunningInCI() { - progressBar = console.NewProgressBar(int64(totalRuns)) - fmt.Fprintf(os.Stderr, "Processing runs: %s\r", progressBar.Update(0)) - } - - // Use atomic counter for thread-safe progress tracking - var completedCount int64 - - // Get configured max concurrent downloads (default or from environment variable) - maxConcurrent := getMaxConcurrentDownloads() - - // Parse repoOverride into owner/repo once for cross-repo artifact download - var dlOwner, dlRepo string - if repoOverride != "" { - parts := strings.SplitN(repoOverride, "/", 2) - if len(parts) == 2 { - dlOwner = parts[0] - dlRepo = parts[1] - } - } - - // Configure concurrent download pool with bounded parallelism and context cancellation. - // The conc pool automatically handles panic recovery and prevents goroutine leaks. - // WithContext enables graceful cancellation via Ctrl+C. - p := pool.NewWithResults[DownloadResult](). - WithContext(ctx). - WithMaxGoroutines(maxConcurrent) - - // Each download task runs concurrently with context awareness. - // Context cancellation (e.g., via Ctrl+C) will stop all in-flight downloads gracefully. - // Panics are automatically recovered by the pool and re-raised with full stack traces - // after all tasks complete. This ensures one failing download doesn't break others. - for _, run := range actualRuns { - p.Go(func(ctx context.Context) (DownloadResult, error) { - // Check for context cancellation before starting download - select { - case <-ctx.Done(): - return DownloadResult{ - Run: run, - Skipped: true, - Error: ctx.Err(), - }, nil - default: - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Processing run %d (%s)...", run.DatabaseID, run.Status))) - } - - // Download artifacts and logs for this run - runOutputDir := filepath.Join(outputDir, fmt.Sprintf("run-%d", run.DatabaseID)) - - // Try to load cached summary first - if summary, ok := loadRunSummary(runOutputDir, verbose); ok { - // Valid cached summary exists, use it directly - result := DownloadResult{ - Run: summary.Run, - Metrics: summary.Metrics, - AwContext: summary.AwContext, - TaskDomain: summary.TaskDomain, - BehaviorFingerprint: summary.BehaviorFingerprint, - AgenticAssessments: summary.AgenticAssessments, - AccessAnalysis: summary.AccessAnalysis, - FirewallAnalysis: summary.FirewallAnalysis, - RedactedDomainsAnalysis: summary.RedactedDomainsAnalysis, - MissingTools: summary.MissingTools, - MissingData: summary.MissingData, - Noops: summary.Noops, - MCPFailures: summary.MCPFailures, - MCPToolUsage: summary.MCPToolUsage, - TokenUsage: summary.TokenUsage, - GitHubRateLimitUsage: summary.GitHubRateLimitUsage, - JobDetails: summary.JobDetails, - LogsPath: runOutputDir, - Cached: true, // Mark as cached - } - // Update progress counter - completed := atomic.AddInt64(&completedCount, 1) - if progressBar != nil { - fmt.Fprintf(os.Stderr, "Processing runs: %s\r", progressBar.Update(completed)) - } - return result, nil - } - - // No cached summary or version mismatch - download and process - err := downloadRunArtifacts(run.DatabaseID, runOutputDir, verbose, dlOwner, dlRepo, "", artifactFilter) - - result := DownloadResult{ - Run: run, - LogsPath: runOutputDir, - } - - if err != nil { - // Check if this is a "no artifacts" case - if errors.Is(err, ErrNoArtifacts) { - // For runs with important conclusions (timed_out, failure, cancelled), - // still process them even without artifacts to show the failure in reports - if isFailureConclusion(run.Conclusion) { - // Don't skip - we want these to appear in the report - // Just use empty metrics - result.Metrics = LogMetrics{} - - // Try to fetch job details to get error count - if failedJobCount, jobErr := fetchJobStatuses(run.DatabaseID, verbose); jobErr == nil { - run.ErrorCount = failedJobCount - } - } else { - // For other runs (success, neutral, etc.) without artifacts, skip them - result.Skipped = true - result.Error = err - } - } else { - result.Error = err - } - } else { - // Extract metrics from logs - metrics, metricsErr := extractLogMetrics(runOutputDir, verbose) - if metricsErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract metrics for run %d: %v", run.DatabaseID, metricsErr))) - } - // Don't fail the whole download for metrics errors - metrics = LogMetrics{} - } - result.Metrics = metrics - - // Update run with metrics so fingerprint computation uses the same data - // as the audit tool, which also derives these fields from extracted log metrics. - result.Run.TokenUsage = metrics.TokenUsage - result.Run.EstimatedCost = metrics.EstimatedCost - result.Run.Turns = metrics.Turns - result.Run.LogsPath = runOutputDir - - // Calculate duration and billable minutes from GitHub API timestamps. - // This mirrors the identical computation in audit.go so that - // processedRun.Run.Duration is consistent across both tools. - if !result.Run.StartedAt.IsZero() && !result.Run.UpdatedAt.IsZero() { - result.Run.Duration = result.Run.UpdatedAt.Sub(result.Run.StartedAt) - result.Run.ActionMinutes = math.Ceil(result.Run.Duration.Minutes()) - } - - // Analyze access logs if available - accessAnalysis, accessErr := analyzeAccessLogs(runOutputDir, verbose) - if accessErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze access logs for run %d: %v", run.DatabaseID, accessErr))) - } - } - result.AccessAnalysis = accessAnalysis - - // Analyze firewall/gateway data only when the agent artifact was downloaded. - // Firewall audit logs are now included in the unified agent artifact. - // Skip silently when the artifact was intentionally excluded from the filter to - // avoid spurious "not found" warnings in verbose mode. - hasFirewallArtifact := artifactMatchesFilter(constants.AgentArtifactName, artifactFilter) - - // Analyze firewall logs if available - var firewallAnalysis *FirewallAnalysis - if hasFirewallArtifact { - var firewallErr error - firewallAnalysis, firewallErr = analyzeFirewallLogs(runOutputDir, verbose) - if firewallErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze firewall logs for run %d: %v", run.DatabaseID, firewallErr))) - } - } - } - result.FirewallAnalysis = firewallAnalysis - - // Analyze redacted domains if available - redactedDomainsAnalysis, redactedErr := analyzeRedactedDomains(runOutputDir, verbose) - if redactedErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze redacted domains for run %d: %v", run.DatabaseID, redactedErr))) - } - } - result.RedactedDomainsAnalysis = redactedDomainsAnalysis - - // Extract missing tools if available - missingTools, missingErr := extractMissingToolsFromRun(runOutputDir, run, verbose) - if missingErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract missing tools for run %d: %v", run.DatabaseID, missingErr))) - } - } - result.MissingTools = missingTools - - // Extract missing data if available - missingData, missingDataErr := extractMissingDataFromRun(runOutputDir, run, verbose) - if missingDataErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract missing data for run %d: %v", run.DatabaseID, missingDataErr))) - } - } - result.MissingData = missingData - - // Extract noops if available - noops, noopErr := extractNoopsFromRun(runOutputDir, run, verbose) - if noopErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract noops for run %d: %v", run.DatabaseID, noopErr))) - } - } - result.Noops = noops - - // Extract MCP failures if available - mcpFailures, mcpErr := extractMCPFailuresFromRun(runOutputDir, run, verbose) - if mcpErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract MCP failures for run %d: %v", run.DatabaseID, mcpErr))) - } - } - result.MCPFailures = mcpFailures - - // Extract MCP tool usage data from gateway logs if available. - // Gated on hasFirewallArtifact since gateway.jsonl lives in the agent artifact. - var mcpToolUsage *MCPToolUsageData - if hasFirewallArtifact { - var mcpToolErr error - mcpToolUsage, mcpToolErr = extractMCPToolUsageData(runOutputDir, verbose) - if mcpToolErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to extract MCP tool usage for run %d: %v", run.DatabaseID, mcpToolErr))) - } - } - } - result.MCPToolUsage = mcpToolUsage - - // Analyze token usage from firewall proxy logs. - // Gated on hasFirewallArtifact since token-usage.jsonl lives in the agent artifact. - var tokenUsage *TokenUsageSummary - if hasFirewallArtifact { - var tokenErr error - tokenUsage, tokenErr = analyzeTokenUsage(runOutputDir, verbose) - if tokenErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze token usage for run %d: %v", run.DatabaseID, tokenErr))) - } - } - } - result.TokenUsage = tokenUsage - - // Propagate effective tokens from the firewall proxy summary when available - if tokenUsage != nil && tokenUsage.TotalEffectiveTokens > 0 { - result.Run.EffectiveTokens = tokenUsage.TotalEffectiveTokens - } - - // Analyze GitHub API rate limit consumption from github_rate_limits.jsonl - rateLimitUsage, rlErr := analyzeGitHubRateLimits(runOutputDir, verbose) - if rlErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze GitHub rate limit usage for run %d: %v", run.DatabaseID, rlErr))) - } - } - result.GitHubRateLimitUsage = rateLimitUsage - - // Count safe output items created in GitHub (from manifest artifact) - result.Run.SafeItemsCount = len(extractCreatedItemsFromManifest(runOutputDir)) - - // Fetch job details for the summary - jobDetails, jobErr := fetchJobDetails(run.DatabaseID, verbose) - if jobErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to fetch job details for run %d: %v", run.DatabaseID, jobErr))) - } - } - - // List all artifacts - artifacts, listErr := listArtifacts(runOutputDir) - if listErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to list artifacts for run %d: %v", run.DatabaseID, listErr))) - } - } - - processedRun := ProcessedRun{ - Run: result.Run, - AccessAnalysis: accessAnalysis, - FirewallAnalysis: firewallAnalysis, - RedactedDomainsAnalysis: redactedDomainsAnalysis, - MissingTools: missingTools, - MissingData: missingData, - Noops: noops, - MCPFailures: mcpFailures, - MCPToolUsage: mcpToolUsage, - TokenUsage: tokenUsage, - GitHubRateLimitUsage: rateLimitUsage, - JobDetails: jobDetails, - } - awContext, _, _, taskDomain, behaviorFingerprint, agenticAssessments := deriveRunAgenticAnalysis(processedRun, metrics) - result.AwContext = awContext - result.TaskDomain = taskDomain - result.BehaviorFingerprint = behaviorFingerprint - result.AgenticAssessments = agenticAssessments - - // Create and save run summary - summary := &RunSummary{ - CLIVersion: GetVersion(), - RunID: run.DatabaseID, - ProcessedAt: time.Now(), - Run: result.Run, - Metrics: metrics, - AwContext: result.AwContext, - TaskDomain: result.TaskDomain, - BehaviorFingerprint: result.BehaviorFingerprint, - AgenticAssessments: result.AgenticAssessments, - AccessAnalysis: accessAnalysis, - FirewallAnalysis: firewallAnalysis, - RedactedDomainsAnalysis: redactedDomainsAnalysis, - MissingTools: missingTools, - MissingData: missingData, - Noops: noops, - MCPFailures: mcpFailures, - MCPToolUsage: mcpToolUsage, - TokenUsage: tokenUsage, - GitHubRateLimitUsage: rateLimitUsage, - ArtifactsList: artifacts, - JobDetails: jobDetails, - } - - if saveErr := saveRunSummary(runOutputDir, summary, verbose); saveErr != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to save run summary for run %d: %v", run.DatabaseID, saveErr))) - } - } - } - - // Update progress counter for completed downloads - completed := atomic.AddInt64(&completedCount, 1) - if progressBar != nil { - fmt.Fprintf(os.Stderr, "Processing runs: %s\r", progressBar.Update(completed)) - } - - return result, nil - }) - } - - // Wait blocks until all downloads complete, context is cancelled, or panic occurs. - // With context support, the pool guarantees: - // - All goroutines finish gracefully on cancellation (no leaks) - // - Panics are propagated with stack traces - // - Partial results are returned when context is cancelled - // - Results are collected in submission order - results, err := p.Wait() - - // Handle context cancellation - if err != nil && verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Download interrupted: %v", err))) - } - - // Clear progress bar silently - detailed summary shown at the end - if progressBar != nil { - console.ClearLine() // Clear the line - } - - if verbose { - successCount := 0 - for _, result := range results { - if result.Error == nil && !result.Skipped { - successCount++ - } - } - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Completed parallel processing: %d successful, %d total", successCount, len(results)))) - } - - return results -} - -// runContainsSafeOutputType checks if a run's agent_output.json contains a specific safe output type -func runContainsSafeOutputType(runDir string, safeOutputType string, verbose bool) (bool, error) { - logsOrchestratorLog.Printf("Checking run for safe output type: dir=%s, type=%s", runDir, safeOutputType) - // Normalize the type for comparison (convert dashes to underscores) - normalizedType := stringutil.NormalizeSafeOutputIdentifier(safeOutputType) - - // Look for agent_output.json in the run directory - agentOutputPath := filepath.Join(runDir, constants.AgentOutputFilename) - - // Support both new flattened form and old directory form - if stat, err := os.Stat(agentOutputPath); err != nil || stat.IsDir() { - // Try old structure - oldPath := filepath.Join(runDir, constants.AgentOutputArtifactName, constants.AgentOutputArtifactName) - if _, err := os.Stat(oldPath); err == nil { - agentOutputPath = oldPath - } else { - // No agent_output.json found - return false, nil - } - } - - // Read the file - content, err := os.ReadFile(agentOutputPath) - if err != nil { - // File doesn't exist or can't be read - return false, nil - } - - // Parse the JSON - var safeOutput struct { - Items []json.RawMessage `json:"items"` - } - - if err := json.Unmarshal(content, &safeOutput); err != nil { - return false, fmt.Errorf("failed to parse agent_output.json: %w", err) - } - - // Check each item for the specified type - for _, itemRaw := range safeOutput.Items { - var item struct { - Type string `json:"type"` - } - - if err := json.Unmarshal(itemRaw, &item); err != nil { - continue // Skip malformed items - } - - // Normalize the item type for comparison - normalizedItemType := stringutil.NormalizeSafeOutputIdentifier(item.Type) - - if normalizedItemType == normalizedType { - return true, nil - } - } - - return false, nil -} - -// runHasDifcFilteredItems checks if a run's gateway logs contain any DIFC_FILTERED events. -// It parses the gateway logs (falling back to rpc-messages.jsonl when gateway.jsonl is absent) -// and returns true when at least one DIFC integrity- or secrecy-filtered event is present. -func runHasDifcFilteredItems(runDir string, verbose bool) (bool, error) { - logsOrchestratorLog.Printf("Checking run for DIFC filtered items: dir=%s", runDir) - - gatewayMetrics, err := parseGatewayLogs(runDir, verbose) - if err != nil { - // No gateway log file present — not an error for workflows without MCP - return false, nil - } - - if gatewayMetrics == nil { - return false, nil - } - - return gatewayMetrics.TotalFiltered > 0, nil -} diff --git a/pkg/cli/logs_report.go b/pkg/cli/logs_report.go index ebf5084554f..3106ff213a6 100644 --- a/pkg/cli/logs_report.go +++ b/pkg/cli/logs_report.go @@ -1,19 +1,16 @@ package cli import ( - "cmp" "encoding/json" "fmt" "os" "path/filepath" - "slices" "strings" "time" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/timeutil" - "github.com/github/gh-aw/pkg/workflow" ) var reportLog = logger.New("cli:logs_report") @@ -378,292 +375,8 @@ func deriveRunClassification(comparison *AuditComparisonData) string { return "normal" } -// isValidToolName checks if a tool name appears to be valid -// Filters out single words, common words, and other garbage that shouldn't be tools -func isValidToolName(toolName string) bool { - name := strings.TrimSpace(toolName) - - // Filter out empty names - if name == "" || name == "-" { - return false - } - - // Filter out single character names - if len(name) == 1 { - return false - } - - // Filter out common English words that are likely from error messages - commonWords := map[string]bool{ - "calls": true, "to": true, "for": true, "the": true, "a": true, "an": true, - "is": true, "are": true, "was": true, "were": true, "be": true, "been": true, - "have": true, "has": true, "had": true, "do": true, "does": true, "did": true, - "will": true, "would": true, "could": true, "should": true, "may": true, "might": true, - "Testing": true, "multiple": true, "launches": true, "command": true, "invocation": true, - "with": true, "from": true, "by": true, "at": true, "in": true, "on": true, - } - - if commonWords[name] { - return false - } - - // Tool names should typically contain underscores, hyphens, or be camelCase - // or be all lowercase. Single words without these patterns are suspect. - hasUnderscore := strings.Contains(name, "_") - hasHyphen := strings.Contains(name, "-") - hasCapital := strings.ToLower(name) != name - - // If it's a single word with no underscores/hyphens and is lowercase and short, - // it's likely a fragment - words := strings.Fields(name) - if len(words) == 1 && !hasUnderscore && !hasHyphen && len(name) < 10 && !hasCapital { - // Could be a fragment - be conservative and reject if it's a common word - return false - } - - return true -} - // buildToolUsageSummary aggregates tool usage across all runs // Filters out invalid tool names that appear to be fragments or garbage -func buildToolUsageSummary(processedRuns []ProcessedRun) []ToolUsageSummary { - reportLog.Printf("Building tool usage summary from %d processed runs", len(processedRuns)) - toolStats := make(map[string]*ToolUsageSummary) - - for _, pr := range processedRuns { - // Extract metrics from run's logs - metrics := ExtractLogMetricsFromRun(pr) - - // Track which runs use each tool - toolRunTracker := make(map[string]bool) - - for _, toolCall := range metrics.ToolCalls { - displayKey := workflow.PrettifyToolName(toolCall.Name) - - // Filter out invalid tool names - if !isValidToolName(displayKey) { - continue - } - - toolRunTracker[displayKey] = true - - if existing, exists := toolStats[displayKey]; exists { - existing.TotalCalls += toolCall.CallCount - if toolCall.MaxOutputSize > existing.MaxOutputSize { - existing.MaxOutputSize = toolCall.MaxOutputSize - } - if toolCall.MaxDuration > 0 { - maxDur := timeutil.FormatDuration(toolCall.MaxDuration) - if existing.MaxDuration == "" || toolCall.MaxDuration > parseDurationString(existing.MaxDuration) { - existing.MaxDuration = maxDur - } - } - } else { - info := &ToolUsageSummary{ - Name: displayKey, - TotalCalls: toolCall.CallCount, - MaxOutputSize: toolCall.MaxOutputSize, - Runs: 0, // Will be incremented below - } - if toolCall.MaxDuration > 0 { - info.MaxDuration = timeutil.FormatDuration(toolCall.MaxDuration) - } - toolStats[displayKey] = info - } - } - - // Increment run count for tools used in this run - for toolName := range toolRunTracker { - if stat, exists := toolStats[toolName]; exists { - stat.Runs++ - } - } - } - - var result []ToolUsageSummary - for _, info := range toolStats { - result = append(result, *info) - } - - // Sort by total calls descending - slices.SortFunc(result, func(a, b ToolUsageSummary) int { - return cmp.Compare(b.TotalCalls, a.TotalCalls) - }) - - return result -} - -// addUniqueWorkflow adds a workflow to the list if it's not already present -func addUniqueWorkflow(workflows []string, workflow string) []string { - if slices.Contains(workflows, workflow) { - return workflows - } - return append(workflows, workflow) -} - -// aggregateSummaryItems is a generic helper that aggregates items from processed runs into summaries -// It handles the common pattern of grouping by key, counting occurrences, tracking unique workflows, and collecting run IDs -func aggregateSummaryItems[TItem any, TSummary any]( - processedRuns []ProcessedRun, - getItems func(ProcessedRun) []TItem, - getKey func(TItem) string, - createSummary func(TItem) *TSummary, - updateSummary func(*TSummary, TItem), - finalizeSummary func(*TSummary), -) []TSummary { - summaryMap := make(map[string]*TSummary) - - // Aggregate items from all runs - for _, pr := range processedRuns { - for _, item := range getItems(pr) { - key := getKey(item) - if summary, exists := summaryMap[key]; exists { - updateSummary(summary, item) - } else { - summaryMap[key] = createSummary(item) - } - } - } - - // Convert map to slice and finalize each summary - var result []TSummary - for _, summary := range summaryMap { - finalizeSummary(summary) - result = append(result, *summary) - } - - return result -} - -// buildMissingToolsSummary aggregates missing tools across all runs -func buildMissingToolsSummary(processedRuns []ProcessedRun) []MissingToolSummary { - reportLog.Printf("Building missing tools summary from %d processed runs", len(processedRuns)) - result := aggregateSummaryItems( - processedRuns, - // getItems: extract missing tools from each run - func(pr ProcessedRun) []MissingToolReport { - return pr.MissingTools - }, - // getKey: use tool name as the aggregation key - func(tool MissingToolReport) string { - return tool.Tool - }, - // createSummary: create new summary for first occurrence - func(tool MissingToolReport) *MissingToolSummary { - return &MissingToolSummary{ - Tool: tool.Tool, - Count: 1, - Workflows: []string{tool.WorkflowName}, - FirstReason: tool.Reason, - RunIDs: []int64{tool.RunID}, - } - }, - // updateSummary: update existing summary with new occurrence - func(summary *MissingToolSummary, tool MissingToolReport) { - summary.Count++ - summary.Workflows = addUniqueWorkflow(summary.Workflows, tool.WorkflowName) - summary.RunIDs = append(summary.RunIDs, tool.RunID) - }, - // finalizeSummary: populate display fields for console rendering - func(summary *MissingToolSummary) { - summary.WorkflowsDisplay = strings.Join(summary.Workflows, ", ") - summary.FirstReasonDisplay = summary.FirstReason - }, - ) - - // Sort by count descending - slices.SortFunc(result, func(a, b MissingToolSummary) int { - return cmp.Compare(b.Count, a.Count) - }) - - return result -} - -// buildMissingDataSummary aggregates missing data across all runs -func buildMissingDataSummary(processedRuns []ProcessedRun) []MissingDataSummary { - reportLog.Printf("Building missing data summary from %d processed runs", len(processedRuns)) - result := aggregateSummaryItems( - processedRuns, - // getItems: extract missing data from each run - func(pr ProcessedRun) []MissingDataReport { - return pr.MissingData - }, - // getKey: use data type as the aggregation key - func(data MissingDataReport) string { - return data.DataType - }, - // createSummary: create new summary for first occurrence - func(data MissingDataReport) *MissingDataSummary { - return &MissingDataSummary{ - DataType: data.DataType, - Count: 1, - Workflows: []string{data.WorkflowName}, - FirstReason: data.Reason, - RunIDs: []int64{data.RunID}, - } - }, - // updateSummary: update existing summary with new occurrence - func(summary *MissingDataSummary, data MissingDataReport) { - summary.Count++ - summary.Workflows = addUniqueWorkflow(summary.Workflows, data.WorkflowName) - summary.RunIDs = append(summary.RunIDs, data.RunID) - }, - // finalizeSummary: populate display fields for console rendering - func(summary *MissingDataSummary) { - summary.WorkflowsDisplay = strings.Join(summary.Workflows, ", ") - summary.FirstReasonDisplay = summary.FirstReason - }, - ) - - // Sort by count descending - slices.SortFunc(result, func(a, b MissingDataSummary) int { - return cmp.Compare(b.Count, a.Count) - }) - - return result -} - -// buildMCPFailuresSummary aggregates MCP failures across all runs -func buildMCPFailuresSummary(processedRuns []ProcessedRun) []MCPFailureSummary { - reportLog.Printf("Building MCP failures summary from %d processed runs", len(processedRuns)) - result := aggregateSummaryItems( - processedRuns, - // getItems: extract MCP failures from each run - func(pr ProcessedRun) []MCPFailureReport { - return pr.MCPFailures - }, - // getKey: use server name as the aggregation key - func(failure MCPFailureReport) string { - return failure.ServerName - }, - // createSummary: create new summary for first occurrence - func(failure MCPFailureReport) *MCPFailureSummary { - return &MCPFailureSummary{ - ServerName: failure.ServerName, - Count: 1, - Workflows: []string{failure.WorkflowName}, - RunIDs: []int64{failure.RunID}, - } - }, - // updateSummary: update existing summary with new occurrence - func(summary *MCPFailureSummary, failure MCPFailureReport) { - summary.Count++ - summary.Workflows = addUniqueWorkflow(summary.Workflows, failure.WorkflowName) - summary.RunIDs = append(summary.RunIDs, failure.RunID) - }, - // finalizeSummary: populate display fields for console rendering - func(summary *MCPFailureSummary) { - summary.WorkflowsDisplay = strings.Join(summary.Workflows, ", ") - }, - ) - - // Sort by count descending - slices.SortFunc(result, func(a, b MCPFailureSummary) int { - return cmp.Compare(b.Count, a.Count) - }) - - return result -} // domainAggregation holds the result of aggregating domain statistics type domainAggregation struct { @@ -674,312 +387,9 @@ type domainAggregation struct { blockedCount int } -// aggregateDomainStats aggregates domain statistics across runs -// This is a shared helper for both access log and firewall log summaries -func aggregateDomainStats(processedRuns []ProcessedRun, getAnalysis func(*ProcessedRun) (allowedDomains, blockedDomains []string, totalRequests, allowedCount, blockedCount int, exists bool)) *domainAggregation { - agg := &domainAggregation{ - allAllowedDomains: make(map[string]bool), - allBlockedDomains: make(map[string]bool), - } - - for _, pr := range processedRuns { - allowedDomains, blockedDomains, totalRequests, allowedCount, blockedCount, exists := getAnalysis(&pr) - if !exists { - continue - } - - agg.totalRequests += totalRequests - agg.allowedCount += allowedCount - agg.blockedCount += blockedCount - - for _, domain := range allowedDomains { - agg.allAllowedDomains[domain] = true - } - for _, domain := range blockedDomains { - agg.allBlockedDomains[domain] = true - } - } - - return agg -} - -// convertDomainsToSortedSlices converts domain maps to sorted slices -func convertDomainsToSortedSlices(allowedMap, blockedMap map[string]bool) (allowed, blocked []string) { - for domain := range allowedMap { - allowed = append(allowed, domain) - } - slices.Sort(allowed) - - for domain := range blockedMap { - blocked = append(blocked, domain) - } - slices.Sort(blocked) - - return allowed, blocked -} - -// buildAccessLogSummary aggregates access log data across all runs -func buildAccessLogSummary(processedRuns []ProcessedRun) *AccessLogSummary { - byWorkflow := make(map[string]*DomainAnalysis) - - // Use shared aggregation helper - agg := aggregateDomainStats(processedRuns, func(pr *ProcessedRun) ([]string, []string, int, int, int, bool) { - if pr.AccessAnalysis == nil { - return nil, nil, 0, 0, 0, false - } - byWorkflow[pr.Run.WorkflowName] = pr.AccessAnalysis - return pr.AccessAnalysis.AllowedDomains, - pr.AccessAnalysis.BlockedDomains, - pr.AccessAnalysis.TotalRequests, - pr.AccessAnalysis.AllowedCount, - pr.AccessAnalysis.BlockedCount, - true - }) - - if agg.totalRequests == 0 { - return nil - } - - allowedDomains, blockedDomains := convertDomainsToSortedSlices(agg.allAllowedDomains, agg.allBlockedDomains) - - return &AccessLogSummary{ - TotalRequests: agg.totalRequests, - AllowedCount: agg.allowedCount, - BlockedCount: agg.blockedCount, - AllowedDomains: allowedDomains, - BlockedDomains: blockedDomains, - ByWorkflow: byWorkflow, - } -} - -// buildFirewallLogSummary aggregates firewall log data across all runs -func buildFirewallLogSummary(processedRuns []ProcessedRun) *FirewallLogSummary { - allRequestsByDomain := make(map[string]DomainRequestStats) - byWorkflow := make(map[string]*FirewallAnalysis) - - // Use shared aggregation helper - agg := aggregateDomainStats(processedRuns, func(pr *ProcessedRun) ([]string, []string, int, int, int, bool) { - if pr.FirewallAnalysis == nil { - return nil, nil, 0, 0, 0, false - } - byWorkflow[pr.Run.WorkflowName] = pr.FirewallAnalysis - - // Aggregate request stats by domain (firewall-specific) - for domain, stats := range pr.FirewallAnalysis.RequestsByDomain { - existing := allRequestsByDomain[domain] - existing.Allowed += stats.Allowed - existing.Blocked += stats.Blocked - allRequestsByDomain[domain] = existing - } - - return pr.FirewallAnalysis.AllowedDomains, - pr.FirewallAnalysis.BlockedDomains, - pr.FirewallAnalysis.TotalRequests, - pr.FirewallAnalysis.AllowedRequests, - pr.FirewallAnalysis.BlockedRequests, - true - }) - - if agg.totalRequests == 0 { - return nil - } - - allowedDomains, blockedDomains := convertDomainsToSortedSlices(agg.allAllowedDomains, agg.allBlockedDomains) - - return &FirewallLogSummary{ - TotalRequests: agg.totalRequests, - AllowedRequests: agg.allowedCount, - BlockedRequests: agg.blockedCount, - AllowedDomains: allowedDomains, - BlockedDomains: blockedDomains, - RequestsByDomain: allRequestsByDomain, - ByWorkflow: byWorkflow, - } -} - -// buildRedactedDomainsSummary aggregates redacted domains data across all runs -func buildRedactedDomainsSummary(processedRuns []ProcessedRun) *RedactedDomainsLogSummary { - allDomainsSet := make(map[string]bool) - byWorkflow := make(map[string]*RedactedDomainsAnalysis) - hasData := false - - for _, pr := range processedRuns { - if pr.RedactedDomainsAnalysis == nil { - continue - } - hasData = true - byWorkflow[pr.Run.WorkflowName] = pr.RedactedDomainsAnalysis - - // Collect all unique domains - for _, domain := range pr.RedactedDomainsAnalysis.Domains { - allDomainsSet[domain] = true - } - } - - if !hasData { - return nil - } - - // Convert set to sorted slice - var allDomains []string - for domain := range allDomainsSet { - allDomains = append(allDomains, domain) - } - slices.Sort(allDomains) - - return &RedactedDomainsLogSummary{ - TotalDomains: len(allDomains), - Domains: allDomains, - ByWorkflow: byWorkflow, - } -} - -// buildMCPToolUsageSummary aggregates MCP tool usage data across all runs -func buildMCPToolUsageSummary(processedRuns []ProcessedRun) *MCPToolUsageSummary { - reportLog.Printf("Building MCP tool usage summary from %d processed runs", len(processedRuns)) - - // Maps for aggregating data - toolSummaryMap := make(map[string]*MCPToolSummary) // Key: serverName:toolName - serverStatsMap := make(map[string]*MCPServerStats) // Key: serverName - var allToolCalls []MCPToolCall - var allFilteredEvents []DifcFilteredEvent - - // Aggregate data from all runs - for _, pr := range processedRuns { - if pr.MCPToolUsage == nil { - continue - } - - // Aggregate tool calls - allToolCalls = append(allToolCalls, pr.MCPToolUsage.ToolCalls...) - - // Aggregate DIFC filtered events - allFilteredEvents = append(allFilteredEvents, pr.MCPToolUsage.FilteredEvents...) - - // Aggregate tool summaries - for _, summary := range pr.MCPToolUsage.Summary { - key := summary.ServerName + ":" + summary.ToolName - - if existing, exists := toolSummaryMap[key]; exists { - // Store previous count before updating - prevCallCount := existing.CallCount - - // Merge with existing summary - existing.CallCount += summary.CallCount - existing.TotalInputSize += summary.TotalInputSize - existing.TotalOutputSize += summary.TotalOutputSize - - // Update max sizes - if summary.MaxInputSize > existing.MaxInputSize { - existing.MaxInputSize = summary.MaxInputSize - } - if summary.MaxOutputSize > existing.MaxOutputSize { - existing.MaxOutputSize = summary.MaxOutputSize - } - - // Update error count - existing.ErrorCount += summary.ErrorCount - - // Recalculate average duration (weighted) - if summary.AvgDuration != "" && existing.CallCount > 0 { - existingDur := parseDurationString(existing.AvgDuration) - newDur := parseDurationString(summary.AvgDuration) - // Weight by call counts using previous count - weightedDur := (existingDur*time.Duration(prevCallCount) + newDur*time.Duration(summary.CallCount)) / time.Duration(existing.CallCount) - existing.AvgDuration = timeutil.FormatDuration(weightedDur) - } - - // Update max duration - if summary.MaxDuration != "" { - maxDur := parseDurationString(summary.MaxDuration) - existingMaxDur := parseDurationString(existing.MaxDuration) - if maxDur > existingMaxDur { - existing.MaxDuration = summary.MaxDuration - } - } - } else { - // Create new summary entry (copy to avoid mutation) - newSummary := summary - toolSummaryMap[key] = &newSummary - } - } - - // Aggregate server stats - for _, serverStats := range pr.MCPToolUsage.Servers { - if existing, exists := serverStatsMap[serverStats.ServerName]; exists { - // Store previous count before updating - prevRequestCount := existing.RequestCount - - // Merge with existing stats - existing.RequestCount += serverStats.RequestCount - existing.ToolCallCount += serverStats.ToolCallCount - existing.TotalInputSize += serverStats.TotalInputSize - existing.TotalOutputSize += serverStats.TotalOutputSize - existing.ErrorCount += serverStats.ErrorCount - - // Recalculate average duration (weighted) - if serverStats.AvgDuration != "" && existing.RequestCount > 0 { - existingDur := parseDurationString(existing.AvgDuration) - newDur := parseDurationString(serverStats.AvgDuration) - // Weight by request counts using previous count - weightedDur := (existingDur*time.Duration(prevRequestCount) + newDur*time.Duration(serverStats.RequestCount)) / time.Duration(existing.RequestCount) - existing.AvgDuration = timeutil.FormatDuration(weightedDur) - } - } else { - // Create new server stats entry (copy to avoid mutation) - newStats := serverStats - serverStatsMap[serverStats.ServerName] = &newStats - } - } - } - - // Return nil if no MCP tool usage data was found - if len(toolSummaryMap) == 0 && len(serverStatsMap) == 0 && len(allFilteredEvents) == 0 { - return nil - } - - // Convert maps to slices - var summaries []MCPToolSummary - for _, summary := range toolSummaryMap { - summaries = append(summaries, *summary) - } - - var servers []MCPServerStats - for _, stats := range serverStatsMap { - servers = append(servers, *stats) - } - - // Sort summaries by server name, then tool name - slices.SortFunc(summaries, func(a, b MCPToolSummary) int { - if a.ServerName != b.ServerName { - return cmp.Compare(a.ServerName, b.ServerName) - } - return cmp.Compare(a.ToolName, b.ToolName) - }) - - // Sort servers by name - slices.SortFunc(servers, func(a, b MCPServerStats) int { - return cmp.Compare(a.ServerName, b.ServerName) - }) - - reportLog.Printf("Built MCP tool usage summary: %d tool summaries, %d servers, %d total tool calls, %d DIFC filtered events", - len(summaries), len(servers), len(allToolCalls), len(allFilteredEvents)) - - return &MCPToolUsageSummary{ - Summary: summaries, - Servers: servers, - ToolCalls: allToolCalls, - FilteredEvents: allFilteredEvents, - } -} - // logErrorAggregator and related functions have been removed since error patterns are no longer supported // buildCombinedErrorsSummary has been removed since error patterns are no longer supported -func buildCombinedErrorsSummary(processedRuns []ProcessedRun) []ErrorSummary { - // Return empty slice since error patterns have been removed - return []ErrorSummary{} -} // renderLogsJSON outputs the logs data as JSON func renderLogsJSON(data LogsData) error { diff --git a/pkg/cli/logs_report_domains.go b/pkg/cli/logs_report_domains.go new file mode 100644 index 00000000000..de20f4513a9 --- /dev/null +++ b/pkg/cli/logs_report_domains.go @@ -0,0 +1,169 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (logs_report_domains.go) contains domain statistics aggregation +// functions for access logs, firewall logs, and redacted domain summaries. + +package cli + +import ( + "slices" +) + +// aggregateDomainStats aggregates domain statistics across runs +// This is a shared helper for both access log and firewall log summaries +func aggregateDomainStats(processedRuns []ProcessedRun, getAnalysis func(*ProcessedRun) (allowedDomains, blockedDomains []string, totalRequests, allowedCount, blockedCount int, exists bool)) *domainAggregation { + agg := &domainAggregation{ + allAllowedDomains: make(map[string]bool), + allBlockedDomains: make(map[string]bool), + } + + for _, pr := range processedRuns { + allowedDomains, blockedDomains, totalRequests, allowedCount, blockedCount, exists := getAnalysis(&pr) + if !exists { + continue + } + + agg.totalRequests += totalRequests + agg.allowedCount += allowedCount + agg.blockedCount += blockedCount + + for _, domain := range allowedDomains { + agg.allAllowedDomains[domain] = true + } + for _, domain := range blockedDomains { + agg.allBlockedDomains[domain] = true + } + } + + return agg +} + +// convertDomainsToSortedSlices converts domain maps to sorted slices +func convertDomainsToSortedSlices(allowedMap, blockedMap map[string]bool) (allowed, blocked []string) { + for domain := range allowedMap { + allowed = append(allowed, domain) + } + slices.Sort(allowed) + + for domain := range blockedMap { + blocked = append(blocked, domain) + } + slices.Sort(blocked) + + return allowed, blocked +} + +// buildAccessLogSummary aggregates access log data across all runs +func buildAccessLogSummary(processedRuns []ProcessedRun) *AccessLogSummary { + byWorkflow := make(map[string]*DomainAnalysis) + + // Use shared aggregation helper + agg := aggregateDomainStats(processedRuns, func(pr *ProcessedRun) ([]string, []string, int, int, int, bool) { + if pr.AccessAnalysis == nil { + return nil, nil, 0, 0, 0, false + } + byWorkflow[pr.Run.WorkflowName] = pr.AccessAnalysis + return pr.AccessAnalysis.AllowedDomains, + pr.AccessAnalysis.BlockedDomains, + pr.AccessAnalysis.TotalRequests, + pr.AccessAnalysis.AllowedCount, + pr.AccessAnalysis.BlockedCount, + true + }) + + if agg.totalRequests == 0 { + return nil + } + + allowedDomains, blockedDomains := convertDomainsToSortedSlices(agg.allAllowedDomains, agg.allBlockedDomains) + + return &AccessLogSummary{ + TotalRequests: agg.totalRequests, + AllowedCount: agg.allowedCount, + BlockedCount: agg.blockedCount, + AllowedDomains: allowedDomains, + BlockedDomains: blockedDomains, + ByWorkflow: byWorkflow, + } +} + +// buildFirewallLogSummary aggregates firewall log data across all runs +func buildFirewallLogSummary(processedRuns []ProcessedRun) *FirewallLogSummary { + allRequestsByDomain := make(map[string]DomainRequestStats) + byWorkflow := make(map[string]*FirewallAnalysis) + + // Use shared aggregation helper + agg := aggregateDomainStats(processedRuns, func(pr *ProcessedRun) ([]string, []string, int, int, int, bool) { + if pr.FirewallAnalysis == nil { + return nil, nil, 0, 0, 0, false + } + byWorkflow[pr.Run.WorkflowName] = pr.FirewallAnalysis + + // Aggregate request stats by domain (firewall-specific) + for domain, stats := range pr.FirewallAnalysis.RequestsByDomain { + existing := allRequestsByDomain[domain] + existing.Allowed += stats.Allowed + existing.Blocked += stats.Blocked + allRequestsByDomain[domain] = existing + } + + return pr.FirewallAnalysis.AllowedDomains, + pr.FirewallAnalysis.BlockedDomains, + pr.FirewallAnalysis.TotalRequests, + pr.FirewallAnalysis.AllowedRequests, + pr.FirewallAnalysis.BlockedRequests, + true + }) + + if agg.totalRequests == 0 { + return nil + } + + allowedDomains, blockedDomains := convertDomainsToSortedSlices(agg.allAllowedDomains, agg.allBlockedDomains) + + return &FirewallLogSummary{ + TotalRequests: agg.totalRequests, + AllowedRequests: agg.allowedCount, + BlockedRequests: agg.blockedCount, + AllowedDomains: allowedDomains, + BlockedDomains: blockedDomains, + RequestsByDomain: allRequestsByDomain, + ByWorkflow: byWorkflow, + } +} + +// buildRedactedDomainsSummary aggregates redacted domains data across all runs +func buildRedactedDomainsSummary(processedRuns []ProcessedRun) *RedactedDomainsLogSummary { + allDomainsSet := make(map[string]bool) + byWorkflow := make(map[string]*RedactedDomainsAnalysis) + hasData := false + + for _, pr := range processedRuns { + if pr.RedactedDomainsAnalysis == nil { + continue + } + hasData = true + byWorkflow[pr.Run.WorkflowName] = pr.RedactedDomainsAnalysis + + // Collect all unique domains + for _, domain := range pr.RedactedDomainsAnalysis.Domains { + allDomainsSet[domain] = true + } + } + + if !hasData { + return nil + } + + // Convert set to sorted slice + var allDomains []string + for domain := range allDomainsSet { + allDomains = append(allDomains, domain) + } + slices.Sort(allDomains) + + return &RedactedDomainsLogSummary{ + TotalDomains: len(allDomains), + Domains: allDomains, + ByWorkflow: byWorkflow, + } +} diff --git a/pkg/cli/logs_report_mcp.go b/pkg/cli/logs_report_mcp.go new file mode 100644 index 00000000000..500faeb5df0 --- /dev/null +++ b/pkg/cli/logs_report_mcp.go @@ -0,0 +1,152 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (logs_report_mcp.go) contains the MCP tool usage summary builder +// for the logs report, aggregating gateway metrics across multiple workflow runs. + +package cli + +import ( + "cmp" + "slices" + "time" + + "github.com/github/gh-aw/pkg/timeutil" +) + +// buildMCPToolUsageSummary aggregates MCP tool usage data across all runs +func buildMCPToolUsageSummary(processedRuns []ProcessedRun) *MCPToolUsageSummary { + reportLog.Printf("Building MCP tool usage summary from %d processed runs", len(processedRuns)) + + // Maps for aggregating data + toolSummaryMap := make(map[string]*MCPToolSummary) // Key: serverName:toolName + serverStatsMap := make(map[string]*MCPServerStats) // Key: serverName + var allToolCalls []MCPToolCall + var allFilteredEvents []DifcFilteredEvent + + // Aggregate data from all runs + for _, pr := range processedRuns { + if pr.MCPToolUsage == nil { + continue + } + + // Aggregate tool calls + allToolCalls = append(allToolCalls, pr.MCPToolUsage.ToolCalls...) + + // Aggregate DIFC filtered events + allFilteredEvents = append(allFilteredEvents, pr.MCPToolUsage.FilteredEvents...) + + // Aggregate tool summaries + for _, summary := range pr.MCPToolUsage.Summary { + key := summary.ServerName + ":" + summary.ToolName + + if existing, exists := toolSummaryMap[key]; exists { + // Store previous count before updating + prevCallCount := existing.CallCount + + // Merge with existing summary + existing.CallCount += summary.CallCount + existing.TotalInputSize += summary.TotalInputSize + existing.TotalOutputSize += summary.TotalOutputSize + + // Update max sizes + if summary.MaxInputSize > existing.MaxInputSize { + existing.MaxInputSize = summary.MaxInputSize + } + if summary.MaxOutputSize > existing.MaxOutputSize { + existing.MaxOutputSize = summary.MaxOutputSize + } + + // Update error count + existing.ErrorCount += summary.ErrorCount + + // Recalculate average duration (weighted) + if summary.AvgDuration != "" && existing.CallCount > 0 { + existingDur := parseDurationString(existing.AvgDuration) + newDur := parseDurationString(summary.AvgDuration) + // Weight by call counts using previous count + weightedDur := (existingDur*time.Duration(prevCallCount) + newDur*time.Duration(summary.CallCount)) / time.Duration(existing.CallCount) + existing.AvgDuration = timeutil.FormatDuration(weightedDur) + } + + // Update max duration + if summary.MaxDuration != "" { + maxDur := parseDurationString(summary.MaxDuration) + existingMaxDur := parseDurationString(existing.MaxDuration) + if maxDur > existingMaxDur { + existing.MaxDuration = summary.MaxDuration + } + } + } else { + // Create new summary entry (copy to avoid mutation) + newSummary := summary + toolSummaryMap[key] = &newSummary + } + } + + // Aggregate server stats + for _, serverStats := range pr.MCPToolUsage.Servers { + if existing, exists := serverStatsMap[serverStats.ServerName]; exists { + // Store previous count before updating + prevRequestCount := existing.RequestCount + + // Merge with existing stats + existing.RequestCount += serverStats.RequestCount + existing.ToolCallCount += serverStats.ToolCallCount + existing.TotalInputSize += serverStats.TotalInputSize + existing.TotalOutputSize += serverStats.TotalOutputSize + existing.ErrorCount += serverStats.ErrorCount + + // Recalculate average duration (weighted) + if serverStats.AvgDuration != "" && existing.RequestCount > 0 { + existingDur := parseDurationString(existing.AvgDuration) + newDur := parseDurationString(serverStats.AvgDuration) + // Weight by request counts using previous count + weightedDur := (existingDur*time.Duration(prevRequestCount) + newDur*time.Duration(serverStats.RequestCount)) / time.Duration(existing.RequestCount) + existing.AvgDuration = timeutil.FormatDuration(weightedDur) + } + } else { + // Create new server stats entry (copy to avoid mutation) + newStats := serverStats + serverStatsMap[serverStats.ServerName] = &newStats + } + } + } + + // Return nil if no MCP tool usage data was found + if len(toolSummaryMap) == 0 && len(serverStatsMap) == 0 && len(allFilteredEvents) == 0 { + return nil + } + + // Convert maps to slices + var summaries []MCPToolSummary + for _, summary := range toolSummaryMap { + summaries = append(summaries, *summary) + } + + var servers []MCPServerStats + for _, stats := range serverStatsMap { + servers = append(servers, *stats) + } + + // Sort summaries by server name, then tool name + slices.SortFunc(summaries, func(a, b MCPToolSummary) int { + if a.ServerName != b.ServerName { + return cmp.Compare(a.ServerName, b.ServerName) + } + return cmp.Compare(a.ToolName, b.ToolName) + }) + + // Sort servers by name + slices.SortFunc(servers, func(a, b MCPServerStats) int { + return cmp.Compare(a.ServerName, b.ServerName) + }) + + reportLog.Printf("Built MCP tool usage summary: %d tool summaries, %d servers, %d total tool calls, %d DIFC filtered events", + len(summaries), len(servers), len(allToolCalls), len(allFilteredEvents)) + + return &MCPToolUsageSummary{ + Summary: summaries, + Servers: servers, + ToolCalls: allToolCalls, + FilteredEvents: allFilteredEvents, + } +} diff --git a/pkg/cli/logs_report_tools.go b/pkg/cli/logs_report_tools.go new file mode 100644 index 00000000000..0f8b23454a3 --- /dev/null +++ b/pkg/cli/logs_report_tools.go @@ -0,0 +1,304 @@ +// This file provides command-line interface functionality for gh-aw. +// This file (logs_report_tools.go) contains tool usage, missing tool, MCP failure, +// and combined error summary building functions for the logs report. + +package cli + +import ( + "cmp" + "slices" + "strings" + + "github.com/github/gh-aw/pkg/timeutil" + "github.com/github/gh-aw/pkg/workflow" +) + +// isValidToolName checks if a tool name appears to be valid +// Filters out single words, common words, and other garbage that shouldn't be tools +func isValidToolName(toolName string) bool { + name := strings.TrimSpace(toolName) + + // Filter out empty names + if name == "" || name == "-" { + return false + } + + // Filter out single character names + if len(name) == 1 { + return false + } + + // Filter out common English words that are likely from error messages + commonWords := map[string]bool{ + "calls": true, "to": true, "for": true, "the": true, "a": true, "an": true, + "is": true, "are": true, "was": true, "were": true, "be": true, "been": true, + "have": true, "has": true, "had": true, "do": true, "does": true, "did": true, + "will": true, "would": true, "could": true, "should": true, "may": true, "might": true, + "Testing": true, "multiple": true, "launches": true, "command": true, "invocation": true, + "with": true, "from": true, "by": true, "at": true, "in": true, "on": true, + } + + if commonWords[name] { + return false + } + + // Tool names should typically contain underscores, hyphens, or be camelCase + // or be all lowercase. Single words without these patterns are suspect. + hasUnderscore := strings.Contains(name, "_") + hasHyphen := strings.Contains(name, "-") + hasCapital := strings.ToLower(name) != name + + // If it's a single word with no underscores/hyphens and is lowercase and short, + // it's likely a fragment + words := strings.Fields(name) + if len(words) == 1 && !hasUnderscore && !hasHyphen && len(name) < 10 && !hasCapital { + // Could be a fragment - be conservative and reject if it's a common word + return false + } + + return true +} + +func buildToolUsageSummary(processedRuns []ProcessedRun) []ToolUsageSummary { + reportLog.Printf("Building tool usage summary from %d processed runs", len(processedRuns)) + toolStats := make(map[string]*ToolUsageSummary) + + for _, pr := range processedRuns { + // Extract metrics from run's logs + metrics := ExtractLogMetricsFromRun(pr) + + // Track which runs use each tool + toolRunTracker := make(map[string]bool) + + for _, toolCall := range metrics.ToolCalls { + displayKey := workflow.PrettifyToolName(toolCall.Name) + + // Filter out invalid tool names + if !isValidToolName(displayKey) { + continue + } + + toolRunTracker[displayKey] = true + + if existing, exists := toolStats[displayKey]; exists { + existing.TotalCalls += toolCall.CallCount + if toolCall.MaxOutputSize > existing.MaxOutputSize { + existing.MaxOutputSize = toolCall.MaxOutputSize + } + if toolCall.MaxDuration > 0 { + maxDur := timeutil.FormatDuration(toolCall.MaxDuration) + if existing.MaxDuration == "" || toolCall.MaxDuration > parseDurationString(existing.MaxDuration) { + existing.MaxDuration = maxDur + } + } + } else { + info := &ToolUsageSummary{ + Name: displayKey, + TotalCalls: toolCall.CallCount, + MaxOutputSize: toolCall.MaxOutputSize, + Runs: 0, // Will be incremented below + } + if toolCall.MaxDuration > 0 { + info.MaxDuration = timeutil.FormatDuration(toolCall.MaxDuration) + } + toolStats[displayKey] = info + } + } + + // Increment run count for tools used in this run + for toolName := range toolRunTracker { + if stat, exists := toolStats[toolName]; exists { + stat.Runs++ + } + } + } + + var result []ToolUsageSummary + for _, info := range toolStats { + result = append(result, *info) + } + + // Sort by total calls descending + slices.SortFunc(result, func(a, b ToolUsageSummary) int { + return cmp.Compare(b.TotalCalls, a.TotalCalls) + }) + + return result +} + +// addUniqueWorkflow adds a workflow to the list if it's not already present +func addUniqueWorkflow(workflows []string, workflow string) []string { + if slices.Contains(workflows, workflow) { + return workflows + } + return append(workflows, workflow) +} + +// aggregateSummaryItems is a generic helper that aggregates items from processed runs into summaries +// It handles the common pattern of grouping by key, counting occurrences, tracking unique workflows, and collecting run IDs +func aggregateSummaryItems[TItem any, TSummary any]( + processedRuns []ProcessedRun, + getItems func(ProcessedRun) []TItem, + getKey func(TItem) string, + createSummary func(TItem) *TSummary, + updateSummary func(*TSummary, TItem), + finalizeSummary func(*TSummary), +) []TSummary { + summaryMap := make(map[string]*TSummary) + + // Aggregate items from all runs + for _, pr := range processedRuns { + for _, item := range getItems(pr) { + key := getKey(item) + if summary, exists := summaryMap[key]; exists { + updateSummary(summary, item) + } else { + summaryMap[key] = createSummary(item) + } + } + } + + // Convert map to slice and finalize each summary + var result []TSummary + for _, summary := range summaryMap { + finalizeSummary(summary) + result = append(result, *summary) + } + + return result +} + +// buildMissingToolsSummary aggregates missing tools across all runs +func buildMissingToolsSummary(processedRuns []ProcessedRun) []MissingToolSummary { + reportLog.Printf("Building missing tools summary from %d processed runs", len(processedRuns)) + result := aggregateSummaryItems( + processedRuns, + // getItems: extract missing tools from each run + func(pr ProcessedRun) []MissingToolReport { + return pr.MissingTools + }, + // getKey: use tool name as the aggregation key + func(tool MissingToolReport) string { + return tool.Tool + }, + // createSummary: create new summary for first occurrence + func(tool MissingToolReport) *MissingToolSummary { + return &MissingToolSummary{ + Tool: tool.Tool, + Count: 1, + Workflows: []string{tool.WorkflowName}, + FirstReason: tool.Reason, + RunIDs: []int64{tool.RunID}, + } + }, + // updateSummary: update existing summary with new occurrence + func(summary *MissingToolSummary, tool MissingToolReport) { + summary.Count++ + summary.Workflows = addUniqueWorkflow(summary.Workflows, tool.WorkflowName) + summary.RunIDs = append(summary.RunIDs, tool.RunID) + }, + // finalizeSummary: populate display fields for console rendering + func(summary *MissingToolSummary) { + summary.WorkflowsDisplay = strings.Join(summary.Workflows, ", ") + summary.FirstReasonDisplay = summary.FirstReason + }, + ) + + // Sort by count descending + slices.SortFunc(result, func(a, b MissingToolSummary) int { + return cmp.Compare(b.Count, a.Count) + }) + + return result +} + +// buildMissingDataSummary aggregates missing data across all runs +func buildMissingDataSummary(processedRuns []ProcessedRun) []MissingDataSummary { + reportLog.Printf("Building missing data summary from %d processed runs", len(processedRuns)) + result := aggregateSummaryItems( + processedRuns, + // getItems: extract missing data from each run + func(pr ProcessedRun) []MissingDataReport { + return pr.MissingData + }, + // getKey: use data type as the aggregation key + func(data MissingDataReport) string { + return data.DataType + }, + // createSummary: create new summary for first occurrence + func(data MissingDataReport) *MissingDataSummary { + return &MissingDataSummary{ + DataType: data.DataType, + Count: 1, + Workflows: []string{data.WorkflowName}, + FirstReason: data.Reason, + RunIDs: []int64{data.RunID}, + } + }, + // updateSummary: update existing summary with new occurrence + func(summary *MissingDataSummary, data MissingDataReport) { + summary.Count++ + summary.Workflows = addUniqueWorkflow(summary.Workflows, data.WorkflowName) + summary.RunIDs = append(summary.RunIDs, data.RunID) + }, + // finalizeSummary: populate display fields for console rendering + func(summary *MissingDataSummary) { + summary.WorkflowsDisplay = strings.Join(summary.Workflows, ", ") + summary.FirstReasonDisplay = summary.FirstReason + }, + ) + + // Sort by count descending + slices.SortFunc(result, func(a, b MissingDataSummary) int { + return cmp.Compare(b.Count, a.Count) + }) + + return result +} + +// buildMCPFailuresSummary aggregates MCP failures across all runs +func buildMCPFailuresSummary(processedRuns []ProcessedRun) []MCPFailureSummary { + reportLog.Printf("Building MCP failures summary from %d processed runs", len(processedRuns)) + result := aggregateSummaryItems( + processedRuns, + // getItems: extract MCP failures from each run + func(pr ProcessedRun) []MCPFailureReport { + return pr.MCPFailures + }, + // getKey: use server name as the aggregation key + func(failure MCPFailureReport) string { + return failure.ServerName + }, + // createSummary: create new summary for first occurrence + func(failure MCPFailureReport) *MCPFailureSummary { + return &MCPFailureSummary{ + ServerName: failure.ServerName, + Count: 1, + Workflows: []string{failure.WorkflowName}, + RunIDs: []int64{failure.RunID}, + } + }, + // updateSummary: update existing summary with new occurrence + func(summary *MCPFailureSummary, failure MCPFailureReport) { + summary.Count++ + summary.Workflows = addUniqueWorkflow(summary.Workflows, failure.WorkflowName) + summary.RunIDs = append(summary.RunIDs, failure.RunID) + }, + // finalizeSummary: populate display fields for console rendering + func(summary *MCPFailureSummary) { + summary.WorkflowsDisplay = strings.Join(summary.Workflows, ", ") + }, + ) + + // Sort by count descending + slices.SortFunc(result, func(a, b MCPFailureSummary) int { + return cmp.Compare(b.Count, a.Count) + }) + + return result +} + +func buildCombinedErrorsSummary(processedRuns []ProcessedRun) []ErrorSummary { + // Return empty slice since error patterns have been removed + return []ErrorSummary{} +} diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 12b747fba4a..168be6d4d77 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -144,785 +144,13 @@ func (b *handlerConfigBuilder) Build() map[string]any { // handlerBuilder is a function that builds a handler config from SafeOutputsConfig type handlerBuilder func(*SafeOutputsConfig) map[string]any -// handlerRegistry maps handler names to their builder functions -var handlerRegistry = map[string]handlerBuilder{ - "create_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateIssues == nil { - return nil - } - c := cfg.CreateIssues - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed_labels", c.AllowedLabels). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfPositive("expires", c.Expires). - AddStringSlice("labels", c.Labels). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddStringSlice("assignees", c.Assignees). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableBool("group", c.Group). - AddTemplatableBool("close_older_issues", c.CloseOlderIssues). - AddIfNotEmpty("close_older_key", c.CloseOlderKey). - AddTemplatableBool("group_by_day", c.GroupByDay). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "add_comment": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AddComments == nil { - return nil - } - c := cfg.AddComments - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddTemplatableBool("hide_older_comments", c.HideOlderComments). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_discussion": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateDiscussions == nil { - return nil - } - c := cfg.CreateDiscussions - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("category", c.Category). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddStringSlice("labels", c.Labels). - AddStringSlice("allowed_labels", c.AllowedLabels). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddTemplatableBool("close_older_discussions", c.CloseOlderDiscussions). - AddIfNotEmpty("close_older_key", c.CloseOlderKey). - AddIfNotEmpty("required_category", c.RequiredCategory). - AddIfPositive("expires", c.Expires). - AddBoolPtr("fallback_to_issue", c.FallbackToIssue). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "close_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CloseIssues == nil { - return nil - } - c := cfg.CloseIssues - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("state_reason", c.StateReason). - AddIfTrue("staged", c.Staged). - Build() - }, - "close_discussion": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CloseDiscussions == nil { - return nil - } - c := cfg.CloseDiscussions - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfTrue("staged", c.Staged). - Build() - }, - "add_labels": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AddLabels == nil { - return nil - } - c := cfg.AddLabels - config := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddStringSlice("blocked", c.Blocked). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - // If config is empty, it means add_labels was explicitly configured with no options - // (null config), which means "allow any labels". Return non-nil empty map to - // indicate the handler is enabled. - if len(config) == 0 { - // Return empty map so handler is included in config - return make(map[string]any) - } - return config - }, - "remove_labels": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.RemoveLabels == nil { - return nil - } - c := cfg.RemoveLabels - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddStringSlice("blocked", c.Blocked). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "add_reviewer": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AddReviewer == nil { - return nil - } - c := cfg.AddReviewer - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Reviewers). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "assign_milestone": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AssignMilestone == nil { - return nil - } - c := cfg.AssignMilestone - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - AddIfTrue("auto_create", c.AutoCreate). - Build() - }, - "mark_pull_request_as_ready_for_review": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.MarkPullRequestAsReadyForReview == nil { - return nil - } - c := cfg.MarkPullRequestAsReadyForReview - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateCodeScanningAlerts == nil { - return nil - } - c := cfg.CreateCodeScanningAlerts - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("driver", c.Driver). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_agent_session": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateAgentSessions == nil { - return nil - } - c := cfg.CreateAgentSessions - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("base", c.Base). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "update_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdateIssues == nil { - return nil - } - c := cfg.UpdateIssues - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("title_prefix", c.TitlePrefix) - // Boolean pointer fields indicate which fields can be updated - if c.Status != nil { - builder.AddDefault("allow_status", true) - } - if c.Title != nil { - builder.AddDefault("allow_title", true) - } - // Body uses boolean value mode - add the actual boolean value - builder.AddBoolPtrOrDefault("allow_body", c.Body, true) - return builder. - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "update_discussion": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdateDiscussions == nil { - return nil - } - c := cfg.UpdateDiscussions - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target) - // Boolean pointer fields indicate which fields can be updated - if c.Title != nil { - builder.AddDefault("allow_title", true) - } - if c.Body != nil { - builder.AddDefault("allow_body", true) - } - if c.Labels != nil { - builder.AddDefault("allow_labels", true) - } - return builder. - AddStringSlice("allowed_labels", c.AllowedLabels). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.LinkSubIssue == nil { - return nil - } - c := cfg.LinkSubIssue - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("parent_required_labels", c.ParentRequiredLabels). - AddIfNotEmpty("parent_title_prefix", c.ParentTitlePrefix). - AddStringSlice("sub_required_labels", c.SubRequiredLabels). - AddIfNotEmpty("sub_title_prefix", c.SubTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "update_release": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdateRelease == nil { - return nil - } - c := cfg.UpdateRelease - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreatePullRequestReviewComments == nil { - return nil - } - c := cfg.CreatePullRequestReviewComments - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("side", c.Side). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "submit_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.SubmitPullRequestReview == nil { - return nil - } - c := cfg.SubmitPullRequestReview - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddStringSlice("allowed_events", c.AllowedEvents). - AddIfNotEmpty("github-token", c.GitHubToken). - AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "reply_to_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ReplyToPullRequestReviewComment == nil { - return nil - } - c := cfg.ReplyToPullRequestReviewComment - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "resolve_pull_request_review_thread": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ResolvePullRequestReviewThread == nil { - return nil - } - c := cfg.ResolvePullRequestReviewThread - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreatePullRequests == nil { - return nil - } - c := cfg.CreatePullRequests - maxPatchSize := 1024 // default 1024 KB - if cfg.MaximumPatchSize > 0 { - maxPatchSize = cfg.MaximumPatchSize - } - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddStringSlice("labels", c.Labels). - AddStringSlice("reviewers", c.Reviewers). - AddStringSlice("assignees", c.Assignees). - AddTemplatableBool("draft", c.Draft). - AddIfNotEmpty("if_no_changes", c.IfNoChanges). - AddTemplatableBool("allow_empty", c.AllowEmpty). - AddTemplatableBool("auto_merge", c.AutoMerge). - AddIfPositive("expires", c.Expires). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddDefault("max_patch_size", maxPatchSize). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddBoolPtr("fallback_as_issue", c.FallbackAsIssue). - AddTemplatableBool("auto_close_issue", c.AutoCloseIssue). - AddIfNotEmpty("base_branch", c.BaseBranch). - AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). - AddStringSlice("protected_files", getAllManifestFiles()). - AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). - AddStringSlice("allowed_files", c.AllowedFiles). - AddStringSlice("excluded_files", c.ExcludedFiles). - AddIfTrue("preserve_branch_name", c.PreserveBranchName). - AddIfNotEmpty("patch_format", c.PatchFormat). - AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.PushToPullRequestBranch == nil { - return nil - } - c := cfg.PushToPullRequestBranch - maxPatchSize := 1024 // default 1024 KB - if cfg.MaximumPatchSize > 0 { - maxPatchSize = cfg.MaximumPatchSize - } - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddStringSlice("labels", c.Labels). - AddIfNotEmpty("if_no_changes", c.IfNoChanges). - AddIfNotEmpty("commit_title_suffix", c.CommitTitleSuffix). - AddDefault("max_patch_size", maxPatchSize). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). - AddStringSlice("protected_files", getAllManifestFiles()). - AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). - AddStringSlice("allowed_files", c.AllowedFiles). - AddStringSlice("excluded_files", c.ExcludedFiles). - AddIfNotEmpty("patch_format", c.PatchFormat). - Build() - }, - "update_pull_request": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdatePullRequests == nil { - return nil - } - c := cfg.UpdatePullRequests - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddBoolPtrOrDefault("allow_title", c.Title, true). - AddBoolPtrOrDefault("allow_body", c.Body, true). - AddStringPtr("default_operation", c.Operation). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "close_pull_request": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ClosePullRequests == nil { - return nil - } - c := cfg.ClosePullRequests - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "hide_comment": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.HideComment == nil { - return nil - } - c := cfg.HideComment - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed_reasons", c.AllowedReasons). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "dispatch_workflow": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.DispatchWorkflow == nil { - return nil - } - c := cfg.DispatchWorkflow - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("workflows", c.Workflows). - AddIfNotEmpty("target-repo", c.TargetRepoSlug) - - // Add workflow_files map if it has entries - if len(c.WorkflowFiles) > 0 { - builder.AddDefault("workflow_files", c.WorkflowFiles) - } - - // Add aw_context_workflows list if it has entries - if len(c.AwContextWorkflows) > 0 { - builder.AddStringSlice("aw_context_workflows", c.AwContextWorkflows) - } - - builder.AddIfNotEmpty("target-ref", c.TargetRef) - builder.AddIfNotEmpty("github-token", c.GitHubToken) - builder.AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "dispatch_repository": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.DispatchRepository == nil || len(cfg.DispatchRepository.Tools) == 0 { - return nil - } - // Serialize each tool as a sub-map - tools := make(map[string]any, len(cfg.DispatchRepository.Tools)) - for toolKey, tool := range cfg.DispatchRepository.Tools { - toolConfig := newHandlerConfigBuilder(). - AddIfNotEmpty("workflow", tool.Workflow). - AddIfNotEmpty("event_type", tool.EventType). - AddIfNotEmpty("repository", tool.Repository). - AddStringSlice("allowed_repositories", tool.AllowedRepositories). - AddTemplatableInt("max", tool.Max). - AddIfNotEmpty("github-token", tool.GitHubToken). - AddIfTrue("staged", tool.Staged). - Build() - tools[toolKey] = toolConfig - } - return map[string]any{"tools": tools} - }, - "call_workflow": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CallWorkflow == nil { - return nil - } - c := cfg.CallWorkflow - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("workflows", c.Workflows) - - // Add workflow_files map if it has entries - if len(c.WorkflowFiles) > 0 { - builder.AddDefault("workflow_files", c.WorkflowFiles) - } - - builder.AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "missing_tool": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.MissingTool == nil { - return nil - } - c := cfg.MissingTool - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "missing_data": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.MissingData == nil { - return nil - } - c := cfg.MissingData - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "noop": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.NoOp == nil { - return nil - } - c := cfg.NoOp - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringPtr("report-as-issue", c.ReportAsIssue). - AddIfTrue("staged", c.Staged). - Build() - }, - "report_incomplete": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ReportIncomplete == nil { - return nil - } - c := cfg.ReportIncomplete - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_report_incomplete_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ReportIncomplete == nil { - return nil - } - c := cfg.ReportIncomplete - if !c.CreateIssue { - return nil - } - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("title-prefix", c.TitlePrefix). - AddStringSlice("labels", c.Labels). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "assign_to_agent": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AssignToAgent == nil { - return nil - } - c := cfg.AssignToAgent - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("name", c.DefaultAgent). - AddIfNotEmpty("model", c.DefaultModel). - AddIfNotEmpty("custom-agent", c.DefaultCustomAgent). - AddIfNotEmpty("custom-instructions", c.DefaultCustomInstructions). - AddStringSlice("allowed", c.Allowed). - AddIfTrue("ignore-if-error", c.IgnoreIfError). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed-repos", c.AllowedRepos). - AddIfNotEmpty("pull-request-repo", c.PullRequestRepoSlug). - AddStringSlice("allowed-pull-request-repos", c.AllowedPullRequestRepos). - AddIfNotEmpty("base-branch", c.BaseBranch). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "upload_asset": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UploadAssets == nil { - return nil - } - c := cfg.UploadAssets - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("branch", c.BranchName). - AddIfPositive("max-size", c.MaxSizeKB). - AddStringSlice("allowed-exts", c.AllowedExts). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "upload_artifact": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UploadArtifact == nil { - return nil - } - c := cfg.UploadArtifact - b := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfPositive("max-uploads", c.MaxUploads). - AddTemplatableInt("retention-days", c.RetentionDays). - AddTemplatableBool("skip-archive", c.SkipArchive). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged) - if c.MaxSizeBytes > 0 { - b = b.AddDefault("max-size-bytes", c.MaxSizeBytes) - } - if len(c.AllowedPaths) > 0 { - b = b.AddStringSlice("allowed-paths", c.AllowedPaths) - } - if c.Defaults != nil { - if c.Defaults.IfNoFiles != "" { - b = b.AddIfNotEmpty("default-if-no-files", c.Defaults.IfNoFiles) - } - } - if c.Filters != nil { - if len(c.Filters.Include) > 0 { - b = b.AddStringSlice("filters-include", c.Filters.Include) - } - if len(c.Filters.Exclude) > 0 { - b = b.AddStringSlice("filters-exclude", c.Filters.Exclude) - } - } - return b.Build() - }, - "autofix_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AutofixCodeScanningAlert == nil { - return nil - } - c := cfg.AutofixCodeScanningAlert - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - // Note: create_project, update_project and create_project_status_update are handled by the unified handler, - // not the separate project handler manager, so they are included in this registry. - "create_project": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateProjects == nil { - return nil - } - c := cfg.CreateProjects - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target_owner", c.TargetOwner). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddIfNotEmpty("github-token", c.GitHubToken) - if len(c.Views) > 0 { - builder.AddDefault("views", c.Views) - } - if len(c.FieldDefinitions) > 0 { - builder.AddDefault("field_definitions", c.FieldDefinitions) - } - builder.AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "update_project": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdateProjects == nil { - return nil - } - c := cfg.UpdateProjects - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfNotEmpty("project", c.Project). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos) - if len(c.Views) > 0 { - builder.AddDefault("views", c.Views) - } - if len(c.FieldDefinitions) > 0 { - builder.AddDefault("field_definitions", c.FieldDefinitions) - } - builder.AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "assign_to_user": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AssignToUser == nil { - return nil - } - c := cfg.AssignToUser - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddStringSlice("blocked", c.Blocked). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("unassign_first", c.UnassignFirst). - AddIfTrue("staged", c.Staged). - Build() - }, - "unassign_from_user": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UnassignFromUser == nil { - return nil - } - c := cfg.UnassignFromUser - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddStringSlice("blocked", c.Blocked). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_project_status_update": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateProjectStatusUpdates == nil { - return nil - } - c := cfg.CreateProjectStatusUpdates - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfNotEmpty("project", c.Project). - AddIfTrue("staged", c.Staged). - Build() - }, - "set_issue_type": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.SetIssueType == nil { - return nil - } - c := cfg.SetIssueType - config := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - // If config is empty, it means set_issue_type was explicitly configured with no options - // (null config), which means "allow any type". Return non-nil empty map to - // indicate the handler is enabled. - if len(config) == 0 { - return make(map[string]any) - } - return config - }, -} +// handlerRegistry maps handler names to their builder functions. +// Handler groups are registered in separate init() functions in: +// - compiler_safe_outputs_handlers_issue.go — issue, comment, discussion, label handlers +// - compiler_safe_outputs_handlers_pr.go — pull request handlers +// - compiler_safe_outputs_handlers_dispatch.go — dispatch and workflow handlers +// - compiler_safe_outputs_handlers_misc.go — utility and project handlers +var handlerRegistry = make(map[string]handlerBuilder) func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *WorkflowData) { if data.SafeOutputs == nil { diff --git a/pkg/workflow/compiler_safe_outputs_handlers_dispatch.go b/pkg/workflow/compiler_safe_outputs_handlers_dispatch.go new file mode 100644 index 00000000000..43dea0c2298 --- /dev/null +++ b/pkg/workflow/compiler_safe_outputs_handlers_dispatch.go @@ -0,0 +1,76 @@ +// This file provides workflow compilation functionality for gh-aw. +// This file (compiler_safe_outputs_handlers_dispatch.go) registers dispatch and workflow safe output handlers +// into the global handlerRegistry during package initialization. + +package workflow + +import "maps" + +func init() { + maps.Copy(handlerRegistry, map[string]handlerBuilder{ + "dispatch_workflow": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.DispatchWorkflow == nil { + return nil + } + c := cfg.DispatchWorkflow + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("workflows", c.Workflows). + AddIfNotEmpty("target-repo", c.TargetRepoSlug) + + // Add workflow_files map if it has entries + if len(c.WorkflowFiles) > 0 { + builder.AddDefault("workflow_files", c.WorkflowFiles) + } + + // Add aw_context_workflows list if it has entries + if len(c.AwContextWorkflows) > 0 { + builder.AddStringSlice("aw_context_workflows", c.AwContextWorkflows) + } + + builder.AddIfNotEmpty("target-ref", c.TargetRef) + builder.AddIfNotEmpty("github-token", c.GitHubToken) + builder.AddIfTrue("staged", c.Staged) + return builder.Build() + }, + + "dispatch_repository": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.DispatchRepository == nil || len(cfg.DispatchRepository.Tools) == 0 { + return nil + } + // Serialize each tool as a sub-map + tools := make(map[string]any, len(cfg.DispatchRepository.Tools)) + for toolKey, tool := range cfg.DispatchRepository.Tools { + toolConfig := newHandlerConfigBuilder(). + AddIfNotEmpty("workflow", tool.Workflow). + AddIfNotEmpty("event_type", tool.EventType). + AddIfNotEmpty("repository", tool.Repository). + AddStringSlice("allowed_repositories", tool.AllowedRepositories). + AddTemplatableInt("max", tool.Max). + AddIfNotEmpty("github-token", tool.GitHubToken). + AddIfTrue("staged", tool.Staged). + Build() + tools[toolKey] = toolConfig + } + return map[string]any{"tools": tools} + }, + + "call_workflow": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CallWorkflow == nil { + return nil + } + c := cfg.CallWorkflow + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("workflows", c.Workflows) + + // Add workflow_files map if it has entries + if len(c.WorkflowFiles) > 0 { + builder.AddDefault("workflow_files", c.WorkflowFiles) + } + + builder.AddIfTrue("staged", c.Staged) + return builder.Build() + }, + }) +} diff --git a/pkg/workflow/compiler_safe_outputs_handlers_issue.go b/pkg/workflow/compiler_safe_outputs_handlers_issue.go new file mode 100644 index 00000000000..eb67a9881c8 --- /dev/null +++ b/pkg/workflow/compiler_safe_outputs_handlers_issue.go @@ -0,0 +1,375 @@ +// This file provides workflow compilation functionality for gh-aw. +// This file (compiler_safe_outputs_handlers_issue.go) registers issue, comment, discussion, and label safe output handlers +// into the global handlerRegistry during package initialization. + +package workflow + +import "maps" + +func init() { + maps.Copy(handlerRegistry, map[string]handlerBuilder{ + "create_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateIssues == nil { + return nil + } + c := cfg.CreateIssues + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed_labels", c.AllowedLabels). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfPositive("expires", c.Expires). + AddStringSlice("labels", c.Labels). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddStringSlice("assignees", c.Assignees). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddTemplatableBool("group", c.Group). + AddTemplatableBool("close_older_issues", c.CloseOlderIssues). + AddIfNotEmpty("close_older_key", c.CloseOlderKey). + AddTemplatableBool("group_by_day", c.GroupByDay). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "add_comment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AddComments == nil { + return nil + } + c := cfg.AddComments + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddTemplatableBool("hide_older_comments", c.HideOlderComments). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + + "close_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CloseIssues == nil { + return nil + } + c := cfg.CloseIssues + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("state_reason", c.StateReason). + AddIfTrue("staged", c.Staged). + Build() + }, + + "update_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateIssues == nil { + return nil + } + c := cfg.UpdateIssues + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("title_prefix", c.TitlePrefix) + // Boolean pointer fields indicate which fields can be updated + if c.Status != nil { + builder.AddDefault("allow_status", true) + } + if c.Title != nil { + builder.AddDefault("allow_title", true) + } + // Body uses boolean value mode - add the actual boolean value + builder.AddBoolPtrOrDefault("allow_body", c.Body, true) + return builder. + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + + "create_discussion": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateDiscussions == nil { + return nil + } + c := cfg.CreateDiscussions + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("category", c.Category). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddStringSlice("labels", c.Labels). + AddStringSlice("allowed_labels", c.AllowedLabels). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddTemplatableBool("close_older_discussions", c.CloseOlderDiscussions). + AddIfNotEmpty("close_older_key", c.CloseOlderKey). + AddIfNotEmpty("required_category", c.RequiredCategory). + AddIfPositive("expires", c.Expires). + AddBoolPtr("fallback_to_issue", c.FallbackToIssue). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "close_discussion": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CloseDiscussions == nil { + return nil + } + c := cfg.CloseDiscussions + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfTrue("staged", c.Staged). + Build() + }, + + "update_discussion": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateDiscussions == nil { + return nil + } + c := cfg.UpdateDiscussions + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target) + // Boolean pointer fields indicate which fields can be updated + if c.Title != nil { + builder.AddDefault("allow_title", true) + } + if c.Body != nil { + builder.AddDefault("allow_body", true) + } + if c.Labels != nil { + builder.AddDefault("allow_labels", true) + } + return builder. + AddStringSlice("allowed_labels", c.AllowedLabels). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + + "add_labels": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AddLabels == nil { + return nil + } + c := cfg.AddLabels + config := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + // If config is empty, it means add_labels was explicitly configured with no options + // (null config), which means "allow any labels". Return non-nil empty map to + // indicate the handler is enabled. + if len(config) == 0 { + // Return empty map so handler is included in config + return make(map[string]any) + } + return config + }, + + "remove_labels": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.RemoveLabels == nil { + return nil + } + c := cfg.RemoveLabels + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "add_reviewer": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AddReviewer == nil { + return nil + } + c := cfg.AddReviewer + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Reviewers). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "assign_milestone": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AssignMilestone == nil { + return nil + } + c := cfg.AssignMilestone + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + AddIfTrue("auto_create", c.AutoCreate). + Build() + }, + + "mark_pull_request_as_ready_for_review": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.MarkPullRequestAsReadyForReview == nil { + return nil + } + c := cfg.MarkPullRequestAsReadyForReview + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.LinkSubIssue == nil { + return nil + } + c := cfg.LinkSubIssue + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("parent_required_labels", c.ParentRequiredLabels). + AddIfNotEmpty("parent_title_prefix", c.ParentTitlePrefix). + AddStringSlice("sub_required_labels", c.SubRequiredLabels). + AddIfNotEmpty("sub_title_prefix", c.SubTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "update_release": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateRelease == nil { + return nil + } + c := cfg.UpdateRelease + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + + "assign_to_user": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AssignToUser == nil { + return nil + } + c := cfg.AssignToUser + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("unassign_first", c.UnassignFirst). + AddIfTrue("staged", c.Staged). + Build() + }, + + "unassign_from_user": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UnassignFromUser == nil { + return nil + } + c := cfg.UnassignFromUser + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "set_issue_type": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.SetIssueType == nil { + return nil + } + c := cfg.SetIssueType + config := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + // If config is empty, it means set_issue_type was explicitly configured with no options + // (null config), which means "allow any type". Return non-nil empty map to + // indicate the handler is enabled. + if len(config) == 0 { + return make(map[string]any) + } + return config + }, + + "create_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateCodeScanningAlerts == nil { + return nil + } + c := cfg.CreateCodeScanningAlerts + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("driver", c.Driver). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "create_agent_session": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateAgentSessions == nil { + return nil + } + c := cfg.CreateAgentSessions + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("base", c.Base). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + }) +} diff --git a/pkg/workflow/compiler_safe_outputs_handlers_misc.go b/pkg/workflow/compiler_safe_outputs_handlers_misc.go new file mode 100644 index 00000000000..313a43866fa --- /dev/null +++ b/pkg/workflow/compiler_safe_outputs_handlers_misc.go @@ -0,0 +1,203 @@ +// This file provides workflow compilation functionality for gh-aw. +// This file (compiler_safe_outputs_handlers_misc.go) registers utility and project safe output handlers +// into the global handlerRegistry during package initialization. + +package workflow + +import "maps" + +func init() { + maps.Copy(handlerRegistry, map[string]handlerBuilder{ + "noop": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.NoOp == nil { + return nil + } + c := cfg.NoOp + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringPtr("report-as-issue", c.ReportAsIssue). + AddIfTrue("staged", c.Staged). + Build() + }, + + "missing_tool": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.MissingTool == nil { + return nil + } + c := cfg.MissingTool + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "missing_data": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.MissingData == nil { + return nil + } + c := cfg.MissingData + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "report_incomplete": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ReportIncomplete == nil { + return nil + } + c := cfg.ReportIncomplete + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "create_report_incomplete_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ReportIncomplete == nil { + return nil + } + c := cfg.ReportIncomplete + if !c.CreateIssue { + return nil + } + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("title-prefix", c.TitlePrefix). + AddStringSlice("labels", c.Labels). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "assign_to_agent": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AssignToAgent == nil { + return nil + } + c := cfg.AssignToAgent + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("name", c.DefaultAgent). + AddIfNotEmpty("model", c.DefaultModel). + AddIfNotEmpty("custom-agent", c.DefaultCustomAgent). + AddIfNotEmpty("custom-instructions", c.DefaultCustomInstructions). + AddStringSlice("allowed", c.Allowed). + AddIfTrue("ignore-if-error", c.IgnoreIfError). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed-repos", c.AllowedRepos). + AddIfNotEmpty("pull-request-repo", c.PullRequestRepoSlug). + AddStringSlice("allowed-pull-request-repos", c.AllowedPullRequestRepos). + AddIfNotEmpty("base-branch", c.BaseBranch). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "upload_asset": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UploadAssets == nil { + return nil + } + c := cfg.UploadAssets + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("branch", c.BranchName). + AddIfPositive("max-size", c.MaxSizeKB). + AddStringSlice("allowed-exts", c.AllowedExts). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "upload_artifact": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UploadArtifact == nil { + return nil + } + c := cfg.UploadArtifact + b := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfPositive("max-uploads", c.MaxUploads). + AddTemplatableInt("retention-days", c.RetentionDays). + AddTemplatableBool("skip-archive", c.SkipArchive). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged) + if c.MaxSizeBytes > 0 { + b = b.AddDefault("max-size-bytes", c.MaxSizeBytes) + } + if len(c.AllowedPaths) > 0 { + b = b.AddStringSlice("allowed-paths", c.AllowedPaths) + } + if c.Defaults != nil { + if c.Defaults.IfNoFiles != "" { + b = b.AddIfNotEmpty("default-if-no-files", c.Defaults.IfNoFiles) + } + } + if c.Filters != nil { + if len(c.Filters.Include) > 0 { + b = b.AddStringSlice("filters-include", c.Filters.Include) + } + if len(c.Filters.Exclude) > 0 { + b = b.AddStringSlice("filters-exclude", c.Filters.Exclude) + } + } + return b.Build() + }, + + "create_project": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateProjects == nil { + return nil + } + c := cfg.CreateProjects + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target_owner", c.TargetOwner). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddIfNotEmpty("github-token", c.GitHubToken) + if len(c.Views) > 0 { + builder.AddDefault("views", c.Views) + } + if len(c.FieldDefinitions) > 0 { + builder.AddDefault("field_definitions", c.FieldDefinitions) + } + builder.AddIfTrue("staged", c.Staged) + return builder.Build() + }, + + "update_project": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateProjects == nil { + return nil + } + c := cfg.UpdateProjects + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfNotEmpty("project", c.Project). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos) + if len(c.Views) > 0 { + builder.AddDefault("views", c.Views) + } + if len(c.FieldDefinitions) > 0 { + builder.AddDefault("field_definitions", c.FieldDefinitions) + } + builder.AddIfTrue("staged", c.Staged) + return builder.Build() + }, + + "create_project_status_update": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateProjectStatusUpdates == nil { + return nil + } + c := cfg.CreateProjectStatusUpdates + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfNotEmpty("project", c.Project). + AddIfTrue("staged", c.Staged). + Build() + }, + }) +} diff --git a/pkg/workflow/compiler_safe_outputs_handlers_pr.go b/pkg/workflow/compiler_safe_outputs_handlers_pr.go new file mode 100644 index 00000000000..06e9ff381fc --- /dev/null +++ b/pkg/workflow/compiler_safe_outputs_handlers_pr.go @@ -0,0 +1,207 @@ +// This file provides workflow compilation functionality for gh-aw. +// This file (compiler_safe_outputs_handlers_pr.go) registers pull request safe output handlers +// into the global handlerRegistry during package initialization. + +package workflow + +import "maps" + +func init() { + maps.Copy(handlerRegistry, map[string]handlerBuilder{ + "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreatePullRequests == nil { + return nil + } + c := cfg.CreatePullRequests + maxPatchSize := 1024 // default 1024 KB + if cfg.MaximumPatchSize > 0 { + maxPatchSize = cfg.MaximumPatchSize + } + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddStringSlice("labels", c.Labels). + AddStringSlice("reviewers", c.Reviewers). + AddStringSlice("assignees", c.Assignees). + AddTemplatableBool("draft", c.Draft). + AddIfNotEmpty("if_no_changes", c.IfNoChanges). + AddTemplatableBool("allow_empty", c.AllowEmpty). + AddTemplatableBool("auto_merge", c.AutoMerge). + AddIfPositive("expires", c.Expires). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddDefault("max_patch_size", maxPatchSize). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddBoolPtr("fallback_as_issue", c.FallbackAsIssue). + AddTemplatableBool("auto_close_issue", c.AutoCloseIssue). + AddIfNotEmpty("base_branch", c.BaseBranch). + AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). + AddStringSlice("protected_files", getAllManifestFiles()). + AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). + AddStringSlice("allowed_files", c.AllowedFiles). + AddStringSlice("excluded_files", c.ExcludedFiles). + AddIfTrue("preserve_branch_name", c.PreserveBranchName). + AddIfNotEmpty("patch_format", c.PatchFormat). + AddIfTrue("staged", c.Staged) + return builder.Build() + }, + + "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.PushToPullRequestBranch == nil { + return nil + } + c := cfg.PushToPullRequestBranch + maxPatchSize := 1024 // default 1024 KB + if cfg.MaximumPatchSize > 0 { + maxPatchSize = cfg.MaximumPatchSize + } + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddStringSlice("labels", c.Labels). + AddIfNotEmpty("if_no_changes", c.IfNoChanges). + AddIfNotEmpty("commit_title_suffix", c.CommitTitleSuffix). + AddDefault("max_patch_size", maxPatchSize). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). + AddStringSlice("protected_files", getAllManifestFiles()). + AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). + AddStringSlice("allowed_files", c.AllowedFiles). + AddStringSlice("excluded_files", c.ExcludedFiles). + AddIfNotEmpty("patch_format", c.PatchFormat). + Build() + }, + + "update_pull_request": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdatePullRequests == nil { + return nil + } + c := cfg.UpdatePullRequests + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddBoolPtrOrDefault("allow_title", c.Title, true). + AddBoolPtrOrDefault("allow_body", c.Body, true). + AddStringPtr("default_operation", c.Operation). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "close_pull_request": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ClosePullRequests == nil { + return nil + } + c := cfg.ClosePullRequests + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "hide_comment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.HideComment == nil { + return nil + } + c := cfg.HideComment + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed_reasons", c.AllowedReasons). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreatePullRequestReviewComments == nil { + return nil + } + c := cfg.CreatePullRequestReviewComments + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("side", c.Side). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "submit_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.SubmitPullRequestReview == nil { + return nil + } + c := cfg.SubmitPullRequestReview + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddStringSlice("allowed_events", c.AllowedEvents). + AddIfNotEmpty("github-token", c.GitHubToken). + AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + + "reply_to_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ReplyToPullRequestReviewComment == nil { + return nil + } + c := cfg.ReplyToPullRequestReviewComment + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + + "resolve_pull_request_review_thread": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ResolvePullRequestReviewThread == nil { + return nil + } + c := cfg.ResolvePullRequestReviewThread + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + + "autofix_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AutofixCodeScanningAlert == nil { + return nil + } + c := cfg.AutofixCodeScanningAlert + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + }) +}