Public/Get-InforcerReportRun.ps1

<#
.SYNOPSIS
    Lists report runs from the Inforcer Reports API, with optional polling and outputs embedding.

    Required API scope(s): Reports.Read
.DESCRIPTION
    GET /beta/reports/runs returns the most recent runs (server-side cap: 500 items, 7-day window;
    query parameters are silently ignored at the time of writing — filter client-side).

    Two known characteristics worth calling out before relying on this endpoint:

      * Propagation lag: a newly-queued run does not appear in the list response for ~4 minutes.
        For freshly-queued work, use Invoke-InforcerReport (which polls the outputs endpoint
        directly) rather than -RunId here.
      * No filter support: server ignores ?status=, ?since=, ?limit=, etc. Filtering happens
        in this cmdlet.

    Polling and outputs embedding are opt-in: -Wait polls the outputs endpoint (which 404s
    until terminal, then 200s — bypasses the list-endpoint lag); -IncludeOutputs fetches
    /runs/{id}/outputs once per returned run and attaches the outputs array.
.PARAMETER RunId
    Filter to a single run by GUID. Combined with -Wait, polls that run until terminal.
.PARAMETER Wait
    Poll runs via GET /runs/{id}/outputs until terminal (200) or -TimeoutSeconds elapses.
    Requires -RunId.
.PARAMETER IncludeOutputs
    For each returned run, fetch GET /runs/{id}/outputs and embed the array on .outputs. Adds
    one API call per run; use sparingly on large result sets.
.PARAMETER TimeoutSeconds
    Maximum total wait when -Wait is used. Default: 600.
.PARAMETER PollIntervalSeconds
    Initial poll interval (doubles up to a 15-second cap). Default: 2.
.PARAMETER Format
    Raw (default). Reserved.
.PARAMETER OutputType
    PowerShellObject (default) or JsonObject. JSON uses Depth 100.
.EXAMPLE
    Get-InforcerReportRun
    Lists every visible run (up to 500, last 7 days).
.EXAMPLE
    Get-InforcerReportRun -RunId 094a49ed-b9b8-492b-870f-0f76fd3b2954
    Returns metadata for a single run (subject to the list-endpoint propagation lag).
.EXAMPLE
    Get-InforcerReportRun -RunId 094a49ed-b9b8-492b-870f-0f76fd3b2954 -Wait
    Polls the run until terminal and returns its metadata with outputs embedded.
.EXAMPLE
    Get-InforcerReportRun -IncludeOutputs
    Returns every run with its outputs array embedded (1 extra API call per run).
.OUTPUTS
    PSObject or String
.LINK
    https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#get-inforcerreportrun
.LINK
    Invoke-InforcerReport
.LINK
    Save-InforcerReportOutput
#>

function Get-InforcerReportRun {
[CmdletBinding()]
[OutputType([PSObject], [string])]
param(
    [Parameter(Mandatory = $false, Position = 0, ValueFromPipelineByPropertyName = $true)]
    [Alias('Id')]
    [guid]$RunId,

    [Parameter(Mandatory = $false)]
    [switch]$Wait,

    [Parameter(Mandatory = $false)]
    [switch]$IncludeOutputs,

    [Parameter(Mandatory = $false)]
    [ValidateRange(10, 3600)]
    [int]$TimeoutSeconds = 600,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 60)]
    [int]$PollIntervalSeconds = 2,

    [Parameter(Mandatory = $false)]
    [ValidateSet('Raw')]
    [string]$Format = 'Raw',

    [Parameter(Mandatory = $false)]
    [ValidateSet('PowerShellObject', 'JsonObject')]
    [string]$OutputType = 'PowerShellObject'
)

begin {
    $sessionOk = Test-InforcerSession
    if (-not $sessionOk) {
        Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' `
            -ErrorId 'NotConnected' -Category ConnectionError
    }
    # Cache the list-endpoint response across process{} iterations so piping N RunIds is
    # one GET, not N GETs. Per-invocation local — not $script:* — so concurrent invocations
    # don't share state.
    $cachedListResponse = $null
    $listResponseFetched = $false
    $polledOutputsByRunId = @{}
}

process {
    if (-not $sessionOk) { return }

if ($Wait.IsPresent -and -not $PSBoundParameters.ContainsKey('RunId')) {
    Write-Error -Message '-Wait requires -RunId. Use Invoke-InforcerReport for batch polling.' `
        -ErrorId 'WaitRequiresRunId' -Category InvalidArgument
    return
}

# --- Optional polling stage (uses outputs endpoint to bypass list-endpoint lag) ---
$polledOutputs = $null
if ($Wait.IsPresent) {
    Write-Verbose "Polling run $RunId until terminal (timeout: ${TimeoutSeconds}s)..."
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    $interval = [Math]::Max($PollIntervalSeconds, 1)
    while ($true) {
        try {
            $probe = Test-InforcerReportRunTerminal -RunId $RunId -ErrorAction Stop
        } catch {
            Write-Error -Message $_.Exception.Message -ErrorId 'PollFailed' -Category ConnectionError
            return
        }
        if ($probe.IsTerminal) {
            $polledOutputs = $probe.Outputs
            $polledOutputsByRunId[$RunId.ToString()] = $polledOutputs
            break
        }
        if ((Get-Date) -ge $deadline) {
            Write-Error -Message "Timed out waiting for run $RunId to complete (waited ${TimeoutSeconds}s)." `
                -ErrorId 'PollTimeout' -Category OperationStopped
            return
        }
        Start-Sleep -Seconds $interval
        $interval = [Math]::Min($interval * 2, 15)
    }
}

# --- Fetch list once, reuse for every piped RunId ---
if (-not $listResponseFetched) {
    Write-Verbose 'GET /beta/reports/runs'
    $cachedListResponse = Invoke-InforcerApiRequest -Endpoint '/beta/reports/runs' -Method GET -OutputType PowerShellObject
    $listResponseFetched = $true
}
$response = $cachedListResponse
if ($null -eq $response) { return }

$runs = @($response)

if ($PSBoundParameters.ContainsKey('RunId')) {
    $needle = $RunId.ToString()
    # API uses 'runId' on list records; older code paths used 'id' — match either. Single pass.
    $matched = [System.Collections.Generic.List[object]]::new()
    foreach ($r in $runs) {
        $runIdProp = $r.PSObject.Properties['runId']
        if ($runIdProp -and (($runIdProp.Value -as [string]) -ieq $needle)) { [void]$matched.Add($r); continue }
        $idProp = $r.PSObject.Properties['id']
        if ($idProp -and (($idProp.Value -as [string]) -ieq $needle)) { [void]$matched.Add($r) }
    }
    $runs = $matched.ToArray()
    if ($runs.Count -eq 0) {
        if ($polledOutputs) {
            # Polling confirmed the run is terminal, but the list endpoint hasn't caught up.
            Write-Warning "Run $RunId is terminal but not yet visible in GET /reports/runs (4-minute propagation lag). Returning a synthesized record with outputs embedded."
            $runs = @([PSCustomObject]@{
                runId   = $needle
                status  = 'completed'
                outputs = @($polledOutputs)
            })
        } elseif ($IncludeOutputs.IsPresent) {
            # -IncludeOutputs without -Wait: try a one-shot outputs probe to bypass the list-endpoint lag.
            try {
                $probe = Test-InforcerReportRunTerminal -RunId $RunId -ErrorAction Stop
            } catch {
                Write-Warning "Run $RunId not found in GET /reports/runs and outputs probe failed: $($_.Exception.Message)"
                return
            }
            if ($probe.IsTerminal) {
                Write-Warning "Run $RunId is terminal but not yet visible in GET /reports/runs (4-minute propagation lag). Returning a synthesized record with outputs embedded."
                $runs = @([PSCustomObject]@{
                    runId   = $needle
                    status  = 'completed'
                    outputs = @($probe.Outputs)
                })
            } else {
                Write-Warning "Run $RunId is not yet terminal and not in GET /reports/runs. Try -Wait, or retry once the run completes."
                return
            }
        } else {
            Write-Warning "Run $RunId not found in GET /reports/runs. The list endpoint has a ~4-minute propagation lag; for fresh runs, pass -IncludeOutputs (or -Wait) to bypass via the outputs endpoint."
            return
        }
    } elseif ($polledOutputs) {
        # Attach polled outputs to the (single) matching run.
        foreach ($r in $runs) {
            if (-not $r.PSObject.Properties['outputs']) {
                $r | Add-Member -NotePropertyName 'outputs' -NotePropertyValue @($polledOutputs) -Force
            }
        }
    }
}

# --- Optionally embed outputs for every returned run ---
if ($IncludeOutputs.IsPresent -and -not $polledOutputs) {
    foreach ($r in $runs) {
        $idProp = $r.PSObject.Properties['runId']
        if (-not $idProp) { $idProp = $r.PSObject.Properties['id'] }
        $idValue = if ($idProp) { $idProp.Value -as [string] } else { $null }
        $idGuid = [guid]::Empty
        if (-not [guid]::TryParse($idValue, [ref]$idGuid)) {
            Write-Warning "Run record missing valid runId; cannot embed outputs."
            continue
        }
        # Skip if outputs are already attached (e.g. from synthesized lag-fallback record).
        $existing = $r.PSObject.Properties['outputs']
        if ($existing -and $existing.Value) { continue }
        try {
            $probe = Test-InforcerReportRunTerminal -RunId $idGuid -ErrorAction Stop
            if ($probe.IsTerminal) {
                if (-not $r.PSObject.Properties['outputs']) {
                    $r | Add-Member -NotePropertyName 'outputs' -NotePropertyValue @($probe.Outputs) -Force
                } else {
                    $r.PSObject.Properties['outputs'].Value = @($probe.Outputs)
                }
            } else {
                Write-Verbose "Run $idValue is not yet terminal; skipping outputs embedding."
            }
        } catch {
            Write-Warning "Failed to fetch outputs for run ${idValue}: $($_.Exception.Message)"
        }
    }
}

# --- Shape output ---
if ($OutputType -eq 'JsonObject') {
    return ($runs | ConvertTo-Json -Depth 100)
}

foreach ($r in $runs) {
    if ($r -is [PSObject]) {
        $null = Add-InforcerPropertyAliases -InputObject $r -ObjectType ReportRun
        if ($r.PSObject.TypeNames[0] -ne 'InforcerCommunity.ReportRun') {
            $r.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportRun')
        }
    }
}
$runs

} # end of process{} block
}