Public/GitHub.ps1

# PSSnips — GitHub Gist integration.
# Functions that interact with the GitHub Gist API.
# All API calls require a GitHub personal access token with the 'gist' scope,
# set via Set-SnipConfig -GitHubToken or the $env:GITHUB_TOKEN environment variable.

function Get-GistList {
    <#
    .SYNOPSIS
        Lists GitHub Gists for the authenticated user or a specified GitHub username.
 
    .DESCRIPTION
        Calls the GitHub Gists API to retrieve a list of Gists and displays them in a
        formatted table showing the Gist ID, description, and file names. The number
        of results is controlled by -Count (default 30, max 100 per API call). Use
        -Filter to restrict results to Gists whose description or file names contain
        the given substring. Returns the raw API response objects for pipeline use.
 
    .PARAMETER Filter
        Optional. A substring to match against Gist descriptions and file names.
        Case-insensitive.
 
    .PARAMETER Count
        Optional. Maximum number of Gists to retrieve per API request. Default: 30.
 
    .PARAMETER Username
        Optional. Retrieve Gists for a different GitHub user. When omitted, defaults
        to the configured GitHubUsername, or the authenticated user's Gists.
 
    .EXAMPLE
        Get-GistList
 
        Lists the 30 most recent Gists for the configured user.
 
    .EXAMPLE
        Get-GistList -Filter 'deploy' -Count 50
 
        Lists up to 50 Gists whose description or file name contains 'deploy'.
 
    .EXAMPLE
        Get-GistList -Username octocat
 
        Lists public Gists for the GitHub user 'octocat'.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        System.Object[]
        Returns the deserialized Gist API response objects. Each object contains id,
        description, html_url, files, and other GitHub API fields.
 
    .NOTES
        Requires a GitHub PAT with the 'gist' scope.
        Set via: Set-SnipConfig -GitHubToken 'ghp_...' or $env:GITHUB_TOKEN
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [string]$Filter   = '',
        [uint32]$Count    = 30,
        [string]$Username = ''
    )
    script:InitEnv
    $cfg  = script:LoadCfg
    $user = if ($Username) { $Username } elseif ($cfg.GitHubUsername) { $cfg.GitHubUsername } else { '' }
    $p    = [GitHubProvider]::new((script:GetGitHubToken), $user)
    try {
        $gists = @($p.ListRemote($Filter))
    } catch { Write-Error "GitHub API error: $_" -ErrorAction Continue; return }
    if ([int]$Count -lt $gists.Count) { $gists = $gists[0..([int]$Count - 1)] }

    if (-not $gists) { script:Out-Info "No gists found."; return }

    Write-Host ""
    Write-Host (" {0,-34} {1,-38} {2}" -f 'GIST ID','DESCRIPTION','FILES') -ForegroundColor DarkCyan
    Write-Host " $('─' * 86)" -ForegroundColor DarkGray
    foreach ($g in $gists) {
        $files = ($g.files.PSObject.Properties.Name) -join ', '
        if ($files.Length -gt 30) { $files = $files.Substring(0,27) + '...' }
        $desc  = if ($g.description) { $g.description } else { '(no description)' }
        if ($desc.Length -gt 36) { $desc = $desc.Substring(0,33) + '...' }
        Write-Host (" {0,-34} " -f $g.id) -ForegroundColor DarkYellow -NoNewline
        Write-Host ("{0,-38} {1}" -f $desc, $files) -ForegroundColor Gray
    }
    Write-Host ""
    return $gists
}

function Get-Gist {
    <#
    .SYNOPSIS
        Displays the full content of a GitHub Gist including all its files.
 
    .DESCRIPTION
        Fetches a specific Gist from the GitHub API by ID and prints each file's
        content to the terminal with syntax-coloured headers. If a file is marked
        truncated in the API response, the raw_url is fetched separately to retrieve
        the full content. Returns the raw Gist API object for pipeline use.
 
    .PARAMETER GistId
        Mandatory. The GitHub Gist ID (32-character hex string) to retrieve.
 
    .EXAMPLE
        Get-Gist abc123def456abc123def456abc1234567
 
        Fetches and displays all files in the specified Gist.
 
    .EXAMPLE
        $gist = Get-Gist abc123def456abc123def456abc1234567
        $gist.html_url
 
        Retrieves the Gist object and accesses its HTML URL.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        System.Object
        Returns the deserialized Gist object from the GitHub API containing id,
        description, html_url, files, owner, and related metadata.
 
    .NOTES
        Requires a GitHub PAT with the 'gist' scope.
        Truncated file content (>1 MB) is fetched via an additional web request
        to the file's raw_url.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position=0, HelpMessage = 'GitHub Gist ID')]
        [ValidateNotNullOrEmpty()]
        [string]$GistId)
    $p = script:Get-RemoteProvider -Name 'GitHub'
    try { $gist = $p.GetRemoteById($GistId) }
    catch { Write-Error "Failed to fetch gist: $_" -ErrorAction Continue; return }

    Write-Host ""
    Write-Host " Gist: $GistId" -ForegroundColor Cyan
    if ($gist.description) { Write-Host " $($gist.description)" -ForegroundColor Gray }
    Write-Host " $($gist.html_url)" -ForegroundColor DarkCyan
    Write-Host ""

    foreach ($fn in $gist.files.PSObject.Properties.Name) {
        $f   = $gist.files.$fn
        $ext = [System.IO.Path]::GetExtension($fn).TrimStart('.')
        $c   = script:LangColor -ext $ext
        Write-Host " ── $fn ──" -ForegroundColor $c
        $body = if ($f.truncated) { (Invoke-RestMethod -Uri $f.raw_url).ToString() } else { $f.content }
        Write-Host $body
        Write-Host ""
    }
    return $gist
}

function Import-Gist {
    <#
    .SYNOPSIS
        Downloads a GitHub Gist and saves it as one or more local snippets.
 
    .DESCRIPTION
        Fetches the specified Gist from GitHub and writes each selected file to the
        configured SnippetsDir. The snippet language is inferred from the file
        extension. Multi-file Gists prompt interactively for which file to import
        unless -All is specified. If a snippet with the derived name already exists,
        a numeric suffix is appended to avoid collision (unless -Force is used).
        The Gist ID and URL are stored in the snippet's index metadata to enable
        future sync operations.
 
    .PARAMETER GistId
        Mandatory. The GitHub Gist ID to import.
 
    .PARAMETER Name
        Optional. Override the local snippet name. Only applies when importing a
        single file; ignored when -All is used.
 
    .PARAMETER FileName
        Optional. Imports only the specified file from a multi-file Gist.
 
    .PARAMETER All
        Optional switch. Imports all files from the Gist as separate snippets.
 
    .PARAMETER Force
        Optional switch. Overwrites existing snippets with the same name.
 
    .EXAMPLE
        Import-Gist abc123def456abc123def456abc1234567
 
        Imports the first (or only) file from the Gist as a local snippet.
 
    .EXAMPLE
        Import-Gist abc123def456abc123def456abc1234567 -Name my-local-name
 
        Imports the Gist and saves it with the local name 'my-local-name'.
 
    .EXAMPLE
        Import-Gist abc123def456abc123def456abc1234567 -All
 
        Imports every file in the Gist as individual snippets.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        None. Writes a confirmation message per imported snippet.
 
    .NOTES
        When -Name is not supplied, the snippet name is derived from the Gist
        file name (without extension). For multi-file imports with -All, each file
        is stored using its original file base name.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position=0, HelpMessage = 'GitHub Gist ID to import')]
        [ValidateNotNullOrEmpty()]
        [string]$GistId,
        [string]$Name      = '',
        [string]$FileName  = '',
        [switch]$All,
        [switch]$Force
    )
    script:InitEnv
    $cfg = script:LoadCfg
    $p   = script:Get-RemoteProvider -Name 'GitHub'
    try { $gist = $p.GetRemoteById($GistId) }
    catch { Write-Error "Failed to fetch gist: $_" -ErrorAction Continue; return }

    $fileNames = @($gist.files.PSObject.Properties.Name)

    # Multi-file prompt when needed
    if ($fileNames.Count -gt 1 -and -not $All -and -not $FileName) {
        Write-Host "`n Gist has $($fileNames.Count) files:" -ForegroundColor Cyan
        for ($i = 0; $i -lt $fileNames.Count; $i++) { Write-Host " [$i] $($fileNames[$i])" -ForegroundColor Gray }
        $choice = Read-Host "`n File number to import (or 'all')"
        if ($choice -eq 'all') { $All = $true } else { $FileName = $fileNames[[int]$choice] }
    }

    $toImport = if ($All) { $fileNames } elseif ($FileName) { @($FileName) } else { @($fileNames[0]) }
    $idx = script:LoadIdx

    foreach ($fn in $toImport) {
        $f        = $gist.files.$fn
        $ext      = [System.IO.Path]::GetExtension($fn).TrimStart('.')
        $snipName = if ($Name -and $toImport.Count -eq 1) { $Name } else { [System.IO.Path]::GetFileNameWithoutExtension($fn) }

        # Deduplicate name
        if ($idx.snippets.ContainsKey($snipName) -and -not $Force) {
            $base = $snipName; $n = 1
            while ($idx.snippets.ContainsKey($snipName)) { $snipName = "$base-$n"; $n++ }
        }

        $body = if ($f.truncated) { (Invoke-RestMethod -Uri $f.raw_url).ToString() } else { $f.content }
        Set-Content (Join-Path $cfg.SnippetsDir "$snipName.$ext") -Value $body -Encoding UTF8

        $importMeta = [SnippetMetadata]::new()
        $importMeta.Name        = $snipName
        $importMeta.Description = if ($gist.description) { $gist.description } else { '' }
        $importMeta.Language    = $ext
        $importMeta.GistId      = $GistId
        $importMeta.GistUrl     = if ($gist.html_url) { $gist.html_url } else { '' }
        $idx.snippets[$snipName] = $importMeta
        script:Out-OK "Imported '$snipName' ($ext)."
        script:Write-AuditLog -Operation 'Import' -SnippetName $snipName
    }
    script:SaveIdx -Idx $idx
}

function Export-Gist {
    <#
    .SYNOPSIS
        Exports a local snippet to GitHub as a new or updated Gist.
 
    .DESCRIPTION
        Reads the snippet file and its metadata, then creates a new GitHub Gist via
        POST or updates the existing linked Gist via PATCH. The decision is based on
        whether the snippet's 'gistId' field in the index is set. After a successful
        API call, the Gist ID and URL are written back to index.json so that future
        calls update the same Gist. New Gists are secret by default; use -Public to
        create a publicly visible Gist.
 
    .PARAMETER Name
        Mandatory. The name of the local snippet to export.
 
    .PARAMETER Description
        Optional. A description for the Gist. Falls back to the snippet's description,
        then the snippet name if not provided.
 
    .PARAMETER Public
        Optional switch. Creates a public Gist. Default is a secret Gist.
 
    .EXAMPLE
        Export-Gist my-snippet
 
        Creates a secret Gist from 'my-snippet' or updates the linked one.
 
    .EXAMPLE
        Export-Gist my-snippet -Description 'Handy deploy script' -Public
 
        Creates or updates a public Gist with a specific description.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        None. Writes the resulting Gist URL to the host on success.
 
    .NOTES
        Requires a GitHub PAT with the 'gist' scope.
        If the snippet has a gistId in the index, the Gist is updated (PATCH).
        If not, a new Gist is created (POST) and the ID is saved to the index.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position=0, HelpMessage = 'Name of the snippet to export as a Gist')]
        [ValidateNotNullOrEmpty()]
        [string]$Name,
        [string]$Description = '',
        [switch]$Public
    )
    script:InitEnv
    $idx = script:LoadIdx
    if (-not $idx.snippets.ContainsKey($Name)) { Write-Error "Snippet '$Name' not found." -ErrorAction Continue; return }

    $meta    = $idx.snippets[$Name]
    $path    = script:FindFile -Name $Name
    $content = Get-Content $path -Raw -Encoding UTF8
    $fn      = "$Name.$($meta.Language)"
    $desc    = if ($Description) { $Description } elseif ($meta.Description) { $meta.Description } else { $Name }

    $p = script:Get-RemoteProvider -Name 'GitHub'
    if (-not $p.IsConfigured()) { Write-Error "GitHub credentials not configured. Run Set-SnipConfig -GitHubToken <token>." -ErrorAction Continue; return }
    try {
        if ($meta.GistId) {
            $p.UpdateRemote($meta.GistId, $fn, $content)
            $gistUrl = if ($meta.GistUrl) { $meta.GistUrl } else { '' }
        } else {
            $result  = $p.CreateRemote($desc, $content, $meta.Language, -not $Public)
            $idx.snippets[$Name].GistId  = $result.Id
            $idx.snippets[$Name].GistUrl = $result.Url
            script:SaveIdx -Idx $idx
            $gistUrl = $result.Url
        }
        script:Out-OK "Gist $(if ($meta.GistId) {'updated'} else {'created'}): $gistUrl"
        script:Write-AuditLog -Operation 'Export' -SnippetName $Name
        script:Invoke-SnipEvent -EventName 'SnipPublished' -Data @{
            Name     = $Name
            Provider = 'github'
            Url      = $gistUrl
        }
    } catch { Write-Error "Failed to export gist: $_" -ErrorAction Continue }
}

function Invoke-Gist {
    <#
    .SYNOPSIS
        Downloads and executes a GitHub Gist file without saving it locally.
 
    .DESCRIPTION
        Fetches a Gist from GitHub, writes the selected file to a temporary path in
        $env:TEMP, executes it with the appropriate language runner, and then deletes
        the temporary file in a finally block. The runner selection follows the same
        logic as Invoke-Snip (ps1, py, js, bat/cmd, sh, rb, go). Supports -WhatIf
        via ShouldProcess — with -WhatIf the file is not written or executed.
        When the Gist has multiple files, the first file matching a known runnable
        extension is selected automatically; use -FileName to specify explicitly.
 
    .PARAMETER GistId
        Mandatory. The GitHub Gist ID to fetch and run.
 
    .PARAMETER FileName
        Optional. The specific file within the Gist to run. When omitted, the first
        file with a known runnable extension is selected.
 
    .PARAMETER ArgumentList
        Optional. Arguments forwarded to the language runner after the file path.
 
    .EXAMPLE
        Invoke-Gist abc123def456abc123def456abc1234567
 
        Fetches and executes the runnable file in the specified Gist.
 
    .EXAMPLE
        Invoke-Gist abc123def456abc123def456abc1234567 -FileName script.ps1
 
        Runs the named file from the Gist.
 
    .EXAMPLE
        Invoke-Gist abc123def456abc123def456abc1234567 -WhatIf
 
        Shows what would be executed without actually running it.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        Variable. Output depends on the language runner.
 
    .NOTES
        The temporary file is always deleted after execution (or on error) via a
        try/finally block. The temp file is placed in $env:TEMP with a random name.
        Requires a GitHub PAT with the 'gist' scope.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position=0, HelpMessage = 'GitHub Gist ID to run')]
        [ValidateNotNullOrEmpty()]
        [string]$GistId,
        [string]  $FileName    = '',
        [string[]]$ArgumentList = @()
    )
    $p = script:Get-RemoteProvider -Name 'GitHub'
    try { $gist = $p.GetRemoteById($GistId) }
    catch { Write-Error "Failed to fetch gist: $_" -ErrorAction Continue; return }

    $fileNames = @($gist.files.PSObject.Properties.Name)
    $target = if ($FileName) { $FileName }
              elseif ($fileNames.Count -eq 1) { $fileNames[0] }
              else { $fileNames | Where-Object { $_ -match '\.(ps1|py|js|bat|cmd|sh|rb|go)$' } | Select-Object -First 1 }

    if (-not $target) { Write-Error "Cannot determine runnable file. Use -FileName." -ErrorAction Continue; return }

    $f    = $gist.files.$target
    $ext  = [System.IO.Path]::GetExtension($target).TrimStart('.').ToLower()
    $body = if ($f.truncated) { (Invoke-RestMethod -Uri $f.raw_url).ToString() } else { $f.content }

    script:Out-Info "Running gist $GistId → $target"

    if ($PSCmdlet.ShouldProcess($target, "Execute gist file")) {
        $tmp = Join-Path $env:TEMP "pssnips_$([System.IO.Path]::GetRandomFileName()).$ext"
        try {
            Set-Content $tmp -Value $body -Encoding UTF8 -ErrorAction Stop
            # Template variable substitution
            $gistPhMatches = [regex]::Matches($body, '\{\{([A-Z0-9_]+)(?::([^}]*))?\}\}', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
            $gistPlaceholders = @($gistPhMatches | ForEach-Object { $_.Groups[1].Value.ToUpper() } | Select-Object -Unique)
            $gistPhDefaults = @{}
            foreach ($m in $gistPhMatches) {
                $phName = $m.Groups[1].Value.ToUpper()
                if ($m.Groups[2].Success -and -not $gistPhDefaults.ContainsKey($phName)) {
                    $gistPhDefaults[$phName] = $m.Groups[2].Value
                }
            }
            if ($gistPlaceholders.Count -gt 0) {
                $gistVarContent = Get-Content $tmp -Raw -Encoding UTF8
                foreach ($ph in $gistPlaceholders) {
                    $envVal = (Get-Item "env:$ph" -ErrorAction SilentlyContinue).Value
                    if ($envVal) {
                        $val = $envVal
                    } elseif ($gistPhDefaults.ContainsKey($ph) -and $gistPhDefaults[$ph] -ne '') {
                        $default = $gistPhDefaults[$ph]
                        $userInput = Read-Host " Value for {{$ph}} [$default]"
                        $val = if ($userInput -ne '') { $userInput } else { $default }
                    } else {
                        $val = Read-Host " Value for {{$ph}}"
                    }
                    $gistVarContent = $gistVarContent -replace "\{\{$ph(?::[^}]*)?\}\}", $val
                }
                Set-Content $tmp -Value $gistVarContent -Encoding UTF8
            }
            switch ($ext) {
                { $_ -in 'ps1','psm1' }  { & $tmp @ArgumentList }
                'py'  { $py = @('python','python3') | Where-Object { Get-Command $_ -EA 0 } | Select-Object -First 1; if ($py) { & $py $tmp @ArgumentList } }
                'js'  { if (Get-Command node -EA 0) { & node $tmp @ArgumentList } }
                { $_ -in 'bat','cmd' }   { & cmd /c $tmp @ArgumentList }
                'sh'  { if (Get-Command bash -EA 0) { & bash $tmp @ArgumentList } else { & wsl bash $tmp @ArgumentList } }
                'rb'  { if (Get-Command ruby -EA 0) { & ruby $tmp @ArgumentList } }
                'go'  { if (Get-Command go   -EA 0) { & go run $tmp @ArgumentList } }
                default { script:Out-Warn "No runner for '.$ext'. Saved to: $tmp"; return }
            }
        } finally {
            if (Test-Path $tmp) { Remove-Item $tmp -Force -EA SilentlyContinue }
        }
    }
}

function Sync-Gist {
    <#
    .SYNOPSIS
        Synchronises a local snippet with its linked GitHub Gist (pull or push).
 
    .DESCRIPTION
        Bi-directional sync between a local snippet and the GitHub Gist it was
        linked to via Export-Gist or Import-Gist. By default (pull mode) the local
        snippet file is overwritten with the latest content from GitHub. With -Push,
        the local file's current content is uploaded to GitHub, updating the Gist.
        The snippet must already have a linked gistId; run Export-Gist first to
        establish the link.
 
    .PARAMETER Name
        Mandatory. The name of the local snippet to synchronise.
 
    .PARAMETER Push
        Optional switch. Pushes the local snippet content to GitHub (update Gist).
        Without this switch, the default is to pull (download from GitHub).
 
    .EXAMPLE
        Sync-Gist my-snippet
 
        Pulls the latest Gist content from GitHub into the local snippet file.
 
    .EXAMPLE
        Sync-Gist my-snippet -Push
 
        Uploads the current local snippet content to the linked GitHub Gist.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        None. Writes a status message to the host.
 
    .NOTES
        Pull mode calls Import-Gist -Force which overwrites the local file.
        Push mode calls Export-Gist which PATCHes the existing Gist.
        The snippet must have a non-null gistId in the index. If not, an error
        is displayed directing the user to run Export-Gist first.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position=0, HelpMessage = 'Name of the snippet to sync with its Gist')]
        [ValidateNotNullOrEmpty()]
        [string]$Name,
        [switch]$Push
    )
    script:InitEnv
    $idx = script:LoadIdx
    if (-not $idx.snippets.ContainsKey($Name)) { Write-Error "Snippet '$Name' not found." -ErrorAction Continue; return }
    if (-not $idx.snippets[$Name].GistId)      { Write-Error "'$Name' has no linked gist. Run Export-Gist first." -ErrorAction Continue; return }
    if ($Push) { Export-Gist -Name $Name }
    else       { Import-Gist -GistId $idx.snippets[$Name].GistId -Name $Name -Force }
}