// // Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later. // using System.CommandLine; using System.Globalization; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; using StellaOps.Integrations.Plugin.GitHubApp.CodeScanning; namespace StellaOps.Cli.Commands; /// /// GitHub integration commands including Code Scanning. /// Sprint: SPRINT_20260109_010_002 /// public static class GitHubCommandGroup { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public static Command BuildGitHubCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var github = new Command("github", "GitHub integration commands."); github.Add(BuildUploadSarifCommand(services, verboseOption, cancellationToken)); github.Add(BuildListAlertsCommand(services, verboseOption, cancellationToken)); github.Add(BuildGetAlertCommand(services, verboseOption, cancellationToken)); github.Add(BuildUpdateAlertCommand(services, verboseOption, cancellationToken)); github.Add(BuildUploadStatusCommand(services, verboseOption, cancellationToken)); return github; } private static Command BuildUploadSarifCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var sarifFileArg = new Argument("sarif-file") { Description = "Path to SARIF file to upload." }; var repoOption = new Option("--repo", new[] { "-r" }) { Description = "Repository in owner/repo format", Required = true }; var refOption = new Option("--ref") { Description = "Git ref (e.g., refs/heads/main). Defaults to current branch." }; var shaOption = new Option("--sha") { Description = "Commit SHA. Defaults to current HEAD." }; var waitOption = new Option("--wait", new[] { "-w" }) { Description = "Wait for processing to complete" }; var timeoutOption = new Option("--timeout", new[] { "-t" }) { Description = "Wait timeout in seconds (default: 300)" }; timeoutOption.SetDefaultValue(300); var toolNameOption = new Option("--tool-name") { Description = "Tool name for GitHub categorization" }; var githubUrlOption = new Option("--github-url") { Description = "GitHub API URL (for GitHub Enterprise Server)" }; var cmd = new Command("upload-sarif", "Upload SARIF to GitHub Code Scanning.") { sarifFileArg, repoOption, refOption, shaOption, waitOption, timeoutOption, toolNameOption, githubUrlOption, verboseOption }; cmd.SetAction(async (parseResult, _) => { var sarifFile = parseResult.GetValue(sarifFileArg)!; var repo = parseResult.GetValue(repoOption)!; var gitRef = parseResult.GetValue(refOption); var sha = parseResult.GetValue(shaOption); var wait = parseResult.GetValue(waitOption); var timeout = parseResult.GetValue(timeoutOption); var toolName = parseResult.GetValue(toolNameOption); var githubUrl = parseResult.GetValue(githubUrlOption); var verbose = parseResult.GetValue(verboseOption); try { await UploadSarifAsync( services, sarifFile, repo, gitRef, sha, wait, TimeSpan.FromSeconds(timeout), toolName, githubUrl, verbose, cancellationToken); return 0; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); return 1; } }); return cmd; } private static async Task UploadSarifAsync( IServiceProvider services, string sarifFilePath, string repo, string? gitRef, string? sha, bool wait, TimeSpan timeout, string? toolName, string? githubUrl, bool verbose, CancellationToken ct) { // Parse owner/repo var parts = repo.Split('/'); if (parts.Length != 2) { throw new ArgumentException("Repository must be in owner/repo format"); } var owner = parts[0]; var repoName = parts[1]; // Validate SARIF file if (!File.Exists(sarifFilePath)) { throw new FileNotFoundException($"SARIF file not found: {sarifFilePath}"); } // Read SARIF content var sarifContent = await File.ReadAllTextAsync(sarifFilePath, ct); // Get git info if not provided var commitSha = sha ?? await GetGitShaAsync(ct); var refValue = gitRef ?? await GetGitRefAsync(ct); AnsiConsole.MarkupLine($"[blue]Uploading SARIF to[/] [yellow]{owner}/{repoName}[/]"); AnsiConsole.MarkupLine($" Commit: [dim]{commitSha}[/]"); AnsiConsole.MarkupLine($" Ref: [dim]{refValue}[/]"); // Get client var client = GetCodeScanningClient(services, githubUrl); // Build request var request = new SarifUploadRequest { CommitSha = commitSha, Ref = refValue, SarifContent = sarifContent, ToolName = toolName ?? "StellaOps Scanner" }; // Upload var result = await client.UploadSarifAsync(owner, repoName, request, ct); AnsiConsole.MarkupLine($"[green]Uploaded successfully![/]"); AnsiConsole.MarkupLine($" SARIF ID: [cyan]{result.Id}[/]"); AnsiConsole.MarkupLine($" Status URL: [dim]{result.Url}[/]"); // Wait if requested if (wait) { AnsiConsole.MarkupLine("[blue]Waiting for processing...[/]"); var status = await AnsiConsole.Status() .Spinner(Spinner.Known.Dots) .StartAsync("Processing...", async ctx => { return await client.WaitForProcessingAsync( owner, repoName, result.Id, timeout, ct); }); if (status.Status == ProcessingStatus.Complete) { AnsiConsole.MarkupLine($"[green]Processing complete![/]"); if (!string.IsNullOrEmpty(status.AnalysisUrl)) { AnsiConsole.MarkupLine($" Analysis: [link]{status.AnalysisUrl}[/]"); } } else if (status.Status == ProcessingStatus.Failed) { AnsiConsole.MarkupLine($"[red]Processing failed![/]"); foreach (var error in status.Errors) { AnsiConsole.MarkupLine($" [red]- {error}[/]"); } throw new InvalidOperationException("SARIF processing failed"); } } } private static Command BuildListAlertsCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var repoOption = new Option("--repo", new[] { "-r" }) { Description = "Repository in owner/repo format", Required = true }; var stateOption = new Option("--state", new[] { "-s" }) { Description = "Filter by state: open, closed, dismissed, fixed" }; var severityOption = new Option("--severity") { Description = "Filter by severity: critical, high, medium, low" }; var toolOption = new Option("--tool") { Description = "Filter by tool name" }; var refOption = new Option("--ref") { Description = "Filter by git ref" }; var jsonOption = new Option("--json") { Description = "Output as JSON" }; var githubUrlOption = new Option("--github-url") { Description = "GitHub API URL (for GitHub Enterprise Server)" }; var cmd = new Command("list-alerts", "List code scanning alerts for a repository.") { repoOption, stateOption, severityOption, toolOption, refOption, jsonOption, githubUrlOption, verboseOption }; cmd.SetAction(async (parseResult, _) => { var repo = parseResult.GetValue(repoOption)!; var state = parseResult.GetValue(stateOption); var severity = parseResult.GetValue(severityOption); var tool = parseResult.GetValue(toolOption); var gitRef = parseResult.GetValue(refOption); var json = parseResult.GetValue(jsonOption); var githubUrl = parseResult.GetValue(githubUrlOption); try { await ListAlertsAsync( services, repo, state, severity, tool, gitRef, json, githubUrl, cancellationToken); return 0; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); return 1; } }); return cmd; } private static async Task ListAlertsAsync( IServiceProvider services, string repo, string? state, string? severity, string? tool, string? gitRef, bool json, string? githubUrl, CancellationToken ct) { var parts = repo.Split('/'); if (parts.Length != 2) { throw new ArgumentException("Repository must be in owner/repo format"); } var owner = parts[0]; var repoName = parts[1]; var client = GetCodeScanningClient(services, githubUrl); var filter = new AlertFilter { State = state, Severity = severity, Tool = tool, Ref = gitRef }; var alerts = await client.ListAlertsAsync(owner, repoName, filter, ct); if (json) { Console.WriteLine(JsonSerializer.Serialize(alerts, JsonOptions)); return; } if (alerts.Count == 0) { AnsiConsole.MarkupLine("[dim]No alerts found.[/]"); return; } var table = new Table(); table.AddColumn("#"); table.AddColumn("State"); table.AddColumn("Severity"); table.AddColumn("Rule"); table.AddColumn("Tool"); table.AddColumn("Created"); foreach (var alert in alerts) { var stateColor = alert.State switch { "open" => "red", "dismissed" => "yellow", "fixed" => "green", _ => "dim" }; var severityColor = alert.RuleSeverity switch { "critical" or "error" => "red", "high" => "yellow", "medium" or "warning" => "blue", _ => "dim" }; table.AddRow( alert.Number.ToString(CultureInfo.InvariantCulture), $"[{stateColor}]{alert.State}[/]", $"[{severityColor}]{alert.RuleSeverity}[/]", alert.RuleId, alert.Tool, alert.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } AnsiConsole.Write(table); AnsiConsole.MarkupLine($"[dim]Total: {alerts.Count} alerts[/]"); } private static Command BuildGetAlertCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var alertNumberArg = new Argument("alert-number") { Description = "Alert number to retrieve." }; var repoOption = new Option("--repo", new[] { "-r" }) { Description = "Repository in owner/repo format", Required = true }; var jsonOption = new Option("--json") { Description = "Output as JSON" }; var githubUrlOption = new Option("--github-url") { Description = "GitHub API URL (for GitHub Enterprise Server)" }; var cmd = new Command("get-alert", "Get details for a specific code scanning alert.") { alertNumberArg, repoOption, jsonOption, githubUrlOption, verboseOption }; cmd.SetAction(async (parseResult, _) => { var alertNumber = parseResult.GetValue(alertNumberArg); var repo = parseResult.GetValue(repoOption)!; var json = parseResult.GetValue(jsonOption); var githubUrl = parseResult.GetValue(githubUrlOption); try { await GetAlertAsync(services, repo, alertNumber, json, githubUrl, cancellationToken); return 0; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); return 1; } }); return cmd; } private static async Task GetAlertAsync( IServiceProvider services, string repo, int alertNumber, bool json, string? githubUrl, CancellationToken ct) { var parts = repo.Split('/'); if (parts.Length != 2) { throw new ArgumentException("Repository must be in owner/repo format"); } var owner = parts[0]; var repoName = parts[1]; var client = GetCodeScanningClient(services, githubUrl); var alert = await client.GetAlertAsync(owner, repoName, alertNumber, ct); if (json) { Console.WriteLine(JsonSerializer.Serialize(alert, JsonOptions)); return; } AnsiConsole.MarkupLine($"[bold]Alert #{alert.Number}[/]"); AnsiConsole.MarkupLine($" State: {alert.State}"); AnsiConsole.MarkupLine($" Rule: {alert.RuleId}"); AnsiConsole.MarkupLine($" Severity: {alert.RuleSeverity}"); AnsiConsole.MarkupLine($" Description: {alert.RuleDescription}"); AnsiConsole.MarkupLine($" Tool: {alert.Tool}"); AnsiConsole.MarkupLine($" Created: {alert.CreatedAt:yyyy-MM-dd HH:mm:ss}"); if (alert.DismissedAt.HasValue) { AnsiConsole.MarkupLine($" Dismissed: {alert.DismissedAt.Value:yyyy-MM-dd HH:mm:ss}"); AnsiConsole.MarkupLine($" Dismiss reason: {alert.DismissedReason}"); } if (alert.MostRecentInstance != null) { AnsiConsole.MarkupLine($" Location: {alert.MostRecentInstance.Location?.Path}:{alert.MostRecentInstance.Location?.StartLine}"); } AnsiConsole.MarkupLine($" URL: [link]{alert.HtmlUrl}[/]"); } private static Command BuildUpdateAlertCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var alertNumberArg = new Argument("alert-number") { Description = "Alert number to update." }; var repoOption = new Option("--repo", new[] { "-r" }) { Description = "Repository in owner/repo format", Required = true }; var stateOption = new Option("--state", new[] { "-s" }) { Description = "New state: dismissed, open", Required = true }; stateOption.FromAmong("dismissed", "open"); var reasonOption = new Option("--reason") { Description = "Dismiss reason: false_positive, wont_fix, used_in_tests" }; var commentOption = new Option("--comment") { Description = "Dismiss comment" }; var githubUrlOption = new Option("--github-url") { Description = "GitHub API URL (for GitHub Enterprise Server)" }; var cmd = new Command("update-alert", "Update a code scanning alert state.") { alertNumberArg, repoOption, stateOption, reasonOption, commentOption, githubUrlOption, verboseOption }; cmd.SetAction(async (parseResult, _) => { var alertNumber = parseResult.GetValue(alertNumberArg); var repo = parseResult.GetValue(repoOption)!; var state = parseResult.GetValue(stateOption)!; var reason = parseResult.GetValue(reasonOption); var comment = parseResult.GetValue(commentOption); var githubUrl = parseResult.GetValue(githubUrlOption); try { await UpdateAlertAsync( services, repo, alertNumber, state, reason, comment, githubUrl, cancellationToken); return 0; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); return 1; } }); return cmd; } private static async Task UpdateAlertAsync( IServiceProvider services, string repo, int alertNumber, string state, string? reason, string? comment, string? githubUrl, CancellationToken ct) { var parts = repo.Split('/'); if (parts.Length != 2) { throw new ArgumentException("Repository must be in owner/repo format"); } var owner = parts[0]; var repoName = parts[1]; if (state == "dismissed" && string.IsNullOrEmpty(reason)) { throw new ArgumentException("Dismiss reason is required when dismissing an alert"); } var client = GetCodeScanningClient(services, githubUrl); var update = new AlertUpdate { State = state, DismissedReason = reason, DismissedComment = comment }; var alert = await client.UpdateAlertAsync(owner, repoName, alertNumber, update, ct); AnsiConsole.MarkupLine($"[green]Alert #{alert.Number} updated to state: {alert.State}[/]"); } private static Command BuildUploadStatusCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var sarifIdArg = new Argument("sarif-id") { Description = "SARIF upload ID to check." }; var repoOption = new Option("--repo", new[] { "-r" }) { Description = "Repository in owner/repo format", Required = true }; var jsonOption = new Option("--json") { Description = "Output as JSON" }; var githubUrlOption = new Option("--github-url") { Description = "GitHub API URL (for GitHub Enterprise Server)" }; var cmd = new Command("upload-status", "Check SARIF upload processing status.") { sarifIdArg, repoOption, jsonOption, githubUrlOption, verboseOption }; cmd.SetAction(async (parseResult, _) => { var sarifId = parseResult.GetValue(sarifIdArg)!; var repo = parseResult.GetValue(repoOption)!; var json = parseResult.GetValue(jsonOption); var githubUrl = parseResult.GetValue(githubUrlOption); try { await GetUploadStatusAsync(services, repo, sarifId, json, githubUrl, cancellationToken); return 0; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); return 1; } }); return cmd; } private static async Task GetUploadStatusAsync( IServiceProvider services, string repo, string sarifId, bool json, string? githubUrl, CancellationToken ct) { var parts = repo.Split('/'); if (parts.Length != 2) { throw new ArgumentException("Repository must be in owner/repo format"); } var owner = parts[0]; var repoName = parts[1]; var client = GetCodeScanningClient(services, githubUrl); var status = await client.GetUploadStatusAsync(owner, repoName, sarifId, ct); if (json) { Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions)); return; } var statusColor = status.Status switch { ProcessingStatus.Complete => "green", ProcessingStatus.Failed => "red", _ => "yellow" }; AnsiConsole.MarkupLine($"[bold]SARIF Upload Status[/]"); AnsiConsole.MarkupLine($" Status: [{statusColor}]{status.Status}[/]"); if (status.ProcessingStartedAt.HasValue) { AnsiConsole.MarkupLine($" Started: {status.ProcessingStartedAt.Value:yyyy-MM-dd HH:mm:ss}"); } if (status.ProcessingCompletedAt.HasValue) { AnsiConsole.MarkupLine($" Completed: {status.ProcessingCompletedAt.Value:yyyy-MM-dd HH:mm:ss}"); } if (!string.IsNullOrEmpty(status.AnalysisUrl)) { AnsiConsole.MarkupLine($" Analysis: [link]{status.AnalysisUrl}[/]"); } if (status.Errors.Length > 0) { AnsiConsole.MarkupLine("[red]Errors:[/]"); foreach (var error in status.Errors) { AnsiConsole.MarkupLine($" - {error}"); } } } private static IGitHubCodeScanningClient GetCodeScanningClient( IServiceProvider services, string? githubUrl) { // Try to get from DI first var client = services.GetService(); if (client != null) { return client; } // Fallback: create manually (this would use environment token) throw new InvalidOperationException( "GitHub Code Scanning client not configured. " + "Please ensure GITHUB_TOKEN environment variable is set."); } private static async Task GetGitShaAsync(CancellationToken ct) { try { var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "git", Arguments = "rev-parse HEAD", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }); if (process == null) { throw new InvalidOperationException("Failed to start git process"); } var sha = await process.StandardOutput.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); return sha.Trim(); } catch { throw new InvalidOperationException( "Could not determine commit SHA. Please provide --sha option."); } } private static async Task GetGitRefAsync(CancellationToken ct) { try { var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "git", Arguments = "symbolic-ref HEAD", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }); if (process == null) { throw new InvalidOperationException("Failed to start git process"); } var refVal = await process.StandardOutput.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); return refVal.Trim(); } catch { throw new InvalidOperationException( "Could not determine git ref. Please provide --ref option."); } } }