Scripts/Compare-DocsFIDOChanges.ps1

<#
.SYNOPSIS
Compares FIDO vendor table changes between GitHub commits and generates markdown reports.

.DESCRIPTION
Fetches the Microsoft Entra FIDO hardware vendor documentation, detects changes between commits,
and generates a timestamped markdown file with Added/Modified/Removed keys. Supports comparing
against local Assets/FidoKeys.json for validation. Implements state tracking to avoid reprocessing
and automatically detects multiple commits between runs.

.PARAMETER Owner
GitHub repository owner (default: MicrosoftDocs)

.PARAMETER Repo
GitHub repository name (default: entra-docs)

.PARAMETER Commit
Specific commit SHA to compare (auto-detected if omitted)

.PARAMETER ParentCommit
Parent commit SHA (auto-detected if omitted)

.PARAMETER FilePath
Path to vendor table in repo (default: docs/identity/authentication/concept-fido2-hardware-vendor.md)

.PARAMETER LatestMeaningful
Skip metadata-only commits and find the latest commit with actual table changes

.PARAMETER FIDODiff
Compare changes against local Assets/FidoKeys.json and show Match/Mismatch/Missing status

.PARAMETER ForceDiff
Force reprocessing even if commit was already processed (testing use)

.EXAMPLE
./Compare-DocsFIDOChanges.ps1 -LatestMeaningful -FIDODiff
Finds latest meaningful commit, compares against local data, generates timestamped report

.EXAMPLE
./Compare-DocsFIDOChanges.ps1 -ForceDiff
Reprocess latest commit, ignoring state file

.NOTES
Generates FIDO_KEY_CHANGES_<timestamp>[_<sha>][_comparediff].md in workspace root.
Maintains .fido_diff_state.json for state tracking across runs.
#>

param(
    [string]$Owner = 'MicrosoftDocs',
    [string]$Repo = 'entra-docs',
    [string]$Commit,
    [string]$ParentCommit,
    [string]$FilePath = 'docs/identity/authentication/concept-fido2-hardware-vendor.md',
    [string]$OutputMarkdown,
    [switch]$LatestMeaningful,
    [switch]$FIDODiff,
    [switch]$ForceDiff
)

# Generate timestamped filename if not specified
if (-not $OutputMarkdown) {
    $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss'
    $rootPath = Split-Path $PSScriptRoot -Parent
    $suffix = $FIDODiff ? '_comparediff' : ''
    $OutputMarkdown = Join-Path $rootPath "FIDO_KEY_CHANGES_${timestamp}${suffix}.md"
}

function Get-LatestCommit {
    param([string]$Owner,[string]$Repo,[string]$Path)
    $url = "https://api.github.com/repos/$Owner/$Repo/commits?path=$Path&per_page=1"
    $headers = @{ 'User-Agent' = 'EntraFIDOFinder' }
    try {
        $resp = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop
        if ($resp -and $resp.Count -gt 0) {
            return $resp[0].sha
        }
        throw "No commits found for path: $Path"
    } catch {
        throw "Failed to get latest commit: $_"
    }
}

function Get-MeaningfulCommit {
    param([string]$Owner,[string]$Repo,[string]$Path,[int]$Scan = 20)
    $url = "https://api.github.com/repos/$Owner/$Repo/commits?path=$Path&per_page=$Scan"
    $headers = @{ 'User-Agent' = 'EntraFIDOFinder' }
    $commits = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop
    if (-not $commits) { throw 'No commits returned when scanning for meaningful changes.' }

    foreach ($c in $commits) {
        $sha = $c.sha
        $parentSha = $c.parents[0].sha
        $newContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $sha -Path $Path
        $oldContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $parentSha -Path $Path
        try {
            $oldRows = Parse-Table -content $oldContent
            $newRows = Parse-Table -content $newContent
        } catch {
            continue
        }
        $comparison = Compare-Entries -old $oldRows -new $newRows
        if ($comparison.Added.Count -or $comparison.Removed.Count -or $comparison.Modified.Count) {
            return @{ Commit = $sha; Parent = $parentSha; Comparison = $comparison }
        }
    }
    throw "No meaningful table changes found in the last $Scan commits."
}

function Get-ParentCommit {
    param([string]$Owner,[string]$Repo,[string]$Commit)
    $url = "https://api.github.com/repos/$Owner/$Repo/commits/$Commit"
    $headers = @{ 'User-Agent' = 'EntraFIDOFinder' }
    try {
        $resp = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop
        return $resp.parents[0].sha
    } catch {
        Write-Warning "Failed to get parent commit via GitHub API: $_"
        return $null
    }
}

function Get-RawFile {
    param([string]$Owner,[string]$Repo,[string]$Commit,[string]$Path)
    $rawUrl = "https://raw.githubusercontent.com/$Owner/$Repo/$Commit/$Path"
    $headers = @{ 'User-Agent' = 'EntraFIDOFinder' }
    try {
        (Invoke-WebRequest -Uri $rawUrl -Headers $headers -UseBasicParsing -ErrorAction Stop).Content
    } catch {
        throw "Failed to download raw file: $rawUrl. $_"
    }
}

function Normalize-Icon {
    param([string]$text)
    if (-not $text) { return '' }
    $t = $text.Trim()
    # Map common check/close representations
    if ($t -match '✅|&#x?2705;|&#10004;') { return '✅' }
    if ($t -match '❌|&#10060;') { return '❌' }
    return $t
}

function Parse-Table {
    param([string]$content)
    $lines = $content -split "`n"
    $start = $lines | Select-String -Pattern '^Description\|AAGUID\|Bio\|USB\|NFC\|BLE$' | Select-Object -First 1
    if (-not $start) { throw 'Could not find table header: Description|AAGUID|Bio|USB|NFC|BLE' }
    $startIndex = $start.LineNumber
    # Skip the delimiter line (---|---|...)
    $i = $startIndex
    while ($i -lt $lines.Count -and ($lines[$i] -match '^-+\|-+\|-+')) { $i++ }
    $results = @()
    for ($j = $i; $j -lt $lines.Count; $j++) {
        $line = $lines[$j].Trim()
        if ($line -match '^##\s' -or [string]::IsNullOrWhiteSpace($line)) { break }
        if ($line -notmatch '\|') { continue }
        $parts = $line -split '\|' # Expect 6 columns
        if ($parts.Count -lt 6) { continue }
        $desc = $parts[0].Trim()
        $aaguid = $parts[1].Trim()
        $bio = Normalize-Icon $parts[2]
        $usb = Normalize-Icon $parts[3]
        $nfc = Normalize-Icon $parts[4]
        $ble = Normalize-Icon $parts[5]
        if ($aaguid -and $aaguid -match '^[0-9a-fA-F\-]{36}$') {
            $results += [pscustomobject]@{
                Description = $desc
                AAGUID      = $aaguid.ToLower()
                Bio         = $bio
                USB         = $usb
                NFC         = $nfc
                BLE         = $ble
            }
        }
    }
    return $results
}

function Compare-Entries {
    param($old,$new)
    $oldMap = @{}
    foreach ($o in $old) { $oldMap[$o.AAGUID] = $o }
    $newMap = @{}
    foreach ($n in $new) { $newMap[$n.AAGUID] = $n }

    $added = @()
    foreach ($n in $new) {
        if (-not $oldMap.ContainsKey($n.AAGUID)) { $added += $n }
    }

    $removed = @()
    foreach ($o in $old) {
        if (-not $newMap.ContainsKey($o.AAGUID)) { $removed += $o }
    }

    $modified = @()
    foreach ($aag in $newMap.Keys) {
        if ($oldMap.ContainsKey($aag)) {
            $n = $newMap[$aag]
            $o = $oldMap[$aag]
            $changes = @()
            if ($o.Description -ne $n.Description) { $changes += @('Description') }
            if ($o.Bio -ne $n.Bio) { $changes += @('Bio') }
            if ($o.USB -ne $n.USB) { $changes += @('USB') }
            if ($o.NFC -ne $n.NFC) { $changes += @('NFC') }
            if ($o.BLE -ne $n.BLE) { $changes += @('BLE') }
            if ($changes.Count -gt 0) {
                $modified += [pscustomobject]@{
                    AAGUID      = $aag
                    Old         = $o
                    New         = $n
                    Changed     = $changes
                }
            }
        }
    }
    return [pscustomobject]@{ Added = $added; Removed = $removed; Modified = $modified }
}

function Build-LocalMap {
    param([string]$LocalPath)
    if (-not (Test-Path $LocalPath)) { throw "Local FIDO keys file not found: $LocalPath" }
    $json = Get-Content -Path $LocalPath -Raw | ConvertFrom-Json
    if (-not $json.keys) { throw "Local FIDO keys file missing 'keys' property" }
    $map = @{}
    foreach ($k in $json.keys) {
        if ($k.AAGUID) {
            $map[$k.AAGUID.ToLower()] = $k
        }
    }
    return $map
}

function Compare-WithLocal {
    param($comparison,$localMap)
    $diff = [System.Collections.ArrayList]::new()

    foreach ($n in $comparison.Added) {
        $entry = if ($localMap.ContainsKey($n.AAGUID)) { $localMap[$n.AAGUID] } else { $null }
        $status = 'Missing'
        if ($entry) {
            if ($entry.Bio -eq $n.Bio -and $entry.USB -eq $n.USB -and $entry.NFC -eq $n.NFC -and $entry.BLE -eq $n.BLE) {
                $status = 'Match'
            } else {
                $status = 'Mismatch'
            }
        }
        [void]$diff.Add([pscustomobject]@{ Type='Added'; AAGUID=$n.AAGUID; Description=$n.Description; Status=$status })
    }

    foreach ($r in $comparison.Removed) {
        $entry = if ($localMap.ContainsKey($r.AAGUID)) { $localMap[$r.AAGUID] } else { $null }
        $status = $entry ? 'Mismatch (still present)' : 'Match (removed)'
        [void]$diff.Add([pscustomobject]@{ Type='Removed'; AAGUID=$r.AAGUID; Description=$r.Description; Status=$status })
    }

    foreach ($m in $comparison.Modified) {
        $entry = if ($localMap.ContainsKey($m.AAGUID)) { $localMap[$m.AAGUID] } else { $null }
        if (-not $entry) {
            [void]$diff.Add([pscustomobject]@{ Type='Modified'; AAGUID=$m.AAGUID; Description=$m.New.Description; Status='Missing' })
            continue
        }
        $matchesNew = ($entry.Bio -eq $m.New.Bio -and $entry.USB -eq $m.New.USB -and $entry.NFC -eq $m.New.NFC -and $entry.BLE -eq $m.New.BLE)
        $status = $matchesNew ? 'Match' : 'Mismatch'
        [void]$diff.Add([pscustomobject]@{ Type='Modified'; AAGUID=$m.AAGUID; Description=$m.New.Description; Status=$status })
    }

    return $diff
}

function Get-StatePath {
    $rootPath = Split-Path $PSScriptRoot -Parent
    return Join-Path $rootPath '.fido_diff_state.json'
}

function Load-State {
    $path = Get-StatePath
    if (Test-Path $path) {
        try { return Get-Content -Path $path -Raw | ConvertFrom-Json } catch { return $null }
    }
    return $null
}

function Save-State {
    param([string]$Commit)
    $path = Get-StatePath
    $obj = @{ lastCommit = $Commit }
    $obj | ConvertTo-Json | Set-Content -Path $path -Encoding UTF8
}

function Get-CommitsBetween {
    param([string]$Owner,[string]$Repo,[string]$Path,[string]$OldCommit,[string]$NewCommit)
    # Get all commits for the path between old and new
    $url = "https://api.github.com/repos/$Owner/$Repo/commits?path=$Path&per_page=100"
    $headers = @{ 'User-Agent' = 'EntraFIDOFinder' }
    try {
        $allCommits = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop
        if (-not $allCommits) { return @() }

        $commits = @()
        $found = $false
        foreach ($c in $allCommits) {
            if ($c.sha -eq $NewCommit) { $found = $true }
            if ($found -and $c.sha -ne $OldCommit) { $commits += $c }
            if ($c.sha -eq $OldCommit) { break }
        }
        return $commits
    } catch {
        Write-Warning "Failed to fetch commits between $OldCommit and ${NewCommit}: $_"
        return @()
    }
}

function Write-Markdown {
    param($result,[string]$output,[string]$oldLabel,[string]$newLabel,$diffSummary)
    $added = $result.Added
    $removed = $result.Removed
    $modified = $result.Modified

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("# FIDO Key Changes - $oldLabel → $newLabel")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("**Source:** MicrosoftDocs/entra-docs commit $newLabel ")
    [void]$sb.AppendLine("**MDS Version:** $oldLabel → $newLabel")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("## Summary")
    [void]$sb.AppendLine("- **$($added.Count) keys added**")
    [void]$sb.AppendLine("- **$($removed.Count) keys removed**")
    [void]$sb.AppendLine("- **$($modified.Count) keys modified**")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("---")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("## ✅ Keys Added ($($added.Count))")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('| Description | AAGUID | Bio | USB | NFC | BLE |')
    [void]$sb.AppendLine('|------------|---------|-----|-----|-----|-----|')
    foreach ($n in ($added | Sort-Object Description)) {
        [void]$sb.AppendLine("| $($n.Description) | $($n.AAGUID) | $($n.Bio) | $($n.USB) | $($n.NFC) | $($n.BLE) |")
    }

    [void]$sb.AppendLine()
    [void]$sb.AppendLine('---')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("## ⚠️ Keys Modified ($($modified.Count))")
    [void]$sb.AppendLine()
    foreach ($m in ($modified | Sort-Object AAGUID)) {
        [void]$sb.AppendLine("### $($m.New.Description)")
        [void]$sb.AppendLine("- **AAGUID:** $($m.AAGUID)")
        foreach ($field in $m.Changed) {
            $oldVal = $m.Old.$field
            $newVal = $m.New.$field
            [void]$sb.AppendLine("- **${field}:** ${oldVal} -> ${newVal}")
        }
        [void]$sb.AppendLine()
    }

    [void]$sb.AppendLine('---')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine("## ❌ Keys Removed ($($removed.Count))")
    [void]$sb.AppendLine()
    if ($removed.Count -eq 0) {
        [void]$sb.AppendLine('No keys were removed in this update.')
    } else {
        [void]$sb.AppendLine('| Description | AAGUID | Bio | USB | NFC | BLE |')
        [void]$sb.AppendLine('|------------|---------|-----|-----|-----|-----|')
        foreach ($r in ($removed | Sort-Object Description)) {
            [void]$sb.AppendLine("| $($r.Description) | $($r.AAGUID) | $($r.Bio) | $($r.USB) | $($r.NFC) | $($r.BLE) |")
        }
    }

    if ($diffSummary) {
        [void]$sb.AppendLine()
        [void]$sb.AppendLine('---')
        [void]$sb.AppendLine()
        [void]$sb.AppendLine('## CompareDiff Results')
        [void]$sb.AppendLine()
        [void]$sb.AppendLine('| Type | AAGUID | Description | Status |')
        [void]$sb.AppendLine('|------|--------|-------------|--------|')
        foreach ($d in $diffSummary) {
            [void]$sb.AppendLine("| $($d.Type) | $($d.AAGUID) | $($d.Description) | $($d.Status) |")
        }
    }

    Set-Content -Path $output -Value $sb.ToString() -Encoding UTF8
}

# Auto-detect latest commit if not specified
if ($LatestMeaningful) {
    Write-Host "Scanning recent commits for meaningful table changes..." -ForegroundColor Yellow
    $meaningful = Get-MeaningfulCommit -Owner $Owner -Repo $Repo -Path $FilePath
    $Commit = $meaningful.Commit
    $ParentCommit = $meaningful.Parent
    $comparison = $meaningful.Comparison
} else {
    if (-not $Commit) {
        Write-Host "No commit specified. Fetching latest commit for '$FilePath'..." -ForegroundColor Yellow
        $Commit = Get-LatestCommit -Owner $Owner -Repo $Repo -Path $FilePath
        Write-Host "Latest commit: $Commit" -ForegroundColor Green
    }

    if (-not $ParentCommit) {
        $ParentCommit = Get-ParentCommit -Owner $Owner -Repo $Repo -Commit $Commit
        if (-not $ParentCommit) { throw 'Parent commit could not be determined. Provide -ParentCommit explicitly.' }
    }

    Write-Host "Comparing $Owner/$Repo file '$FilePath' between commits" -ForegroundColor Cyan
    Write-Host "New: $Commit" -ForegroundColor Cyan
    Write-Host "Parent: $ParentCommit" -ForegroundColor Cyan

    $newContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $Commit -Path $FilePath
    $oldContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $ParentCommit -Path $FilePath

    $oldRows = Parse-Table -content $oldContent
    $newRows = Parse-Table -content $newContent

    $comparison = Compare-Entries -old $oldRows -new $newRows
}

if (-not $ForceDiff) {
    $state = Load-State
    if ($state -and $state.lastCommit -eq $Commit) {
        Write-Host "Already processed latest commit $Commit. Use -ForceDiff to rerun." -ForegroundColor Yellow
        return
    }

    # Check for intermediate commits
    if ($state -and $state.lastCommit -ne $Commit) {
        Write-Host "Detected new commits since last run. Processing each..." -ForegroundColor Yellow
        $intermediateCommits = Get-CommitsBetween -Owner $Owner -Repo $Repo -Path $FilePath -OldCommit $state.lastCommit -NewCommit $Commit
        
        if ($intermediateCommits.Count -gt 0) {
            foreach ($ic in $intermediateCommits) {
                $icSha = $ic.sha
                $icParent = $ic.parents[0].sha
                Write-Host "Processing intermediate commit $icSha..." -ForegroundColor Cyan
                
                $newContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $icSha -Path $FilePath
                $oldContent = Get-RawFile -Owner $Owner -Repo $Repo -Commit $icParent -Path $FilePath
                $oldRows = Parse-Table -content $oldContent
                $newRows = Parse-Table -content $newContent
                $icComparison = Compare-Entries -old $oldRows -new $newRows
                
                if ($icComparison.Added.Count -or $icComparison.Removed.Count -or $icComparison.Modified.Count) {
                    $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss'
                    $rootPath = Split-Path $PSScriptRoot -Parent
                    $suffix = $FIDODiff ? '_comparediff' : ''
                    $icOutput = Join-Path $rootPath "FIDO_KEY_CHANGES_${timestamp}_${icSha.Substring(0,7)}${suffix}.md"
                    
                    if ($FIDODiff) {
                        $localPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Assets/FidoKeys.json'
                        $localMap = Build-LocalMap -LocalPath $localPath
                        $icDiffSummary = Compare-WithLocal -comparison $icComparison -localMap $localMap
                        Write-Markdown -result $icComparison -output $icOutput -oldLabel $icParent -newLabel $icSha -diffSummary $icDiffSummary
                    } else {
                        Write-Markdown -result $icComparison -output $icOutput -oldLabel $icParent -newLabel $icSha -diffSummary $null
                    }
                    Write-Host "Intermediate commit written to: $icOutput" -ForegroundColor Green
                }
            }
        }
    }
}

# The labels can reflect MDS version if available in content; we keep commit shas for clarity
if ($FIDODiff) {
    $localPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Assets/FidoKeys.json'
    $localMap = Build-LocalMap -LocalPath $localPath
    $diffSummary = Compare-WithLocal -comparison $comparison -localMap $localMap
    Write-Markdown -result $comparison -output $OutputMarkdown -oldLabel $ParentCommit -newLabel $Commit -diffSummary $diffSummary
} else {
    Write-Markdown -result $comparison -output $OutputMarkdown -oldLabel $ParentCommit -newLabel $Commit -diffSummary $null
}

Save-State -Commit $Commit

Write-Host "Markdown written to: $OutputMarkdown" -ForegroundColor Green