Common/Export-M365Remediation.ps1

<#
.SYNOPSIS
    Exports M365-Assess findings in consultant-workflow formats (D3 #787).
.DESCRIPTION
    Reads a completed assessment folder and produces one or more remediation
    export files keyed to specific consultant workflows: GitHub Issues markdown,
    executive-summary markdown, Jira CSV import, and a technical backlog
    markdown table.

    Outputs go to <AssessmentFolder>/Remediation/ by default. Formats can be
    selected individually via -Format; the default is all four.

    The cmdlet does NOT re-run the assessment. It reads the per-collector CSVs
    and the registry-driven horizon (now/next/later) the same way the HTML
    report and XLSX matrix do, so all three artifacts stay in agreement.
.PARAMETER AssessmentFolder
    Path to the completed assessment output folder.
.PARAMETER OutputFolder
    Where to write the export files. Defaults to <AssessmentFolder>/Remediation.
.PARAMETER Format
    One or more formats: GitHub | ExecutiveSummary | Jira | TechnicalBacklog.
    Default is all four.
.PARAMETER TenantName
    Optional tenant identifier embedded in the executive summary.
.OUTPUTS
    [string[]] Array of full paths to written files.
.EXAMPLE
    Export-M365Remediation -AssessmentFolder ./M365-Assessment/Assessment_20260426 -Format GitHub,Jira
.EXAMPLE
    Export-M365Remediation -AssessmentFolder ./M365-Assessment/Assessment_20260426 -TenantName 'Contoso'
#>


function Export-M365Remediation {
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$AssessmentFolder,

        [Parameter()]
        [string]$OutputFolder,

        [Parameter()]
        [ValidateSet('GitHub', 'ExecutiveSummary', 'Jira', 'TechnicalBacklog')]
        [string[]]$Format = @('GitHub', 'ExecutiveSummary', 'Jira', 'TechnicalBacklog'),

        [Parameter()]
        [string]$TenantName
    )

    if (-not (Test-Path -Path $AssessmentFolder -PathType Container)) {
        throw "AssessmentFolder not found: $AssessmentFolder"
    }

    if (-not $OutputFolder) {
        $OutputFolder = Join-Path -Path $AssessmentFolder -ChildPath 'Remediation'
    }
    New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null

    if (-not $TenantName) {
        $TenantName = Split-Path -Leaf $AssessmentFolder
    }

    # ---------- Load findings + remediation horizon ----------
    $laneScript = Join-Path -Path $PSScriptRoot -ChildPath 'Get-RemediationLane.ps1'
    if (-not (Get-Command -Name Get-RemediationLane -ErrorAction SilentlyContinue)) {
        if (Test-Path $laneScript) { . $laneScript }
    }
    $registryScript = Join-Path -Path $PSScriptRoot -ChildPath 'Import-ControlRegistry.ps1'
    if (-not (Get-Command -Name Import-ControlRegistry -ErrorAction SilentlyContinue)) {
        if (Test-Path $registryScript) { . $registryScript }
    }

    $registry = @{}
    $controlsPath = Join-Path -Path $PSScriptRoot -ChildPath '..\controls'
    if (Test-Path -Path $controlsPath) {
        try { $registry = Import-ControlRegistry -ControlsPath $controlsPath } catch { $registry = @{} }
    }

    # Aggregate per-collector CSVs (the same source-of-truth shape the XLSX matrix uses).
    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()
    $csvFiles = Get-ChildItem -Path $AssessmentFolder -Filter '*-config*.csv' -ErrorAction SilentlyContinue
    foreach ($csv in $csvFiles) {
        Import-Csv -Path $csv.FullName | ForEach-Object {
            if ($_.CheckId -and $_.Status -in @('Fail', 'Warning', 'Review')) {
                $base = $_.CheckId -replace '\.\d+$', ''
                $reg  = if ($registry.ContainsKey($base)) { $registry[$base] } else { $null }
                $sev  = if ($reg -and $reg.riskSeverity) { $reg.riskSeverity } else { 'medium' }
                $eff  = if ($reg -and $reg.effort) { [string]$reg.effort } else { 'medium' }
                $hor  = Get-RemediationLane -Status $_.Status -Severity $sev -Effort $eff
                $findings.Add([PSCustomObject]@{
                    CheckId      = $_.CheckId
                    BaseCheckId  = $base
                    Setting      = $_.Setting
                    Category     = $_.Category
                    Status       = $_.Status
                    CurrentValue = $_.CurrentValue
                    Recommended  = $_.RecommendedValue
                    Remediation  = $_.Remediation
                    Severity     = $sev
                    Effort       = $eff
                    Horizon      = $hor
                    Impact       = if ($reg -and $reg.impact) { [string]$reg.impact } else { '' }
                    References   = if ($reg -and $reg.references) { @($reg.references) } else { @() }
                })
            }
        }
    }

    if ($findings.Count -eq 0) {
        Write-Warning "No remediation-relevant findings (Fail/Warning/Review) in: $AssessmentFolder"
        return @()
    }

    $written = [System.Collections.Generic.List[string]]::new()

    # ---------- GitHub Issues markdown ----------
    if ($Format -contains 'GitHub') {
        $ghDir = Join-Path -Path $OutputFolder -ChildPath 'github'
        New-Item -Path $ghDir -ItemType Directory -Force | Out-Null
        foreach ($f in $findings) {
            $title = "[$($f.Severity.ToUpper())] $($f.Setting)"
            $labels = @($f.Status.ToLower(), "horizon:$($f.Horizon)", "severity:$($f.Severity)") -join ', '
            $refsBlock = if ($f.References.Count -gt 0) {
                "## References`n" + (($f.References | ForEach-Object {
                    $url = if ($_ -is [hashtable]) { $_.url } else { $_.url }
                    $title = if ($_ -is [hashtable]) { $_.title } else { $_.title }
                    "- [$title]($url)"
                }) -join "`n")
            } else { '' }
            $body = @"
# $title

**CheckId:** $($f.CheckId) | **Status:** $($f.Status) | **Severity:** $($f.Severity) | **Effort:** $($f.Effort) | **Horizon:** $($f.Horizon)

**Suggested labels:** $labels

## Current state
$($f.CurrentValue)

## Recommended state
$($f.Recommended)

## Remediation
$($f.Remediation)

$refsBlock
"@

            $safeName = $f.CheckId -replace '[^A-Za-z0-9_.-]', '_'
            $path = Join-Path -Path $ghDir -ChildPath "$safeName.md"
            Set-Content -Path $path -Value $body -Encoding UTF8
            $written.Add($path)
        }
        # Helper script
        $helper = @"
# Bulk-create GitHub issues from these markdown files
# Usage: cd github && bash create-issues.sh <owner>/<repo>
set -euo pipefail
REPO="`${1:?owner/repo required}"
for f in *.md; do
  [[ "`$f" == "create-issues.sh" || "`$f" == "README.md" ]] && continue
  TITLE=`$(head -1 "`$f" | sed 's/^# //')
  gh issue create --repo "`$REPO" --title "`$TITLE" --body-file "`$f"
done
"@

        Set-Content -Path (Join-Path $ghDir 'create-issues.sh') -Value $helper -Encoding UTF8
        $written.Add((Join-Path $ghDir 'create-issues.sh'))
    }

    # ---------- Executive summary markdown ----------
    if ($Format -contains 'ExecutiveSummary') {
        $statusCounts = @{
            Fail    = @($findings | Where-Object Status -eq 'Fail').Count
            Warning = @($findings | Where-Object Status -eq 'Warning').Count
            Review  = @($findings | Where-Object Status -eq 'Review').Count
        }
        $sevOrder = @{ critical = 0; high = 1; medium = 2; low = 3; none = 4; info = 5 }
        $topCritical = @($findings | Where-Object Status -eq 'Fail' |
            Sort-Object { $sevOrder[$_.Severity] }, CheckId | Select-Object -First 5)
        $quickWins = @($findings | Where-Object {
            $_.Status -eq 'Fail' -and ($_.Effort -eq 'small' -or $_.Effort -eq 'low')
        } | Sort-Object { $sevOrder[$_.Severity] } | Select-Object -First 5)
        $byHorizon = @{
            now   = @($findings | Where-Object Horizon -eq 'now').Count
            soon  = @($findings | Where-Object Horizon -eq 'soon').Count
            later = @($findings | Where-Object Horizon -eq 'later').Count
        }

        $exec = @"
# Executive Remediation Summary - $TenantName

Generated: $(Get-Date -Format 'yyyy-MM-dd')

## Status snapshot

| Status | Count |
|---|---|
| Fail | $($statusCounts.Fail) |
| Warning | $($statusCounts.Warning) |
| Review (manual validation) | $($statusCounts.Review) |

## Remediation horizon

| Horizon | Count | Meaning |
|---|---|---|
| Now | $($byHorizon.now) | Critical / high-impact fixes the team should start this sprint |
| Soon | $($byHorizon.soon) | Next 30-60 days |
| Later | $($byHorizon.later) | Long-tail / strategic |

## Top critical findings

$(if ($topCritical.Count -eq 0) { '_No critical failures._' } else {
    ($topCritical | ForEach-Object {
        "- **[$($_.CheckId)]** $($_.Setting) -- severity $($_.Severity), effort $($_.Effort)"
    }) -join "`n"
})

## Quick wins (high-impact, low-effort)

$(if ($quickWins.Count -eq 0) { '_No quick wins available -- failures require non-trivial effort._' } else {
    ($quickWins | ForEach-Object {
        "- **[$($_.CheckId)]** $($_.Setting) -- $($_.Severity) severity, $($_.Effort) effort"
    }) -join "`n"
})

## Next steps

1. Review the top critical findings with the security architect
2. Schedule the Now-horizon items into the next sprint
3. Use the GitHub Issues markdown export to bulk-create tracking issues
4. Re-run the assessment after the next remediation sprint to measure drift
"@

        $execPath = Join-Path -Path $OutputFolder -ChildPath 'executive-summary.md'
        Set-Content -Path $execPath -Value $exec -Encoding UTF8
        $written.Add($execPath)
    }

    # ---------- Jira CSV ----------
    if ($Format -contains 'Jira') {
        # Map M365-Assess severity to Jira priority. Jira's defaults: Highest/High/Medium/Low/Lowest.
        $priMap = @{ critical = 'Highest'; high = 'High'; medium = 'Medium'; low = 'Low'; none = 'Lowest'; info = 'Lowest' }
        $jiraRows = $findings | ForEach-Object {
            [PSCustomObject][ordered]@{
                Summary       = "[$($_.CheckId)] $($_.Setting)"
                Description   = "Status: $($_.Status). Current: $($_.CurrentValue). Recommended: $($_.Recommended). $([System.Environment]::NewLine)$([System.Environment]::NewLine)Remediation: $($_.Remediation)"
                'Issue Type'  = 'Task'
                Priority      = $priMap[$_.Severity]
                Labels        = "m365-assess $($_.Status.ToLower()) horizon-$($_.Horizon) severity-$($_.Severity)"
                Components    = $_.Category
            }
        }
        $jiraPath = Join-Path -Path $OutputFolder -ChildPath 'jira-import.csv'
        $jiraRows | Export-Csv -Path $jiraPath -NoTypeInformation -Encoding UTF8
        $written.Add($jiraPath)
    }

    # ---------- Technical backlog markdown ----------
    if ($Format -contains 'TechnicalBacklog') {
        $rows = $findings | Sort-Object @{Expression={
            switch ($_.Horizon) { 'now' { 0 } 'soon' { 1 } 'later' { 2 } default { 3 } }
        }}, Severity, CheckId
        $body = "# Technical Remediation Backlog - $TenantName`n`n"
        $body += "Generated: $(Get-Date -Format 'yyyy-MM-dd'). Total: $($rows.Count) finding(s).`n`n"
        $body += "| Horizon | CheckId | Setting | Status | Severity | Effort | Category |`n"
        $body += "|---|---|---|---|---|---|---|`n"
        foreach ($r in $rows) {
            $setting = $r.Setting -replace '\|', '\|'
            $body += "| $($r.Horizon) | $($r.CheckId) | $setting | $($r.Status) | $($r.Severity) | $($r.Effort) | $($r.Category) |`n"
        }
        $tbPath = Join-Path -Path $OutputFolder -ChildPath 'technical-backlog.md'
        Set-Content -Path $tbPath -Value $body -Encoding UTF8
        $written.Add($tbPath)
    }

    return $written.ToArray()
}