//
// 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.");
}
}
}