Public/Sync-KnowledgeArticles.ps1

function Sync-KnowledgeArticles {
    <#
    .SYNOPSIS
        Pushes new or updated knowledge articles to ServiceNow as drafts.
    .DESCRIPTION
        Takes knowledge gap objects (from Get-KnowledgeGaps or manual input) and creates
        new KB articles in ServiceNow as drafts. Articles are NEVER published directly;
        they always require human review and manual publication.
        Supports -WhatIf for preview and -ReviewOnly for local file generation.
    .EXAMPLE
        $gaps = Get-KnowledgeGaps -Provider ServiceNow -Instance 'company.service-now.com' -Credential $cred
        $gaps | Sync-KnowledgeArticles -Instance 'company.service-now.com' -Credential $cred -KnowledgeBase 'abc123'
    .EXAMPLE
        $gaps | Sync-KnowledgeArticles -ReviewOnly -OutputDirectory '.\kb-review'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object[]]$Articles,

        [Parameter()]
        [string]$Instance,

        [Parameter()]
        [PSCredential]$Credential,

        [Parameter()]
        [string]$ApiKey,

        [Parameter(HelpMessage = 'ServiceNow Knowledge Base sys_id')]
        [string]$KnowledgeBase,

        [Parameter()]
        [string]$Category,

        [Parameter()]
        [switch]$ReviewOnly,

        [Parameter()]
        [string]$OutputDirectory = '.\kb-review'
    )

    begin {
        $allArticles = @()

        # Validate parameters for push mode
        if (-not $ReviewOnly) {
            if (-not $Instance) {
                throw 'ServiceNow -Instance is required when not using -ReviewOnly.'
            }
            if (-not $KnowledgeBase) {
                throw '-KnowledgeBase (ServiceNow KB sys_id) is required when pushing articles.'
            }
        }

        $results = @()
    }

    process {
        foreach ($article in $Articles) {
            $allArticles += $article
        }
    }

    end {
        if ($allArticles.Count -eq 0) {
            Write-Warning 'No articles provided for sync.'
            return @()
        }

        Write-Verbose "Processing $($allArticles.Count) articles (ReviewOnly: $ReviewOnly)"

        # ── ReviewOnly mode: save as local markdown files ──
        if ($ReviewOnly) {
            if (-not (Test-Path $OutputDirectory)) {
                New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null
            }

            foreach ($article in $allArticles) {
                $title = if ($article.SuggestedTitle) { $article.SuggestedTitle }
                         elseif ($article.Topic) { $article.Topic }
                         else { 'Untitled Article' }

                $content = if ($article.SuggestedContent) { $article.SuggestedContent }
                           else { '(No content generated)' }

                $relatedTickets = if ($article.RelatedTickets) { ($article.RelatedTickets -join ', ') } else { 'None' }
                $gapType = if ($article.GapType) { $article.GapType } else { 'New' }

                # Build markdown content
                $markdown = @"
# $title
 
**Gap Type:** $gapType
**Related Tickets:** $relatedTickets
**Category:** $(if ($Category) { $Category } else { 'Uncategorized' })
**Status:** Draft - Pending Review
 
---
 
## Article Content
 
$content
 
---
 
*Generated by ITSM-Insights on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')*
*This article requires human review before publication.*
"@


                # Sanitize filename
                $safeTitle = $title -replace '[^\w\s-]', '' -replace '\s+', '-'
                $safeTitle = $safeTitle.Substring(0, [math]::Min($safeTitle.Length, 80))
                $filePath = Join-Path $OutputDirectory "$safeTitle.md"

                $markdown | Out-File -FilePath $filePath -Encoding UTF8 -Force
                Write-Verbose "Saved review article: $filePath"

                $results += [PSCustomObject]@{
                    ArticleTitle = $title
                    Status       = 'ReviewPending'
                    SysId        = $null
                    Url          = $filePath
                    GapType      = $gapType
                }
            }

            Write-Host "Saved $($results.Count) articles for review in: $OutputDirectory" -ForegroundColor Cyan
            return $results
        }

        # ── Push mode: create articles in ServiceNow ──
        $authParams = @{ Instance = $Instance }
        if ($Credential) { $authParams['Credential'] = $Credential }
        if ($ApiKey) { $authParams['ApiKey'] = $ApiKey }

        foreach ($article in $allArticles) {
            $title = if ($article.SuggestedTitle) { $article.SuggestedTitle }
                     elseif ($article.Topic) { $article.Topic }
                     else { 'Untitled Article' }

            $content = if ($article.SuggestedContent) { $article.SuggestedContent }
                       else { '(Content pending)' }

            $relatedTickets = if ($article.RelatedTickets) { ($article.RelatedTickets -join ', ') } else { '' }

            # Build article HTML body for ServiceNow
            $articleBody = @"
<h2>$([System.Net.WebUtility]::HtmlEncode($title))</h2>
$([System.Net.WebUtility]::HtmlEncode($content) -replace "`n", '<br/>')
<hr/>
<p><em>Related Tickets: $([System.Net.WebUtility]::HtmlEncode($relatedTickets))</em></p>
<p><em>Auto-generated by ITSM-Insights. This article requires review before publication.</em></p>
"@


            # ServiceNow KB article payload — ALWAYS draft
            $kbPayload = @{
                short_description  = $title
                text               = $articleBody
                kb_knowledge_base  = $KnowledgeBase
                workflow_state     = 'draft'
            }

            if ($Category) {
                $kbPayload['category'] = $Category
            }

            # ShouldProcess check
            if ($PSCmdlet.ShouldProcess("ServiceNow KB: $title", 'Create draft knowledge article')) {
                try {
                    Write-Verbose "Creating KB article: $title"

                    $response = Connect-ServiceNow @authParams -Method POST `
                        -Endpoint 'api/now/table/kb_knowledge' `
                        -Body $kbPayload

                    $createdArticle = if ($response.PSObject.Properties['result']) { $response.result } else { $response }
                    $sysId = $createdArticle.sys_id
                    $articleNumber = $createdArticle.number

                    $baseUrl = if ($Instance -match '^https?://') { $Instance.TrimEnd('/') } else { "https://$Instance" }
                    $articleUrl = "$baseUrl/kb_view.do?sys_kb_id=$sysId"

                    Write-Verbose "Created article: $articleNumber ($sysId)"

                    $results += [PSCustomObject]@{
                        ArticleTitle = $title
                        Status       = 'Created'
                        SysId        = $sysId
                        Url          = $articleUrl
                        GapType      = if ($article.GapType) { $article.GapType } else { 'New' }
                        Number       = $articleNumber
                    }
                }
                catch {
                    Write-Warning "Failed to create KB article '$title': $($_.Exception.Message)"
                    $results += [PSCustomObject]@{
                        ArticleTitle = $title
                        Status       = "Failed: $($_.Exception.Message)"
                        SysId        = $null
                        Url          = $null
                        GapType      = if ($article.GapType) { $article.GapType } else { 'New' }
                        Number       = $null
                    }
                }
            }
            else {
                # WhatIf was used
                $results += [PSCustomObject]@{
                    ArticleTitle = $title
                    Status       = 'WhatIf'
                    SysId        = $null
                    Url          = $null
                    GapType      = if ($article.GapType) { $article.GapType } else { 'New' }
                    Number       = $null
                }
            }
        }

        $created = @($results | Where-Object { $_.Status -eq 'Created' }).Count
        $failed = @($results | Where-Object { $_.Status -like 'Failed*' }).Count
        Write-Host "Sync complete: $created created, $failed failed (all as DRAFT - human review required)" -ForegroundColor Cyan

        return $results
    }
}