Add channel test providers for Email, Slack, Teams, and Webhook
- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
		@@ -25,103 +25,103 @@ using StellaOps.Cli.Telemetry;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Cli.Commands;
 | 
			
		||||
 | 
			
		||||
internal static class CommandHandlers
 | 
			
		||||
{
 | 
			
		||||
    public static async Task HandleScannerDownloadAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string channel,
 | 
			
		||||
        string? output,
 | 
			
		||||
        bool overwrite,
 | 
			
		||||
        bool install,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "scanner download");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.channel", channel);
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("scanner download");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (result.FromCache)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Using cached scanner at {Path}.", result.Path);
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            CliMetrics.RecordScannerDownload(channel, result.FromCache);
 | 
			
		||||
 | 
			
		||||
            if (install)
 | 
			
		||||
            {
 | 
			
		||||
                var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
 | 
			
		||||
                await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                CliMetrics.RecordScannerInstall(channel);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Environment.ExitCode = 0;
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Failed to download scanner bundle.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleScannerRunAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string runner,
 | 
			
		||||
        string entry,
 | 
			
		||||
        string targetDirectory,
 | 
			
		||||
        IReadOnlyList<string> arguments,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "scan run");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.runner", runner);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.entry", entry);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.target", targetDirectory);
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("scan run");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
 | 
			
		||||
            var resultsDirectory = options.ResultsDirectory;
 | 
			
		||||
 | 
			
		||||
            var executionResult = await executor.RunAsync(
 | 
			
		||||
                runner,
 | 
			
		||||
                entry,
 | 
			
		||||
                targetDirectory,
 | 
			
		||||
                resultsDirectory,
 | 
			
		||||
                arguments,
 | 
			
		||||
                verbose,
 | 
			
		||||
                cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            Environment.ExitCode = executionResult.ExitCode;
 | 
			
		||||
            CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
internal static class CommandHandlers
 | 
			
		||||
{
 | 
			
		||||
    public static async Task HandleScannerDownloadAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string channel,
 | 
			
		||||
        string? output,
 | 
			
		||||
        bool overwrite,
 | 
			
		||||
        bool install,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "scanner download");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.channel", channel);
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("scanner download");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (result.FromCache)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Using cached scanner at {Path}.", result.Path);
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            CliMetrics.RecordScannerDownload(channel, result.FromCache);
 | 
			
		||||
 | 
			
		||||
            if (install)
 | 
			
		||||
            {
 | 
			
		||||
                var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
 | 
			
		||||
                await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                CliMetrics.RecordScannerInstall(channel);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Environment.ExitCode = 0;
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Failed to download scanner bundle.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleScannerRunAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string runner,
 | 
			
		||||
        string entry,
 | 
			
		||||
        string targetDirectory,
 | 
			
		||||
        IReadOnlyList<string> arguments,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "scan run");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.runner", runner);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.entry", entry);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.target", targetDirectory);
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("scan run");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
 | 
			
		||||
            var resultsDirectory = options.ResultsDirectory;
 | 
			
		||||
 | 
			
		||||
            var executionResult = await executor.RunAsync(
 | 
			
		||||
                runner,
 | 
			
		||||
                entry,
 | 
			
		||||
                targetDirectory,
 | 
			
		||||
                resultsDirectory,
 | 
			
		||||
                arguments,
 | 
			
		||||
                verbose,
 | 
			
		||||
                cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            Environment.ExitCode = executionResult.ExitCode;
 | 
			
		||||
            CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
 | 
			
		||||
 | 
			
		||||
            if (executionResult.ExitCode == 0)
 | 
			
		||||
            {
 | 
			
		||||
                var backend = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
@@ -138,128 +138,128 @@ internal static class CommandHandlers
 | 
			
		||||
            logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath);
 | 
			
		||||
            activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Scanner execution failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleScanUploadAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string file,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "scan upload");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.file", file);
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("scan upload");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var path = Path.GetFullPath(file);
 | 
			
		||||
            await client.UploadScanResultsAsync(path, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            logger.LogInformation("Scan results uploaded successfully.");
 | 
			
		||||
            Environment.ExitCode = 0;
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Failed to upload scan results.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleConnectorJobAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string source,
 | 
			
		||||
        string stage,
 | 
			
		||||
        string? mode,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "db fetch");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.source", source);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.stage", stage);
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(mode))
 | 
			
		||||
        {
 | 
			
		||||
            activity?.SetTag("stellaops.cli.mode", mode);
 | 
			
		||||
        }
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("db fetch");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var jobKind = $"source:{source}:{stage}";
 | 
			
		||||
            var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(mode))
 | 
			
		||||
            {
 | 
			
		||||
                parameters["mode"] = mode;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Connector job failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleMergeJobAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "db merge");
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("db merge");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Merge job failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Scanner execution failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleScanUploadAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string file,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "scan upload");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.file", file);
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("scan upload");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var path = Path.GetFullPath(file);
 | 
			
		||||
            await client.UploadScanResultsAsync(path, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            logger.LogInformation("Scan results uploaded successfully.");
 | 
			
		||||
            Environment.ExitCode = 0;
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Failed to upload scan results.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleConnectorJobAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string source,
 | 
			
		||||
        string stage,
 | 
			
		||||
        string? mode,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "db fetch");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.source", source);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.stage", stage);
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(mode))
 | 
			
		||||
        {
 | 
			
		||||
            activity?.SetTag("stellaops.cli.mode", mode);
 | 
			
		||||
        }
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("db fetch");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var jobKind = $"source:{source}:{stage}";
 | 
			
		||||
            var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(mode))
 | 
			
		||||
            {
 | 
			
		||||
                parameters["mode"] = mode;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Connector job failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleMergeJobAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "db merge");
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("db merge");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Merge job failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleExportJobAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string format,
 | 
			
		||||
@@ -271,16 +271,16 @@ internal static class CommandHandlers
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "db export");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.format", format);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.delta", delta);
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "db export");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.format", format);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.delta", delta);
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("db export");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.publish_full", publishFull);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.publish_delta", publishDelta);
 | 
			
		||||
@@ -330,16 +330,16 @@ internal static class CommandHandlers
 | 
			
		||||
            {
 | 
			
		||||
                parameters["includeDelta"] = includeDelta.Value;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Export job failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
 | 
			
		||||
            await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Export job failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@@ -723,6 +723,62 @@ internal static class CommandHandlers
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Task HandleExcititorBackfillStatementsAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        DateTimeOffset? retrievedSince,
 | 
			
		||||
        bool force,
 | 
			
		||||
        int batchSize,
 | 
			
		||||
        int? maxDocuments,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (batchSize <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be greater than zero.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (maxDocuments.HasValue && maxDocuments.Value <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentOutOfRangeException(nameof(maxDocuments), "Max documents must be greater than zero when specified.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
 | 
			
		||||
        {
 | 
			
		||||
            ["force"] = force,
 | 
			
		||||
            ["batchSize"] = batchSize,
 | 
			
		||||
            ["maxDocuments"] = maxDocuments
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (retrievedSince.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            payload["retrievedSince"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var activityTags = new Dictionary<string, object?>(StringComparer.Ordinal)
 | 
			
		||||
        {
 | 
			
		||||
            ["stellaops.cli.force"] = force,
 | 
			
		||||
            ["stellaops.cli.batch_size"] = batchSize,
 | 
			
		||||
            ["stellaops.cli.max_documents"] = maxDocuments
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (retrievedSince.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            activityTags["stellaops.cli.retrieved_since"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return ExecuteExcititorCommandAsync(
 | 
			
		||||
            services,
 | 
			
		||||
            commandName: "excititor backfill-statements",
 | 
			
		||||
            verbose,
 | 
			
		||||
            activityTags,
 | 
			
		||||
            client => client.ExecuteExcititorOperationAsync(
 | 
			
		||||
                "admin/backfill-statements",
 | 
			
		||||
                HttpMethod.Post,
 | 
			
		||||
                RemoveNullValues(payload),
 | 
			
		||||
                cancellationToken),
 | 
			
		||||
            cancellationToken);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Task HandleExcititorVerifyAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string? exportId,
 | 
			
		||||
@@ -2208,7 +2264,7 @@ internal static class CommandHandlers
 | 
			
		||||
        {
 | 
			
		||||
            ["policyVerdict"] = decision.PolicyVerdict,
 | 
			
		||||
            ["signed"] = decision.Signed,
 | 
			
		||||
            ["hasSbom"] = decision.HasSbom
 | 
			
		||||
            ["hasSbomReferrers"] = decision.HasSbomReferrers
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (decision.Reasons.Count > 0)
 | 
			
		||||
@@ -2218,11 +2274,26 @@ internal static class CommandHandlers
 | 
			
		||||
 | 
			
		||||
        if (decision.Rekor is not null)
 | 
			
		||||
        {
 | 
			
		||||
            map["rekor"] = new Dictionary<string, object?>(StringComparer.Ordinal)
 | 
			
		||||
            var rekorMap = new Dictionary<string, object?>(StringComparer.Ordinal);
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid))
 | 
			
		||||
            {
 | 
			
		||||
                ["uuid"] = decision.Rekor.Uuid,
 | 
			
		||||
                ["url"] = decision.Rekor.Url
 | 
			
		||||
            };
 | 
			
		||||
                rekorMap["uuid"] = decision.Rekor.Uuid;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(decision.Rekor.Url))
 | 
			
		||||
            {
 | 
			
		||||
                rekorMap["url"] = decision.Rekor.Url;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (decision.Rekor.Verified.HasValue)
 | 
			
		||||
            {
 | 
			
		||||
                rekorMap["verified"] = decision.Rekor.Verified;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (rekorMap.Count > 0)
 | 
			
		||||
            {
 | 
			
		||||
                map["rekor"] = rekorMap;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach (var kvp in decision.AdditionalProperties)
 | 
			
		||||
@@ -2240,7 +2311,8 @@ internal static class CommandHandlers
 | 
			
		||||
 | 
			
		||||
        if (AnsiConsole.Profile.Capabilities.Interactive)
 | 
			
		||||
        {
 | 
			
		||||
            var table = new Table().Border(TableBorder.Rounded).AddColumns("Image", "Verdict", "Signed", "SBOM", "Reasons", "Attestation");
 | 
			
		||||
            var table = new Table().Border(TableBorder.Rounded)
 | 
			
		||||
                .AddColumns("Image", "Verdict", "Signed", "SBOM Ref", "Quieted", "Confidence", "Reasons", "Attestation");
 | 
			
		||||
 | 
			
		||||
            foreach (var image in orderedImages)
 | 
			
		||||
            {
 | 
			
		||||
@@ -2250,9 +2322,11 @@ internal static class CommandHandlers
 | 
			
		||||
                        image,
 | 
			
		||||
                        decision.PolicyVerdict,
 | 
			
		||||
                        FormatBoolean(decision.Signed),
 | 
			
		||||
                        FormatBoolean(decision.HasSbom),
 | 
			
		||||
                        FormatBoolean(decision.HasSbomReferrers),
 | 
			
		||||
                        FormatQuietedDisplay(decision.AdditionalProperties),
 | 
			
		||||
                        FormatConfidenceDisplay(decision.AdditionalProperties),
 | 
			
		||||
                        decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-",
 | 
			
		||||
                        string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!);
 | 
			
		||||
                        FormatAttestation(decision.Rekor));
 | 
			
		||||
 | 
			
		||||
                    summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
 | 
			
		||||
 | 
			
		||||
@@ -2264,7 +2338,7 @@ internal static class CommandHandlers
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    table.AddRow(image, "<missing>", "-", "-", "-", "-");
 | 
			
		||||
                    table.AddRow(image, "<missing>", "-", "-", "-", "-", "-", "-");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@@ -2278,12 +2352,14 @@ internal static class CommandHandlers
 | 
			
		||||
                {
 | 
			
		||||
                    var reasons = decision.Reasons.Count > 0 ? string.Join(", ", decision.Reasons) : "none";
 | 
			
		||||
                    logger.LogInformation(
 | 
			
		||||
                        "{Image} -> verdict={Verdict} signed={Signed} sbom={Sbom} attestation={Attestation} reasons={Reasons}",
 | 
			
		||||
                        "{Image} -> verdict={Verdict} signed={Signed} sbomRef={Sbom} quieted={Quieted} confidence={Confidence} attestation={Attestation} reasons={Reasons}",
 | 
			
		||||
                        image,
 | 
			
		||||
                        decision.PolicyVerdict,
 | 
			
		||||
                        FormatBoolean(decision.Signed),
 | 
			
		||||
                        FormatBoolean(decision.HasSbom),
 | 
			
		||||
                        string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!,
 | 
			
		||||
                        FormatBoolean(decision.HasSbomReferrers),
 | 
			
		||||
                        FormatQuietedDisplay(decision.AdditionalProperties),
 | 
			
		||||
                        FormatConfidenceDisplay(decision.AdditionalProperties),
 | 
			
		||||
                        FormatAttestation(decision.Rekor),
 | 
			
		||||
                        reasons);
 | 
			
		||||
 | 
			
		||||
                    summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
 | 
			
		||||
@@ -2346,6 +2422,144 @@ internal static class CommandHandlers
 | 
			
		||||
    private static string FormatBoolean(bool? value)
 | 
			
		||||
        => value is null ? "unknown" : value.Value ? "yes" : "no";
 | 
			
		||||
 | 
			
		||||
    private static string FormatQuietedDisplay(IReadOnlyDictionary<string, object?> metadata)
 | 
			
		||||
    {
 | 
			
		||||
        var quieted = GetMetadataBoolean(metadata, "quieted", "quiet");
 | 
			
		||||
        var quietedBy = GetMetadataString(metadata, "quietedBy", "quietedReason");
 | 
			
		||||
 | 
			
		||||
        if (quieted is true)
 | 
			
		||||
        {
 | 
			
		||||
            return string.IsNullOrWhiteSpace(quietedBy) ? "yes" : $"yes ({quietedBy})";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (quieted is false)
 | 
			
		||||
        {
 | 
			
		||||
            return "no";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return string.IsNullOrWhiteSpace(quietedBy) ? "-" : $"? ({quietedBy})";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string FormatConfidenceDisplay(IReadOnlyDictionary<string, object?> metadata)
 | 
			
		||||
    {
 | 
			
		||||
        var confidence = GetMetadataDouble(metadata, "confidence");
 | 
			
		||||
        var confidenceBand = GetMetadataString(metadata, "confidenceBand", "confidenceTier");
 | 
			
		||||
 | 
			
		||||
        if (confidence.HasValue && !string.IsNullOrWhiteSpace(confidenceBand))
 | 
			
		||||
        {
 | 
			
		||||
            return string.Format(CultureInfo.InvariantCulture, "{0:0.###} ({1})", confidence.Value, confidenceBand);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (confidence.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            return confidence.Value.ToString("0.###", CultureInfo.InvariantCulture);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(confidenceBand))
 | 
			
		||||
        {
 | 
			
		||||
            return confidenceBand!;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return "-";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string FormatAttestation(RuntimePolicyRekorReference? rekor)
 | 
			
		||||
    {
 | 
			
		||||
        if (rekor is null)
 | 
			
		||||
        {
 | 
			
		||||
            return "-";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var uuid = string.IsNullOrWhiteSpace(rekor.Uuid) ? null : rekor.Uuid;
 | 
			
		||||
        var url = string.IsNullOrWhiteSpace(rekor.Url) ? null : rekor.Url;
 | 
			
		||||
        var verified = rekor.Verified;
 | 
			
		||||
 | 
			
		||||
        var core = uuid ?? url;
 | 
			
		||||
        if (!string.IsNullOrEmpty(core))
 | 
			
		||||
        {
 | 
			
		||||
            if (verified.HasValue)
 | 
			
		||||
            {
 | 
			
		||||
                var suffix = verified.Value ? " (verified)" : " (unverified)";
 | 
			
		||||
                return core + suffix;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return core!;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (verified.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            return verified.Value ? "verified" : "unverified";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return "-";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool? GetMetadataBoolean(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
 | 
			
		||||
    {
 | 
			
		||||
        foreach (var key in keys)
 | 
			
		||||
        {
 | 
			
		||||
            if (metadata.TryGetValue(key, out var value) && value is not null)
 | 
			
		||||
            {
 | 
			
		||||
                switch (value)
 | 
			
		||||
                {
 | 
			
		||||
                    case bool b:
 | 
			
		||||
                        return b;
 | 
			
		||||
                    case string s when bool.TryParse(s, out var parsed):
 | 
			
		||||
                        return parsed;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? GetMetadataString(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
 | 
			
		||||
    {
 | 
			
		||||
        foreach (var key in keys)
 | 
			
		||||
        {
 | 
			
		||||
            if (metadata.TryGetValue(key, out var value) && value is not null)
 | 
			
		||||
            {
 | 
			
		||||
                if (value is string s)
 | 
			
		||||
                {
 | 
			
		||||
                    return string.IsNullOrWhiteSpace(s) ? null : s;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static double? GetMetadataDouble(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
 | 
			
		||||
    {
 | 
			
		||||
        foreach (var key in keys)
 | 
			
		||||
        {
 | 
			
		||||
            if (metadata.TryGetValue(key, out var value) && value is not null)
 | 
			
		||||
            {
 | 
			
		||||
                switch (value)
 | 
			
		||||
                {
 | 
			
		||||
                    case double d:
 | 
			
		||||
                        return d;
 | 
			
		||||
                    case float f:
 | 
			
		||||
                        return f;
 | 
			
		||||
                    case decimal m:
 | 
			
		||||
                        return (double)m;
 | 
			
		||||
                    case long l:
 | 
			
		||||
                        return l;
 | 
			
		||||
                    case int i:
 | 
			
		||||
                        return i;
 | 
			
		||||
                    case string s when double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed):
 | 
			
		||||
                        return parsed;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
 | 
			
		||||
        new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    private static string FormatAdditionalValue(object? value)
 | 
			
		||||
    {
 | 
			
		||||
        return value switch
 | 
			
		||||
@@ -2359,8 +2573,6 @@ internal static class CommandHandlers
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
 | 
			
		||||
        new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers)
 | 
			
		||||
    {
 | 
			
		||||
@@ -2397,29 +2609,29 @@ internal static class CommandHandlers
 | 
			
		||||
        string jobKind,
 | 
			
		||||
        IDictionary<string, object?> parameters,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (result.Success)
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(result.Location))
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
 | 
			
		||||
            }
 | 
			
		||||
            else if (result.Run is not null)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Job accepted.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Environment.ExitCode = 0;
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
    {
 | 
			
		||||
        JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (result.Success)
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(result.Location))
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
 | 
			
		||||
            }
 | 
			
		||||
            else if (result.Run is not null)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Job accepted.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Environment.ExitCode = 0;
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user