Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 0 additions & 324 deletions pkg/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,19 @@ package cli

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/errorutil"
"github.com/github/gh-aw/pkg/fileutil"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/parser"
"github.com/github/gh-aw/pkg/workflow"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -937,321 +931,3 @@ func renderAuditCompletion(runOutputDir string, jsonOutput bool) {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Audit complete. Logs saved to "+absOutputDir))
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Tip: use --artifacts to select specific artifact sets (agent, firewall, mcp, activation, detection, etc.)"))
}

// auditJobRunOptions holds parameters for auditJobRun.
type auditJobRunOptions struct {
runID int64
jobID int64
stepNumber int
owner string
repo string
hostname string
outputDir string
verbose bool
jsonOutput bool
}

// auditJobRun performs a targeted audit of a specific job within a workflow run
// If stepNumber > 0, focuses on extracting output for that specific step
func auditJobRun(opts auditJobRunOptions) error {
opts.hostname = resolveAuditHostname(opts.hostname)
auditLog.Printf("Starting job-specific audit: runID=%d, jobID=%d, stepNumber=%d, hostname=%s", opts.runID, opts.jobID, opts.stepNumber, opts.hostname)
if err := os.MkdirAll(opts.outputDir, constants.DirPermSensitive); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
jobLogContent, jobLogPath, err := fetchAuditJobLog(opts)
if err != nil {
return err
}
if err := extractAuditJobDetails(opts, jobLogContent); err != nil {
return err
}
renderAuditJobSummary(opts, jobLogPath)
return nil
}

func fetchAuditJobLog(opts auditJobRunOptions) (string, string, error) {
args := []string{"run", "view"}
if opts.owner != "" && opts.repo != "" {
args = append(args, "-R", fmt.Sprintf("%s/%s", opts.owner, opts.repo))
}
args = append(args, "--job", strconv.FormatInt(opts.jobID, 10), "--log")
if opts.verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Fetching logs for job %d...", opts.jobID)))
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Executing: gh "+strings.Join(args, " ")))
}
cmd := workflow.ExecGH(args...)
workflow.SetGHHostEnv(cmd, opts.hostname)
output, err := cmd.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("failed to fetch job logs: %w\nOutput: %s", err, string(output))
}
jobLogPath := filepath.Join(opts.outputDir, fmt.Sprintf("job-%d.log", opts.jobID))
if err := os.WriteFile(jobLogPath, output, constants.FilePermSensitive); err != nil {
return "", "", fmt.Errorf("failed to write job log: %w", err)
}
if opts.verbose {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Job log saved to "+jobLogPath))
}
return string(output), jobLogPath, nil
}

func extractAuditJobDetails(opts auditJobRunOptions, jobLogContent string) error {
if opts.stepNumber > 0 {
return extractRequestedStepOutput(opts, jobLogContent)
}
return extractFirstFailingStepOutput(opts, jobLogContent)
}

func extractRequestedStepOutput(opts auditJobRunOptions, jobLogContent string) error {
stepOutput, err := extractStepOutput(jobLogContent, opts.stepNumber)
if err != nil {
if opts.verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not extract step %d output: %v", opts.stepNumber, err)))
}
return nil
}
stepLogPath := filepath.Join(opts.outputDir, fmt.Sprintf("job-%d-step-%d.log", opts.jobID, opts.stepNumber))
if err := os.WriteFile(stepLogPath, []byte(stepOutput), constants.FilePermSensitive); err != nil {
return fmt.Errorf("failed to write step log: %w", err)
}
if opts.verbose {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Step %d output saved to %s", opts.stepNumber, stepLogPath)))
}
return nil
}

func extractFirstFailingStepOutput(opts auditJobRunOptions, jobLogContent string) error {
failingStepNum, failingStepOutput := findFirstFailingStep(jobLogContent)
if failingStepNum == 0 {
if opts.verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No failing steps found in job"))
}
return nil
}
stepLogPath := filepath.Join(opts.outputDir, fmt.Sprintf("job-%d-step-%d-failed.log", opts.jobID, failingStepNum))
if err := os.WriteFile(stepLogPath, []byte(failingStepOutput), constants.FilePermSensitive); err != nil {
return fmt.Errorf("failed to write failing step log: %w", err)
}
if opts.verbose {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("First failing step %d output saved to %s", failingStepNum, stepLogPath)))
}
return nil
}

func renderAuditJobSummary(opts auditJobRunOptions, jobLogPath string) {
if opts.jsonOutput {
return
}
absOutputDir, _ := filepath.Abs(opts.outputDir)
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Job audit complete. Logs saved to "+absOutputDir))
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("\nDownloaded files:"))
fmt.Fprintf(os.Stderr, " - %s (full job log)\n", jobLogPath)
if opts.stepNumber > 0 {
renderRequestedStepSummary(opts)
return
}
renderFailingStepSummary(opts)
}

func renderRequestedStepSummary(opts auditJobRunOptions) {
stepLogPath := filepath.Join(opts.outputDir, fmt.Sprintf("job-%d-step-%d.log", opts.jobID, opts.stepNumber))
if fileutil.FileExists(stepLogPath) {
fmt.Fprintf(os.Stderr, " - %s (step %d output)\n", stepLogPath, opts.stepNumber)
}
}

func renderFailingStepSummary(opts auditJobRunOptions) {
failingStepPath := filepath.Join(opts.outputDir, fmt.Sprintf("job-%d-step-*-failed.log", opts.jobID))
matches, _ := filepath.Glob(failingStepPath)
for _, match := range matches {
fmt.Fprintf(os.Stderr, " - %s (first failing step)\n", match)
}
}

// extractStepOutput extracts the output of a specific step from job logs
func extractStepOutput(jobLog string, stepNumber int) (string, error) {
auditLog.Printf("Extracting output for step %d from job logs (%d bytes)", stepNumber, len(jobLog))
lines := strings.Split(jobLog, "\n")
var stepOutput []string
inStep := false
stepPattern := "##[group]Run " // GitHub Actions step marker
stepEndPattern := "##[endgroup]"
currentStep := 0

for _, line := range lines {
// Detect step boundaries
if strings.Contains(line, stepPattern) || strings.HasPrefix(line, fmt.Sprintf("##[group]Step %d:", stepNumber)) {
currentStep++
if currentStep == stepNumber {
inStep = true
}
} else if strings.Contains(line, stepEndPattern) {
if inStep {
break // End of target step
}
}

if inStep {
stepOutput = append(stepOutput, line)
}
}

if len(stepOutput) == 0 {
auditLog.Printf("Step %d not found in job logs (scanned %d lines)", stepNumber, len(lines))
return "", fmt.Errorf("step %d not found in job logs", stepNumber)
}

auditLog.Printf("Extracted %d lines for step %d", len(stepOutput), stepNumber)
return strings.Join(stepOutput, "\n"), nil
}

// findFirstFailingStep finds the first step that failed in the job logs
func findFirstFailingStep(jobLog string) (int, string) {
auditLog.Printf("Searching for first failing step in job logs (%d bytes)", len(jobLog))
lines := strings.Split(jobLog, "\n")
var stepOutput []string
inStep := false
currentStep := 0
foundFailure := false

for _, line := range lines {
// Detect step start
if strings.Contains(line, "##[group]") {
if inStep && foundFailure {
break // We found a complete failing step
}
inStep = true
currentStep++
stepOutput = []string{line}
foundFailure = false
} else if inStep {
stepOutput = append(stepOutput, line)

// Detect failure indicators
if strings.Contains(line, "##[error]") ||
strings.Contains(line, "Error:") ||
strings.Contains(line, "FAILED") ||
strings.Contains(line, "exit code") && !strings.Contains(line, "exit code 0") {
foundFailure = true
}
}
}

if foundFailure && len(stepOutput) > 0 {
auditLog.Printf("Found failing step %d with %d lines of output", currentStep, len(stepOutput))
return currentStep, strings.Join(stepOutput, "\n")
}

auditLog.Print("No failing step found in job logs")
return 0, ""
}

// fetchWorkflowRunMetadata fetches metadata for a single workflow run
func fetchWorkflowRunMetadata(ctx context.Context, runID int64, owner, repo, hostname string, verbose bool) (WorkflowRun, error) {
args := buildWorkflowRunMetadataArgs(runID, owner, repo, hostname)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Executing: gh "+strings.Join(args, " ")))
}
output, err := workflow.RunGHCombinedContext(ctx, "Fetching run metadata...", args...)
if err != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(string(output)))
}
return WorkflowRun{}, classifyWorkflowRunMetadataError(runID, err, output)
}
var run WorkflowRun
if err := json.Unmarshal(output, &run); err != nil {
return WorkflowRun{}, fmt.Errorf("failed to parse run metadata: %w", err)
}
resolveWorkflowRunDisplayName(ctx, &run, owner, repo, hostname)
return run, nil
}

func buildWorkflowRunMetadataArgs(runID int64, owner, repo, hostname string) []string {
endpoint := fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d", runID)
if owner != "" && repo != "" {
endpoint = fmt.Sprintf("repos/%s/%s/actions/runs/%d", owner, repo, runID)
}
args := []string{"api"}
if hostname != "" && hostname != "github.com" {
args = append(args, "--hostname", hostname)
}
return append(args, endpoint, "--jq", "{databaseId: .id, number: .run_number, url: .html_url, status: .status, conclusion: .conclusion, workflowName: .name, workflowPath: .path, createdAt: .created_at, startedAt: .run_started_at, updatedAt: .updated_at, event: .event, headBranch: .head_branch, headSha: .head_sha, displayTitle: .display_title}")
}

func classifyWorkflowRunMetadataError(runID int64, err error, output []byte) error {
outputStr := string(output)
if errorutil.IsNotFoundError(err) ||
errorutil.IsNotFoundError(errors.New(outputStr)) ||
strings.Contains(outputStr, "Could not resolve") {
return fmt.Errorf("workflow run %d not found. Please verify the run ID is correct and that you have access to the repository", runID)
}
return fmt.Errorf("failed to fetch run metadata: %w", err)
}

func resolveWorkflowRunDisplayName(ctx context.Context, run *WorkflowRun, owner, repo, hostname string) {
if !strings.HasPrefix(run.WorkflowName, constants.GithubDir) {
return
}
if displayName := resolveWorkflowDisplayName(ctx, run.WorkflowPath, owner, repo, hostname); displayName != "" {
auditLog.Printf("Resolved workflow display name: %q -> %q", run.WorkflowName, displayName)
run.WorkflowName = displayName
}
}

// resolveWorkflowDisplayName returns the human-readable display name for a workflow file.
// It first attempts to read the YAML file from the local filesystem (resolving the path
// relative to the git repository root so that it works from any working directory inside
// the repo); if that fails it falls back to a GitHub API call. An empty string is
// returned on any error so that callers can gracefully keep the original value.
func resolveWorkflowDisplayName(ctx context.Context, workflowPath, owner, repo, hostname string) string {
// Try local file first. workflowPath is a repo-relative path like
// ".github/workflows/foo.lock.yml", so we resolve it against the git root to
// produce a correct absolute path regardless of the current working directory.
if gitRoot, err := gitutil.FindGitRoot(); err == nil {
absPath := filepath.Join(gitRoot, workflowPath)
if content, err := os.ReadFile(absPath); err == nil {
if name := extractWorkflowNameFromYAML(content); name != "" {
return name
}
}
}

// Fall back to the GitHub Actions workflows API.
filename := filepath.Base(workflowPath)
var endpoint string
if owner != "" && repo != "" {
endpoint = fmt.Sprintf("repos/%s/%s/actions/workflows/%s", owner, repo, filename)
} else {
endpoint = "repos/{owner}/{repo}/actions/workflows/" + filename
}

args := []string{"api"}
if hostname != "" && hostname != "github.com" {
args = append(args, "--hostname", hostname)
}
args = append(args, endpoint, "--jq", ".name")

out, err := workflow.RunGHCombinedContext(ctx, "Fetching workflow name...", args...)
if err != nil {
auditLog.Printf("Failed to fetch workflow display name for %q: %v", workflowPath, err)
return ""
}

return strings.TrimSpace(string(out))
}

// extractWorkflowNameFromYAML parses a GitHub Actions workflow YAML document and
// returns the value of its top-level "name:" field. An empty string is returned
// when the field is absent or the document cannot be parsed.
func extractWorkflowNameFromYAML(content []byte) string {
var wf struct {
Name string `yaml:"name"`
}
if err := yaml.Unmarshal(content, &wf); err != nil {
auditLog.Printf("Failed to parse workflow YAML for name extraction (file may be malformed): %v", err)
return ""
}
return wf.Name
}
Loading
Loading