From 920bc587551685bab1f6441de31cdbd3259d2d4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 00:34:20 +0000 Subject: [PATCH 1/4] Initial plan From a7041c320615315573cca3c93115fe85141c363a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 00:48:10 +0000 Subject: [PATCH 2/4] Add URL parsing support to audit command for owner/repo/hostname extraction Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/audit.go | 94 +++++++++++++++++++++++-------- pkg/cli/audit_test.go | 128 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 24 deletions(-) diff --git a/pkg/cli/audit.go b/pkg/cli/audit.go index c158db1bd8..cb7e09d123 100644 --- a/pkg/cli/audit.go +++ b/pkg/cli/audit.go @@ -30,6 +30,8 @@ This command accepts: - A numeric run ID (e.g., 1234567890) - A GitHub Actions run URL (e.g., https://github.com/owner/repo/actions/runs/1234567890) - A GitHub Actions job URL (e.g., https://github.com/owner/repo/actions/runs/1234567890/job/9876543210) +- A GitHub workflow run URL (e.g., https://github.com/owner/repo/runs/1234567890) +- GitHub Enterprise URLs (e.g., https://github.example.com/owner/repo/actions/runs/1234567890) This command: - Downloads artifacts and logs for the specified run ID @@ -42,14 +44,16 @@ Examples: ` + constants.CLIExtensionPrefix + ` audit 1234567890 # Audit run with ID 1234567890 ` + constants.CLIExtensionPrefix + ` audit https://github.com/owner/repo/actions/runs/1234567890 # Audit from run URL ` + constants.CLIExtensionPrefix + ` audit https://github.com/owner/repo/actions/runs/1234567890/job/9876543210 # Audit from job URL + ` + constants.CLIExtensionPrefix + ` audit https://github.com/owner/repo/runs/1234567890 # Audit from workflow run URL + ` + constants.CLIExtensionPrefix + ` audit https://github.example.com/owner/repo/actions/runs/1234567890 # Audit from GitHub Enterprise ` + constants.CLIExtensionPrefix + ` audit 1234567890 -o ./audit-reports # Custom output directory ` + constants.CLIExtensionPrefix + ` audit 1234567890 -v # Verbose output`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { runIDOrURL := args[0] - // Extract run ID from input (either numeric ID or URL) - runID, err := extractRunID(runIDOrURL) + // Parse run information from input (either numeric ID or URL) + runInfo, err := parseRunURL(runIDOrURL) if err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) @@ -59,7 +63,7 @@ Examples: verbose, _ := cmd.Flags().GetBool("verbose") jsonOutput, _ := cmd.Flags().GetBool("json") - if err := AuditWorkflowRun(runID, outputDir, verbose, jsonOutput); err != nil { + if err := AuditWorkflowRun(runInfo, outputDir, verbose, jsonOutput); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } @@ -73,30 +77,57 @@ Examples: return auditCmd } +// RunURLInfo contains the parsed information from a workflow run URL +type RunURLInfo struct { + RunID int64 + Owner string + Repo string + Hostname string +} + // extractRunID extracts the run ID from either a numeric string or a GitHub Actions URL func extractRunID(input string) (int64, error) { + info, err := parseRunURL(input) + if err != nil { + return 0, err + } + return info.RunID, nil +} + +// parseRunURL parses a run ID or URL and extracts all relevant information +func parseRunURL(input string) (RunURLInfo, error) { // First try to parse as a direct numeric ID if runID, err := strconv.ParseInt(input, 10, 64); err == nil { - return runID, nil + return RunURLInfo{RunID: runID}, nil } - // Try to extract run ID from GitHub Actions URL + // Try to extract information from GitHub Actions URL // Patterns: // - https://github.com/owner/repo/actions/runs/12345678 // - https://github.com/owner/repo/actions/runs/12345678/job/98765432 // - https://github.com/owner/repo/actions/runs/12345678/attempts/2 - runIDPattern := regexp.MustCompile(`/actions/runs/(\d+)`) - matches := runIDPattern.FindStringSubmatch(input) + // - https://github.com/owner/repo/runs/12345678 (action run URL) + // - https://github.example.com/owner/repo/actions/runs/12345678 (enterprise) - if len(matches) >= 2 { - runID, err := strconv.ParseInt(matches[1], 10, 64) + // Pattern to match GitHub URLs with run IDs + // Captures: hostname, owner, repo, runID + urlPattern := regexp.MustCompile(`^https?://([^/]+)/([^/]+)/([^/]+)/(?:actions/)?runs/(\d+)`) + matches := urlPattern.FindStringSubmatch(input) + + if len(matches) >= 5 { + runID, err := strconv.ParseInt(matches[4], 10, 64) if err != nil { - return 0, fmt.Errorf("invalid run ID in URL '%s': %w", input, err) + return RunURLInfo{}, fmt.Errorf("invalid run ID in URL '%s': %w", input, err) } - return runID, nil + return RunURLInfo{ + RunID: runID, + Hostname: matches[1], + Owner: matches[2], + Repo: matches[3], + }, nil } - return 0, fmt.Errorf("invalid run ID or URL '%s': must be a numeric run ID or a GitHub Actions URL containing '/actions/runs/{run-id}'", input) + return RunURLInfo{}, fmt.Errorf("invalid run ID or URL '%s': must be a numeric run ID or a GitHub URL containing '/actions/runs/{run-id}' or '/runs/{run-id}'", input) } // isPermissionError checks if an error is related to permissions/authentication @@ -113,18 +144,18 @@ func isPermissionError(err error) bool { } // AuditWorkflowRun audits a single workflow run and generates a report -func AuditWorkflowRun(runID int64, outputDir string, verbose bool, jsonOutput bool) error { +func AuditWorkflowRun(runInfo RunURLInfo, outputDir string, verbose bool, jsonOutput bool) error { if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Auditing workflow run %d...", runID))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Auditing workflow run %d...", runInfo.RunID))) } - runOutputDir := filepath.Join(outputDir, fmt.Sprintf("run-%d", runID)) + runOutputDir := filepath.Join(outputDir, fmt.Sprintf("run-%d", runInfo.RunID)) // Check if we have locally cached artifacts first hasLocalCache := dirExists(runOutputDir) && !isDirEmpty(runOutputDir) // Try to get run metadata from GitHub API - run, metadataErr := fetchWorkflowRunMetadata(runID, verbose) + run, metadataErr := fetchWorkflowRunMetadata(runInfo, verbose) var useLocalCache bool if metadataErr != nil { @@ -141,7 +172,7 @@ func AuditWorkflowRun(runID int64, outputDir string, verbose bool, jsonOutput bo " - run_id: %d\n"+ " - output_directory: %s\n\n"+ "2. After downloading, run this audit command again to analyze the cached artifacts.\n\n"+ - "Original error: %v", runID, runOutputDir, metadataErr) + "Original error: %v", runInfo.RunID, runOutputDir, metadataErr) } } else { return fmt.Errorf("failed to fetch run metadata: %w", metadataErr) @@ -154,7 +185,7 @@ func AuditWorkflowRun(runID int64, outputDir string, verbose bool, jsonOutput bo } // Download artifacts for the run - err := downloadRunArtifacts(runID, runOutputDir, verbose) + err := downloadRunArtifacts(runInfo.RunID, runOutputDir, verbose) if err != nil { // Gracefully handle cases where the run legitimately has no artifacts if errors.Is(err, ErrNoArtifacts) { @@ -172,7 +203,7 @@ func AuditWorkflowRun(runID int64, outputDir string, verbose bool, jsonOutput bo " - run_id: %d\n"+ " - output_directory: %s\n\n"+ "2. After downloading, run this audit command again to analyze the cached artifacts.\n\n"+ - "Original error: %v", runID, runOutputDir, err) + "Original error: %v", runInfo.RunID, runOutputDir, err) } } else { return fmt.Errorf("failed to download artifacts: %w", err) @@ -183,8 +214,8 @@ func AuditWorkflowRun(runID int64, outputDir string, verbose bool, jsonOutput bo // If using local cache without metadata, create a minimal run structure if useLocalCache && run.DatabaseID == 0 { run = WorkflowRun{ - DatabaseID: runID, - WorkflowName: fmt.Sprintf("Workflow Run %d", runID), + DatabaseID: runInfo.RunID, + WorkflowName: fmt.Sprintf("Workflow Run %d", runInfo.RunID), Status: "unknown", LogsPath: runOutputDir, } @@ -266,7 +297,7 @@ func AuditWorkflowRun(runID int64, outputDir string, verbose bool, jsonOutput bo if engine := extractEngineFromAwInfo(awInfoPath, verbose); engine != nil { // reuse existing helper in same package if err := parseAgentLog(runOutputDir, engine, verbose); err != nil { if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse agent log for run %d: %v", runID, err))) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse agent log for run %d: %v", runInfo.RunID, err))) } } else if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No agent logs found to parse or no parser available")) @@ -285,14 +316,29 @@ func AuditWorkflowRun(runID int64, outputDir string, verbose bool, jsonOutput bo } // fetchWorkflowRunMetadata fetches metadata for a single workflow run -func fetchWorkflowRunMetadata(runID int64, verbose bool) (WorkflowRun, error) { +func fetchWorkflowRunMetadata(runInfo RunURLInfo, verbose bool) (WorkflowRun, error) { + // Build the API endpoint + var endpoint string + if runInfo.Owner != "" && runInfo.Repo != "" { + // Use explicit owner/repo from the URL + endpoint = fmt.Sprintf("repos/%s/%s/actions/runs/%d", runInfo.Owner, runInfo.Repo, runInfo.RunID) + } else { + // Fall back to {owner}/{repo} placeholders for context-based resolution + endpoint = fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d", runInfo.RunID) + } + args := []string{ "api", - fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d", runID), + endpoint, "--jq", "{databaseId: .id, number: .run_number, url: .html_url, status: .status, conclusion: .conclusion, workflowName: .name, createdAt: .created_at, startedAt: .run_started_at, updatedAt: .updated_at, event: .event, headBranch: .head_branch, headSha: .head_sha, displayTitle: .display_title}", } + // Add hostname flag if specified (for GitHub Enterprise) + if runInfo.Hostname != "" && runInfo.Hostname != "github.com" { + args = append([]string{"api", "--hostname", runInfo.Hostname, endpoint, "--jq"}, args[3:]...) + } + if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Executing: gh %s", strings.Join(args, " ")))) } diff --git a/pkg/cli/audit_test.go b/pkg/cli/audit_test.go index d0fbbf72c1..efcc0fe443 100644 --- a/pkg/cli/audit_test.go +++ b/pkg/cli/audit_test.go @@ -50,6 +50,18 @@ func TestExtractRunID(t *testing.T) { expected: 12345678, shouldErr: false, }, + { + name: "Workflow run URL without /actions/", + input: "https://github.com/owner/repo/runs/12345678", + expected: 12345678, + shouldErr: false, + }, + { + name: "GitHub Enterprise URL", + input: "https://github.example.com/owner/repo/actions/runs/12345678", + expected: 12345678, + shouldErr: false, + }, { name: "Invalid format", input: "not-a-number", @@ -90,6 +102,122 @@ func TestExtractRunID(t *testing.T) { } } +func TestParseRunURL(t *testing.T) { + tests := []struct { + name string + input string + expectedInfo RunURLInfo + shouldErr bool + }{ + { + name: "Numeric run ID", + input: "1234567890", + expectedInfo: RunURLInfo{ + RunID: 1234567890, + Owner: "", + Repo: "", + Hostname: "", + }, + shouldErr: false, + }, + { + name: "Run URL with /actions/runs/", + input: "https://github.com/owner/repo/actions/runs/12345678", + expectedInfo: RunURLInfo{ + RunID: 12345678, + Owner: "owner", + Repo: "repo", + Hostname: "github.com", + }, + shouldErr: false, + }, + { + name: "Job URL", + input: "https://github.com/owner/repo/actions/runs/12345678/job/98765432", + expectedInfo: RunURLInfo{ + RunID: 12345678, + Owner: "owner", + Repo: "repo", + Hostname: "github.com", + }, + shouldErr: false, + }, + { + name: "Workflow run URL without /actions/", + input: "https://github.com/owner/repo/runs/12345678", + expectedInfo: RunURLInfo{ + RunID: 12345678, + Owner: "owner", + Repo: "repo", + Hostname: "github.com", + }, + shouldErr: false, + }, + { + name: "GitHub Enterprise URL", + input: "https://github.example.com/owner/repo/actions/runs/12345678", + expectedInfo: RunURLInfo{ + RunID: 12345678, + Owner: "owner", + Repo: "repo", + Hostname: "github.example.com", + }, + shouldErr: false, + }, + { + name: "GitHub Enterprise URL without /actions/", + input: "https://ghe.company.com/myorg/myrepo/runs/99999", + expectedInfo: RunURLInfo{ + RunID: 99999, + Owner: "myorg", + Repo: "myrepo", + Hostname: "ghe.company.com", + }, + shouldErr: false, + }, + { + name: "Invalid URL format", + input: "https://github.com/owner/repo/actions", + expectedInfo: RunURLInfo{}, + shouldErr: true, + }, + { + name: "Invalid string", + input: "not-a-number", + expectedInfo: RunURLInfo{}, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseRunURL(tt.input) + + if tt.shouldErr { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result.RunID != tt.expectedInfo.RunID { + t.Errorf("Expected run ID %d, got %d", tt.expectedInfo.RunID, result.RunID) + } + if result.Owner != tt.expectedInfo.Owner { + t.Errorf("Expected owner '%s', got '%s'", tt.expectedInfo.Owner, result.Owner) + } + if result.Repo != tt.expectedInfo.Repo { + t.Errorf("Expected repo '%s', got '%s'", tt.expectedInfo.Repo, result.Repo) + } + if result.Hostname != tt.expectedInfo.Hostname { + t.Errorf("Expected hostname '%s', got '%s'", tt.expectedInfo.Hostname, result.Hostname) + } + } + }) + } +} + func TestIsPermissionError(t *testing.T) { tests := []struct { name string From e6a95534bd7b557b0f1753beee15a1137d821469 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 00:51:17 +0000 Subject: [PATCH 3/4] Fix gh CLI argument construction for hostname flag Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/audit.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/cli/audit.go b/pkg/cli/audit.go index cb7e09d123..27efb0e5e8 100644 --- a/pkg/cli/audit.go +++ b/pkg/cli/audit.go @@ -327,18 +327,19 @@ func fetchWorkflowRunMetadata(runInfo RunURLInfo, verbose bool) (WorkflowRun, er endpoint = fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d", runInfo.RunID) } - args := []string{ - "api", - endpoint, - "--jq", - "{databaseId: .id, number: .run_number, url: .html_url, status: .status, conclusion: .conclusion, workflowName: .name, createdAt: .created_at, startedAt: .run_started_at, updatedAt: .updated_at, event: .event, headBranch: .head_branch, headSha: .head_sha, displayTitle: .display_title}", - } + args := []string{"api"} // Add hostname flag if specified (for GitHub Enterprise) if runInfo.Hostname != "" && runInfo.Hostname != "github.com" { - args = append([]string{"api", "--hostname", runInfo.Hostname, endpoint, "--jq"}, args[3:]...) + args = append(args, "--hostname", runInfo.Hostname) } + args = append(args, + endpoint, + "--jq", + "{databaseId: .id, number: .run_number, url: .html_url, status: .status, conclusion: .conclusion, workflowName: .name, createdAt: .created_at, startedAt: .run_started_at, updatedAt: .updated_at, event: .event, headBranch: .head_branch, headSha: .head_sha, displayTitle: .display_title}", + ) + if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Executing: gh %s", strings.Join(args, " ")))) } From c2bf143a7295c5865cf06f337e2b8f0c09c8e22e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 01:00:50 +0000 Subject: [PATCH 4/4] Fix undefined runID variable after merge and format code Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/audit.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/cli/audit.go b/pkg/cli/audit.go index ba78f0479a..7bb9194ad2 100644 --- a/pkg/cli/audit.go +++ b/pkg/cli/audit.go @@ -297,17 +297,17 @@ func AuditWorkflowRun(runInfo RunURLInfo, outputDir string, verbose bool, parse // This creates a log.md file in the run directory for a rich, human-readable agent session summary. // We intentionally do not fail the audit on parse errors; they are reported as warnings. if parse { - awInfoPath := filepath.Join(runOutputDir, "aw_info.json") - if engine := extractEngineFromAwInfo(awInfoPath, verbose); engine != nil { // reuse existing helper in same package - if err := parseAgentLog(runOutputDir, engine, verbose); err != nil { - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse agent log for run %d: %v", runInfo.RunID, err))) + awInfoPath := filepath.Join(runOutputDir, "aw_info.json") + if engine := extractEngineFromAwInfo(awInfoPath, verbose); engine != nil { // reuse existing helper in same package + if err := parseAgentLog(runOutputDir, engine, verbose); err != nil { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse agent log for run %d: %v", runInfo.RunID, err))) } } else { // Always show success message for parsing, not just in verbose mode logMdPath := filepath.Join(runOutputDir, "log.md") if _, err := os.Stat(logMdPath); err == nil { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ Parsed log for run %d → %s", runID, logMdPath))) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ Parsed log for run %d → %s", runInfo.RunID, logMdPath))) } } } else if verbose {