Private/Get-DFGitHubRepoInfo.ps1

#Requires -Version 7.0

# GitHub enrichment for trifle's -GitInfo: repo stats + latest release,
# fetched via gh (authenticated, 5000 req/hr) when available, anonymous REST
# (60 req/hr — fine for single lookups) otherwise. Cached through the catalog
# detail engine under catalogs/github/details/.

function Test-DFGitHubCli {
    <#
    .SYNOPSIS
        True when gh is on PATH and authenticated. Memoized per session in
        $script:DFGitHubCliOk.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param()

    if ($null -eq $script:DFGitHubCliOk) {
        $script:DFGitHubCliOk = $false
        if (Get-Command gh -ErrorAction Ignore) {
            $null = gh auth status 2>$null
            $script:DFGitHubCliOk = ($LASTEXITCODE -eq 0)
        }
    }
    $script:DFGitHubCliOk
}

function Invoke-DFGitHubApi {
    <#
    .SYNOPSIS
        Fetches one GitHub REST path ('repos/<o>/<r>', …) via gh or anonymous
        Invoke-RestMethod. Mockable seam; throws on failure.
    .PARAMETER Path
        API path relative to https://api.github.com/.
    .PARAMETER Accept
        Optional Accept header (e.g. 'application/vnd.github.raw' for readmes).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Path,

        [string]$Accept
    )

    if (Test-DFGitHubCli) {
        $ghArgs = @('api', $Path)
        if ($Accept) { $ghArgs += @('-H', "Accept: $Accept") }
        $raw = gh @ghArgs 2>$null
        if ($LASTEXITCODE -ne 0) { throw "gh api $Path failed" }
        $joined = $raw -join "`n"
        # Raw-media responses (readme) aren't JSON — return the text as-is.
        return ($Accept -like '*raw*') ? $joined : ($joined | ConvertFrom-Json)
    }

    $headers = @{ 'User-Agent' = 'DotForge PowerShell module (+https://github.com/simsrw73/DotForge)' }
    if ($Accept) { $headers['Accept'] = $Accept }
    Invoke-RestMethod -Uri "https://api.github.com/$Path" -Headers $headers -TimeoutSec 10
}

function Resolve-DFGitHubRepoUrl {
    <#
    .SYNOPSIS
        Resolves a merged ToolInfo to a GitHub owner/repo pair, checking each
        source detail's RepositoryUrl (canonical order) then the Homepage.
        Returns $null when nothing points at github.com.
    .PARAMETER Info
        A DotForge.ToolInfo (Details may be $null).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Info
    )

    $candidates = [System.Collections.Generic.List[string]]::new()
    if ($Info.Details) {
        foreach ($detail in $Info.Details.Values) {
            if ($detail -and $detail.RepositoryUrl) { $candidates.Add([string]$detail.RepositoryUrl) }
        }
    }
    if ($Info.Homepage) { $candidates.Add([string]$Info.Homepage) }

    foreach ($url in $candidates) {
        if ($url -match 'github\.com[/:]([^/]+)/([^/#?\s]+)') {
            return [pscustomobject]@{
                Owner = $Matches[1]
                Repo  = ($Matches[2] -replace '\.git$', '')
            }
        }
    }
    $null
}

function Get-DFGitHubRepoInfo {
    <#
    .SYNOPSIS
        Fetches (cache-first) GitHub repo stats + latest release as a
        DotForge.RepoInfo, or $null when the repo is unreachable.
    .PARAMETER Owner
        Repository owner.
    .PARAMETER Repo
        Repository name.
    .PARAMETER Fresh
        Force a live fetch.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Owner,

        [Parameter(Mandatory)]
        [string]$Repo,

        [switch]$Fresh
    )

    Get-DFCatalogDetailCache -Provider 'github' -PackageId "$Owner/$Repo" -Fresh:$Fresh -Fetch {
        param($id)
        $repoDoc = Invoke-DFGitHubApi -Path "repos/$id"

        $release = $null
        try { $release = Invoke-DFGitHubApi -Path "repos/$id/releases/latest" } catch {
            Write-Verbose "DotForge: no latest release for $id"
        }

        $toDate = { param($v) if ($v) { try { [datetime]$v } catch { $null } } }

        [pscustomobject]@{
            PSTypeName      = 'DotForge.RepoInfo'
            Owner           = ($id -split '/')[0]
            Repo            = ($id -split '/')[1]
            Stars           = [long]$repoDoc.stargazers_count
            OpenIssues      = [long]$repoDoc.open_issues_count
            PushedAt        = & $toDate $repoDoc.pushed_at
            LatestRelease   = [string]$release.tag_name
            LatestReleaseAt = & $toDate $release.published_at
            Archived        = [bool]$repoDoc.archived
            DefaultBranch   = [string]$repoDoc.default_branch
            Description     = [string]$repoDoc.description
            License         = [string]$repoDoc.license.spdx_id
        }
    }
}