modules/Invoke-GhActionsBilling.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    GitHub Actions billing and runtime cost telemetry.
.DESCRIPTION
    Uses gh api to collect org billing signals plus per-repo workflow run
    durations. Emits v1 findings for:
      - org included minute overage
      - top repository consumers (top 5 by minutes)
      - long-run anomaly (>60 min and above 30-day average)
.PARAMETER Org
    GitHub organization name.
.PARAMETER Repo
    Optional repo filter. When omitted, all org repos are queried.
.PARAMETER DaysBack
    Lookback window for workflow runs. Defaults to 30.
.PARAMETER MonthlyBudgetUsd
    Optional soft budget threshold for estimated paid minute cost.
#>

[CmdletBinding()]
param (
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string] $Org,

    [string] $Repo,

    [ValidateRange(1, 365)]
    [int] $DaysBack = 30,

    [double] $MonthlyBudgetUsd
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$sharedDir = Join-Path $PSScriptRoot 'shared'
. (Join-Path $sharedDir 'Retry.ps1')
. (Join-Path $sharedDir 'Sanitize.ps1')

. (Join-Path $sharedDir 'New-WrapperEnvelope.ps1')
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
$errorsPath = Join-Path $sharedDir 'Errors.ps1'
if (Test-Path $errorsPath) { . $errorsPath }
if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) {
    function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } }
}
if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) {
    function Format-FindingErrorMessage {
        param([Parameter(Mandatory)]$FindingError)
        $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason
        if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" }
        return $line
    }
}
$installerPath = Join-Path $sharedDir 'Installer.ps1'
if (Test-Path $installerPath) { . $installerPath }
if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue)) {
    function Invoke-WithTimeout {
        param([string]$Command, [string[]]$Arguments, [int]$TimeoutSec = 300)
        $stdout = & $Command @Arguments 2>&1 | Out-String
        [PSCustomObject]@{ ExitCode = $LASTEXITCODE; Output = $stdout }
    }
}

function Invoke-GhApi {
    param (
        [Parameter(Mandatory)]
        [string] $Endpoint
    )

    Invoke-WithRetry -ScriptBlock {
        $result = Invoke-WithTimeout -Command 'gh' -Arguments @('api', $Endpoint) -TimeoutSec 300
        if ($result.ExitCode -ne 0) {
            $sanitizedOutput = Remove-Credentials -Text ([string]$result.Output)
            $reason = "gh api $Endpoint failed (exit $($result.ExitCode))"
            # Keep transient hints (HTTP 429, rate limit) in Reason so Invoke-WithRetry can classify,
            # but don't embed the full CLI output here - that goes in Details only.
            if ($sanitizedOutput -match '\b(408|429|503|504)\b') {
                $reason += "; HTTP $($Matches[1])"
            } elseif ($sanitizedOutput -match '(?i)(rate limit|throttl|timed out|timeout|service unavailable|temporarily unavailable)') {
                $reason += '; transient response'
            }
            throw (Format-FindingErrorMessage (New-FindingError `
                -Source 'wrapper:gh-actions-billing' `
                -Category 'UnexpectedFailure' `
                -Reason $reason `
                -Remediation 'Verify gh auth status and that the endpoint is correct.' `
                -Details $sanitizedOutput))
        }
        $text = [string]$result.Output
        if ([string]::IsNullOrWhiteSpace($text)) {
            return [PSCustomObject]@{}
        }
        return $text | ConvertFrom-Json -Depth 100
    }
}

function Get-GhToolVersion {
    try {
        $versionResult = Invoke-WithTimeout -Command 'gh' -Arguments @('--version') -TimeoutSec 300
        if ($versionResult.ExitCode -ne 0) { return 'unknown' }
        $versionText = if ($versionResult.Output -is [array]) { ($versionResult.Output -join ' ') } else { [string]$versionResult.Output }
        $match = [regex]::Match($versionText, 'gh version\s+([0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9\.-]+)?)')
        if ($match.Success) { return $match.Groups[1].Value }
        $trimmed = $versionText.Trim()
        if (-not [string]::IsNullOrWhiteSpace($trimmed)) { return $trimmed }
        return 'unknown'
    } catch {
        return 'unknown'
    }
}

function Convert-ToRunnerTag {
    param (
        [object] $Run,
        [string] $Fallback = 'ubuntu'
    )

    $candidates = [System.Collections.Generic.List[string]]::new()
    $labels = if ($Run -and $Run.PSObject.Properties['labels']) { @($Run.labels) } else { @() }
    foreach ($label in $labels) { if ($label) { $candidates.Add(([string]$label).ToLowerInvariant()) | Out-Null } }
    foreach ($propertyName in @('name', 'display_title', 'path', 'runner_name', 'runner_group_name')) {
        if ($Run -and $Run.PSObject.Properties[$propertyName] -and $Run.$propertyName) {
            $candidates.Add(([string]$Run.$propertyName).ToLowerInvariant()) | Out-Null
        }
    }

    foreach ($candidate in $candidates) {
        if ($candidate -match 'macos|mac') { return 'runner:macos' }
        if ($candidate -match 'windows|win') { return 'runner:windows' }
        if ($candidate -match 'ubuntu|linux') { return 'runner:ubuntu' }
    }

    switch -Regex ($Fallback.ToLowerInvariant()) {
        'mac' { return 'runner:macos' }
        'win' { return 'runner:windows' }
        default { return 'runner:ubuntu' }
    }
}

function Get-OrgRunnerTag {
    param ([object]$Billing)

    $breakdown = if ($Billing -and $Billing.PSObject.Properties['minutes_used_breakdown']) { $Billing.minutes_used_breakdown } else { $null }
    if (-not $breakdown -and $Billing -and $Billing.PSObject.Properties['included_minutes_used_breakdown']) {
        $breakdown = $Billing.included_minutes_used_breakdown
    }
    if (-not $breakdown) { return 'runner:ubuntu' }

    $ubuntu = [double]($breakdown.UBUNTU ?? $breakdown.ubuntu ?? 0)
    $windows = [double]($breakdown.WINDOWS ?? $breakdown.windows ?? 0)
    $macos = [double]($breakdown.MACOS ?? $breakdown.macos ?? 0)
    if ($windows -ge $ubuntu -and $windows -ge $macos) { return 'runner:windows' }
    if ($macos -ge $ubuntu -and $macos -ge $windows) { return 'runner:macos' }
    return 'runner:ubuntu'
}

function Resolve-ImpactLevel {
    param (
        [double] $PaidQuotaRatio = 0.0,
        [double] $BudgetOverrunUsd = 0.0,
        [double] $MinuteDelta = 0.0
    )

    if ($PaidQuotaRatio -gt 0.5 -or $BudgetOverrunUsd -gt 500 -or $MinuteDelta -gt 180) { return 'High' }
    if ($PaidQuotaRatio -gt 0.2 -or $BudgetOverrunUsd -gt 100 -or $MinuteDelta -gt 60) { return 'Medium' }
    return 'Low'
}

function Get-RepoRunMinutes {
    param (
        [Parameter(Mandatory)]
        [string] $OrgName,
        [Parameter(Mandatory)]
        [string] $RepoName,
        [Parameter(Mandatory)]
        [datetime] $SinceUtc
    )

    $sinceDate = $SinceUtc.ToString('yyyy-MM-dd')
    $createdFilter = [uri]::EscapeDataString(">=$sinceDate")
    $runsResponse = Invoke-GhApi -Endpoint "repos/$OrgName/$RepoName/actions/runs?per_page=100&created=$createdFilter"
    $runs = if ($runsResponse -and $runsResponse.PSObject.Properties['workflow_runs']) { @($runsResponse.workflow_runs) } else { @() }

    $durations = [System.Collections.Generic.List[double]]::new()
    foreach ($run in $runs) {
        $minutes = 0.0
        if ($run.PSObject.Properties['run_duration_ms'] -and $run.run_duration_ms) {
            $minutes = [math]::Round(([double]$run.run_duration_ms / 60000.0), 2)
        } elseif ($run.PSObject.Properties['run_started_at'] -and $run.PSObject.Properties['updated_at'] -and $run.run_started_at -and $run.updated_at) {
            try {
                $start = [datetime]$run.run_started_at
                $finish = [datetime]$run.updated_at
                if ($finish -gt $start) {
                    $minutes = [math]::Round(($finish - $start).TotalMinutes, 2)
                }
            } catch {
                $minutes = 0.0
            }
        }
        if ($minutes -gt 0) {
            $durations.Add($minutes)
        }
    }

    $total = if ($durations.Count -gt 0) { [math]::Round(($durations | Measure-Object -Sum).Sum, 2) } else { 0.0 }
    $avg = if ($durations.Count -gt 0) { [math]::Round(($durations | Measure-Object -Average).Average, 2) } else { 0.0 }
    return [PSCustomObject]@{
        Runs      = $runs
        Durations = @($durations)
        Total     = $total
        Average   = $avg
    }
}

if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
    return [PSCustomObject]@{
        Source   = 'gh-actions-billing'
        Status   = 'Skipped'
        Message  = 'gh CLI not installed. Install GitHub CLI and authenticate with gh auth login.'
        Findings = @()
        Errors   = @()
    }
}

try {
    $sinceUtc = (Get-Date).ToUniversalTime().AddDays(-1 * $DaysBack)
    $billing = Invoke-GhApi -Endpoint "orgs/$Org/settings/billing/actions"
    $toolVersion = Get-GhToolVersion

    $repos = @()
    if ($Repo) {
        $repos = @([PSCustomObject]@{ name = $Repo; full_name = "$Org/$Repo"; owner = [PSCustomObject]@{ login = $Org } })
    } else {
        $repoResponse = Invoke-GhApi -Endpoint "orgs/$Org/repos?per_page=100&type=all"
        $repos = @($repoResponse)
    }

    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()
    $repoUsage = [System.Collections.Generic.List[PSCustomObject]]::new()
    $billingUrl = "https://github.com/organizations/$Org/billing"

    $includedMinutes = 0.0
    if ($billing.PSObject.Properties['included_minutes']) { $includedMinutes = [double]$billing.included_minutes }
    $includedUsed = 0.0
    if ($billing.PSObject.Properties['included_minutes_used']) { $includedUsed = [double]$billing.included_minutes_used }
    elseif ($billing.PSObject.Properties['total_minutes_used']) { $includedUsed = [double]$billing.total_minutes_used }
    $paidMinutes = 0.0
    if ($billing.PSObject.Properties['total_paid_minutes_used']) { $paidMinutes = [double]$billing.total_paid_minutes_used }
    $paidQuotaRatio = 0.0
    $totalMinutesUsed = [math]::Max(($includedUsed + $paidMinutes), 0.0)
    if ($totalMinutesUsed -gt 0) {
        $paidQuotaRatio = [math]::Round(($paidMinutes / $totalMinutesUsed), 4)
    } elseif ($includedMinutes -gt 0) {
        $paidQuotaRatio = [math]::Round(($paidMinutes / $includedMinutes), 4)
    }
    $orgRunnerTag = Get-OrgRunnerTag -Billing $billing

    if ($includedMinutes -gt 0 -and $includedUsed -gt $includedMinutes) {
        $scoreDelta = [math]::Round(($includedUsed - $includedMinutes), 2)
        $findings.Add([PSCustomObject]@{
                Id           = [guid]::NewGuid().ToString()
                Source       = 'gh-actions-billing'
                RuleId       = 'GHA-PaidMinutesExceeded'
                Category     = 'Cost'
                Title        = "Org '$Org' exceeded included GitHub Actions minutes"
                Compliant    = $false
                Severity     = 'High'
                Detail       = "Included minutes used $includedUsed exceeds included minutes $includedMinutes."
                Remediation  = 'Review high-minute repositories, optimize workflow runtime, and move heavy workloads to self-hosted runners where appropriate.'
                ResourceId   = "github.com/$($Org.ToLowerInvariant())/_org-billing"
                LearnMoreUrl = $billingUrl
                Pillar       = 'Cost Optimization'
                ScoreDelta   = $scoreDelta
                Impact       = Resolve-ImpactLevel -PaidQuotaRatio $paidQuotaRatio -MinuteDelta $scoreDelta
                Effort       = 'Low'
                DeepLinkUrl  = $billingUrl
                EvidenceUris = @($billingUrl)
                BaselineTags = @('GHA-PaidMinutesExceeded', $orgRunnerTag)
                EntityRefs   = @("org:$Org")
                RemediationSnippets = @(
                    @{
                        language = 'yaml'
                        before   = "runs-on: windows-latest`nstrategy:`n matrix:`n os: [ubuntu-latest, windows-latest]"
                        after    = "runs-on: ubuntu-latest`nstrategy:`n matrix:`n os: [ubuntu-latest]"
                    }
                )
                ToolVersion  = $toolVersion
                SchemaVersion = '1.0'
            })
    }

    foreach ($repoItem in $repos) {
        if (-not $repoItem) { continue }
        $repoName = if ($repoItem.PSObject.Properties['name'] -and $repoItem.name) { [string]$repoItem.name } else { '' }
        if (-not $repoName) { continue }
        $ownerName = if ($repoItem.PSObject.Properties['owner'] -and $repoItem.owner -and $repoItem.owner.PSObject.Properties['login']) { [string]$repoItem.owner.login } else { $Org }
        $usage = Get-RepoRunMinutes -OrgName $ownerName -RepoName $repoName -SinceUtc $sinceUtc
        $repoUsage.Add([PSCustomObject]@{
                Org      = $ownerName
                Repo     = $repoName
                Total    = $usage.Total
                Average  = $usage.Average
                Runs     = $usage.Runs
            })

        foreach ($run in @($usage.Runs)) {
            $runMinutes = 0.0
            if ($run.PSObject.Properties['run_duration_ms'] -and $run.run_duration_ms) {
                $runMinutes = [math]::Round(([double]$run.run_duration_ms / 60000.0), 2)
            } elseif ($run.PSObject.Properties['run_started_at'] -and $run.PSObject.Properties['updated_at'] -and $run.run_started_at -and $run.updated_at) {
                try {
                    $start = [datetime]$run.run_started_at
                    $finish = [datetime]$run.updated_at
                    if ($finish -gt $start) { $runMinutes = [math]::Round(($finish - $start).TotalMinutes, 2) }
                } catch {
                    $runMinutes = 0.0
                }
            }
            if ($runMinutes -le 60) { continue }

            $baseline = $usage.Average
            if ($usage.Durations.Count -gt 1) {
                $other = @($usage.Durations | Where-Object { $_ -ne $runMinutes } | Select-Object -First ($usage.Durations.Count - 1))
                if (@($other).Count -gt 0) {
                    $baseline = [math]::Round((@($other) | Measure-Object -Average).Average, 2)
                }
            }
            if ($baseline -gt 0 -and $runMinutes -le ($baseline * 2.0)) { continue }

            $runId = if ($run.PSObject.Properties['id']) { [string]$run.id } else { 'unknown' }
            $runUrl = if ($run.PSObject.Properties['html_url'] -and $run.html_url) { [string]$run.html_url } else { "https://github.com/$ownerName/$repoName/actions" }
            $workflowPath = if ($run.PSObject.Properties['path'] -and $run.path) { [string]$run.path } else { ".github/workflows/ci.yml" }
            $workflowRef = if ($run.PSObject.Properties['head_branch'] -and $run.head_branch) { [string]$run.head_branch } else { 'HEAD' }
            $workflowUrl = "https://github.com/$ownerName/$repoName/blob/$workflowRef/$workflowPath"
            $runScoreDelta = [math]::Round([math]::Max(($runMinutes - $baseline), 0.0), 2)
            $runnerTag = Convert-ToRunnerTag -Run $run
            $workflowId = if ($run.PSObject.Properties['workflow_id'] -and $run.workflow_id) { [string]$run.workflow_id } else { '' }
            $findings.Add([PSCustomObject]@{
                    Id           = [guid]::NewGuid().ToString()
                    Source       = 'gh-actions-billing'
                    RuleId       = 'GHA-RunAnomaly'
                    Category     = 'Cost'
                    Title        = "Workflow run anomaly in $ownerName/$repoName"
                    Compliant    = $false
                    Severity     = 'Low'
                    Detail       = "Run $runId consumed $runMinutes minutes; comparison baseline is $baseline minutes."
                    Remediation  = 'Inspect the workflow run logs and optimize slow jobs, cache misses, and redundant test matrices.'
                    ResourceId   = "github.com/$($ownerName.ToLowerInvariant())/$($repoName.ToLowerInvariant())"
                    LearnMoreUrl = $runUrl
                    Pillar       = 'Cost Optimization'
                    ScoreDelta   = $runScoreDelta
                    Impact       = Resolve-ImpactLevel -PaidQuotaRatio $paidQuotaRatio -MinuteDelta $runScoreDelta
                    Effort       = 'Low'
                    DeepLinkUrl  = $runUrl
                    EvidenceUris = @($billingUrl, $runUrl, $workflowUrl)
                    BaselineTags = @('GHA-RunAnomaly', $runnerTag)
                    EntityRefs   = @("org:$ownerName", "repo:$ownerName/$repoName") + $(if ($workflowId) { @("workflow:$workflowId") } else { @() })
                    RemediationSnippets = @(
                        @{
                            language = 'yaml'
                            before   = "runs-on: windows-latest`nstrategy:`n matrix:`n shard: [1,2,3,4,5,6]"
                            after    = "runs-on: ubuntu-latest`nstrategy:`n matrix:`n shard: [1,2,3]"
                        }
                    )
                    ToolVersion  = $toolVersion
                    SchemaVersion = '1.0'
                })
        }
    }

    $topConsumers = @($repoUsage | Sort-Object Total -Descending | Select-Object -First 5)
    foreach ($consumer in $topConsumers) {
        if ($consumer.Total -le 0) { continue }
        $repoUsageUrl = "https://github.com/$($consumer.Org)/$($consumer.Repo)/actions"
        $repoShareRatio = if ($totalMinutesUsed -gt 0) { [math]::Round(($consumer.Total / $totalMinutesUsed), 4) } else { 0.0 }
        $findings.Add([PSCustomObject]@{
                Id           = [guid]::NewGuid().ToString()
                Source       = 'gh-actions-billing'
                RuleId       = 'GHA-TopConsumer'
                Category     = 'Cost'
                Title        = "Top GitHub Actions minute consumer: $($consumer.Org)/$($consumer.Repo)"
                Compliant    = $false
                Severity     = 'Medium'
                Detail       = "Repository used $($consumer.Total) runner minutes in the last $DaysBack day(s)."
                Remediation  = 'Review matrix size, unnecessary workflow triggers, and long-running jobs.'
                ResourceId   = "github.com/$($consumer.Org.ToLowerInvariant())/$($consumer.Repo.ToLowerInvariant())"
                LearnMoreUrl = $repoUsageUrl
                Pillar       = 'Cost Optimization'
                ScoreDelta   = [math]::Round([double]$consumer.Total, 2)
                Impact       = Resolve-ImpactLevel -PaidQuotaRatio ([math]::Max($paidQuotaRatio, $repoShareRatio)) -MinuteDelta $consumer.Total
                Effort       = 'Low'
                DeepLinkUrl  = $repoUsageUrl
                EvidenceUris = @($billingUrl, $repoUsageUrl)
                BaselineTags = @('GHA-TopConsumer', 'runner:ubuntu')
                EntityRefs   = @("org:$($consumer.Org)", "repo:$($consumer.Org)/$($consumer.Repo)")
                RemediationSnippets = @(
                    @{
                        language = 'yaml'
                        before   = "runs-on: ubuntu-latest`non:`n schedule:`n - cron: '*/5 * * * *'"
                        after    = "runs-on: ubuntu-latest`non:`n schedule:`n - cron: '0 */2 * * *'"
                    }
                )
                ToolVersion  = $toolVersion
                SchemaVersion = '1.0'
            })
    }

    if ($PSBoundParameters.ContainsKey('MonthlyBudgetUsd') -and $MonthlyBudgetUsd -gt 0) {
        $estimatedSpend = [math]::Round(($paidMinutes * 0.008), 2)
        if ($estimatedSpend -gt $MonthlyBudgetUsd) {
            $scoreDelta = [math]::Round(($estimatedSpend - $MonthlyBudgetUsd), 2)
            $findings.Add([PSCustomObject]@{
                    Id           = [guid]::NewGuid().ToString()
                    Source       = 'gh-actions-billing'
                    RuleId       = 'GHA-BudgetOverage'
                    Category     = 'Cost'
                    Title        = "Org '$Org' estimated Actions spend exceeded monthly budget"
                    Compliant    = $false
                    Severity     = 'High'
                    Detail       = "Estimated spend $$estimatedSpend exceeded configured budget $$MonthlyBudgetUsd based on paid minutes ($paidMinutes) at 0.008 USD per minute."
                    Remediation  = 'Set stricter workflow concurrency limits, reduce paid runner use, and enforce workflow ownership review.'
                    ResourceId   = "github.com/$($Org.ToLowerInvariant())/_org-billing"
                    LearnMoreUrl = $billingUrl
                    Pillar       = 'Cost Optimization'
                    ScoreDelta   = $scoreDelta
                    Impact       = Resolve-ImpactLevel -PaidQuotaRatio $paidQuotaRatio -BudgetOverrunUsd $scoreDelta
                    Effort       = 'Low'
                    DeepLinkUrl  = $billingUrl
                    EvidenceUris = @($billingUrl)
                    BaselineTags = @('GHA-BudgetOverage', $orgRunnerTag)
                    EntityRefs   = @("org:$Org")
                    RemediationSnippets = @(
                        @{
                            language = 'yaml'
                            before   = "strategy:`n matrix:`n node: [18,20,22]"
                            after    = "strategy:`n matrix:`n node: [20]"
                        }
                    )
                    ToolVersion  = $toolVersion
                    SchemaVersion = '1.0'
                })
        }
    }

    return [PSCustomObject]@{
        Source   = 'gh-actions-billing'
        Status   = 'Success'
        Message  = Remove-Credentials "Scanned $($repos.Count) repo(s); produced $($findings.Count) GitHub Actions cost finding(s)."
        Findings = @($findings)
        Errors   = @()
    }
} catch {
    $msg = Remove-Credentials ([string]$_.Exception.Message)
    Write-Warning "GitHub Actions billing scan failed: $msg"
    return [PSCustomObject]@{
        Source   = 'gh-actions-billing'
        Status   = 'Failed'
        Message  = $msg
        Findings = @()
        Errors   = @()
    }
}