lib/rules/marketplace/MarketplaceRulesHelper.ps1

#############################################################################
# MarketplaceRulesHelper.ps1 - Helper functions for marketplace validation rules
#############################################################################
# This module provides shared helper functions used by marketplace validation rules.
#############################################################################

<#
.SYNOPSIS
Converts an action name to a GitHub Marketplace URL slug.

.DESCRIPTION
GitHub Marketplace uses a URL-friendly slug derived from the action's name property.
The slug is lowercase with spaces replaced by hyphens and special characters removed.

.PARAMETER ActionName
The action name from action.yaml (e.g., "Actions SemVer Checker")

.OUTPUTS
The marketplace URL slug (e.g., "actions-semver-checker")
#>

function ConvertTo-MarketplaceSlug {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$ActionName
    )
    
    # Convert to lowercase, replace spaces with hyphens, remove non-alphanumeric except hyphens
    $slug = $ActionName.ToLower() -replace '\s+', '-' -replace '[^a-z0-9\-]', ''
    # Remove consecutive hyphens and trim leading/trailing hyphens
    $slug = $slug -replace '-+', '-' -replace '^-|-$', ''
    
    return $slug
}

<#
.SYNOPSIS
Tests if a specific version of an action is published to GitHub Marketplace.

.DESCRIPTION
Queries the public GitHub Marketplace URL to check if a specific version
of an action has been published. The marketplace URL is:
https://github.com/marketplace/actions/{slug}?version={version}

When a version is published, the page shows "Use {version}" in the UI.
When a version is not published, it falls back to "Use latest version".

.PARAMETER ActionName
The action name from action.yaml (e.g., "Actions SemVer Checker")

.PARAMETER Version
The version tag to check (e.g., "v2.0.0")

.PARAMETER ServerUrl
The GitHub server URL (default: https://github.com). Used for GitHub Enterprise Server.

.OUTPUTS
Returns a PSCustomObject with:
- IsPublished: $true if the version is published to the marketplace
- MarketplaceUrl: The full marketplace URL for this version
- Error: Error message if the check failed, $null otherwise
#>

function Test-MarketplaceVersionPublished {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [string]$ActionName,
        
        [Parameter(Mandatory)]
        [string]$Version,
        
        [string]$ServerUrl = "https://github.com"
    )
    
    $slug = ConvertTo-MarketplaceSlug -ActionName $ActionName
    $marketplaceUrl = "$ServerUrl/marketplace/actions/$slug"
    $versionUrl = "$marketplaceUrl`?version=$Version"
    
    try {
        # Use Invoke-WebRequestWrapper if available (for testability)
        $response = $null
        if (Get-Command Invoke-WebRequestWrapper -ErrorAction SilentlyContinue) {
            $response = Invoke-WebRequestWrapper -Uri $versionUrl -Method Get -ErrorAction Stop -TimeoutSec 10
        } else {
            $response = Invoke-WebRequest -Uri $versionUrl -Method Get -ErrorAction Stop -TimeoutSec 10
        }
        
        # Get content - handle both raw response and wrapped response
        $content = if ($response.Content) { $response.Content } else { $response }
        
        # Primary method: Parse the embedded JSON data from the React app
        # The page contains: <script type="application/json" data-target="react-app.embeddedData">
        # with releaseData.releases[] containing all published versions
        $isPublished = $false
        
        if ($content -match '<script[^>]+data-target="react-app\.embeddedData"[^>]*>([^<]+)</script>') {
            try {
                $embeddedJson = $Matches[1]
                $embeddedData = $embeddedJson | ConvertFrom-Json
                $releases = $embeddedData.payload.releaseData.releases
                
                if ($releases) {
                    # Check if the version appears in the releases array
                    $isPublished = ($releases | Where-Object { $_.tagName -eq $Version }).Count -gt 0
                }
            }
            catch {
                Write-Host "::debug::Failed to parse embedded JSON: $_"
            }
        }
        
        return [PSCustomObject]@{
            IsPublished = $isPublished
            MarketplaceUrl = $versionUrl
            Error = $null
        }
    }
    catch {
        $statusCode = $null
        if ($_.Exception.Response) {
            $statusCode = [int]$_.Exception.Response.StatusCode
        }
        
        # If we get a 404, the action is not in the marketplace at all
        if ($statusCode -eq 404) {
            return [PSCustomObject]@{
                IsPublished = $false
                MarketplaceUrl = $marketplaceUrl
                Error = "Action is not published to the GitHub Marketplace"
            }
        }
        
        # For other errors, return with error message but don't fail
        return [PSCustomObject]@{
            IsPublished = $null  # Unknown
            MarketplaceUrl = $marketplaceUrl
            Error = "Failed to check marketplace: $($_.Exception.Message)"
        }
    }
}

<#
.SYNOPSIS
Fetches and parses action.yaml/action.yml marketplace metadata from the repository.

.DESCRIPTION
Attempts to fetch action.yaml first, then falls back to action.yml.
Parses the YAML content to extract marketplace-required fields:
- name
- description
- branding.icon
- branding.color

Also checks for README.md existence.

.PARAMETER State
The RepositoryState object containing API configuration.

.PARAMETER Ref
Optional. The commit, branch, or tag to get the metadata from. Defaults to the default branch.

.OUTPUTS
Returns a MarketplaceMetadata object with the parsed information.
#>

function Get-ActionMarketplaceMetadata {
    param(
        [Parameter(Mandatory)]
        [RepositoryState]$State,
        
        [string]$Ref
    )
    
    $metadata = [MarketplaceMetadata]::new()
    
    # Try to fetch action.yaml first, then action.yml
    $actionContent = $null
    $actionPath = $null
    
    foreach ($path in @('action.yaml', 'action.yml')) {
        Write-Host "::debug::Checking for $path..."
        $content = Get-GitHubFileContents -State $State -Path $path -Ref $Ref
        if ($content) {
            $actionContent = $content
            $actionPath = $path
            Write-Host "::debug::Found $path"
            break
        }
    }
    
    if ($actionContent -and $actionPath) {
        $metadata.ActionFileExists = $true
        $metadata.ActionFilePath = $actionPath
        
        # Parse YAML content to extract required fields
        # Note: Using simple regex-based parsing since PowerShell doesn't have built-in YAML support
        # This handles common YAML formats without requiring external modules
        
        # Extract 'name' property (top-level)
        if ($actionContent -match '(?m)^name:\s*[''"]?([^''"#\r\n]+)[''"]?') {
            $metadata.Name = $matches[1].Trim()
            $metadata.HasName = $metadata.Name.Length -gt 0
        } elseif ($actionContent -match '(?m)^name:\s*$') {
            # Empty name
            $metadata.HasName = $false
        }
        
        # Extract 'description' property (top-level)
        if ($actionContent -match '(?m)^description:\s*[''"]?([^''"#\r\n]+)[''"]?') {
            $metadata.Description = $matches[1].Trim()
            $metadata.HasDescription = $metadata.Description.Length -gt 0
        } elseif ($actionContent -match "(?m)^description:\s*['`"]\|") {
            # Multi-line description (folded or literal block)
            $metadata.HasDescription = $true
            $metadata.Description = "(multi-line)"
        }
        
        # Extract 'branding.icon' property
        # Split into lines and look for icon after branding section
        $lines = $actionContent -split '[\r\n]+'
        $inBranding = $false
        for ($i = 0; $i -lt $lines.Count; $i++) {
            if ($lines[$i] -match '^\s*branding:\s*$') {
                $inBranding = $true
                continue
            }
            if ($inBranding) {
                # Check if we're still in branding section (indented lines)
                if ($lines[$i] -match '^\s{2,}icon:\s*[''"]?([^''"#]+)[''"]?\s*$') {
                    $metadata.BrandingIcon = $matches[1].Trim()
                    $metadata.HasBrandingIcon = $metadata.BrandingIcon.Length -gt 0
                }
                elseif ($lines[$i] -match '^\s{2,}color:\s*[''"]?([^''"#]+)[''"]?\s*$') {
                    $metadata.BrandingColor = $matches[1].Trim()
                    $metadata.HasBrandingColor = $metadata.BrandingColor.Length -gt 0
                }
                elseif ($lines[$i] -match '^\S' -and $lines[$i] -notmatch '^\s*$') {
                    # Non-indented non-empty line = end of branding section
                    break
                }
            }
        }
    }
    
    # Check for README.md using directory listing (single API call with case-insensitive local match)
    $rootContents = Get-GitHubDirectoryContents -State $State -Ref $Ref
    $readmeFile = $rootContents | Where-Object { $_.Type -eq 'file' -and $_.Name -match '^readme(\.md)?$' } | Select-Object -First 1
    if ($readmeFile) {
        $metadata.ReadmeExists = $true
    }
    
    return $metadata
}