Public/New-Runbook.ps1

function New-Runbook {
    <#
    .SYNOPSIS
        Creates a new runbook from a built-in template or generates one using AI from ITSM ticket history.
    .DESCRIPTION
        Provides two methods for creating runbooks:
        1. From Template: Copies a built-in template to the output location with optional customization.
        2. From Ticket History: Queries an ITSM provider (ServiceNow, Jira, or CSV) for tickets related
           to a CI, then uses AI to analyze patterns and generate a YAML runbook for common issues.
 
        Generated runbooks follow the standard decision-tree YAML format used by Invoke-Runbook.
    .PARAMETER Name
        Name for the new runbook (used as the filename without extension).
    .PARAMETER FromTemplate
        Name of a built-in template to use as the base (e.g., 'high-cpu', 'disk-space').
    .PARAMETER FromTicketHistory
        Switch to generate a runbook from ITSM ticket history using AI.
    .PARAMETER ITSMProvider
        ITSM provider type: ServiceNow, Jira, or CSV.
    .PARAMETER ITSMEndpoint
        URL or path to the ITSM source (API endpoint or CSV file path).
    .PARAMETER ITSMCredential
        PSCredential for authenticating with the ITSM provider.
    .PARAMETER CIName
        Configuration Item name to generate the runbook for.
    .PARAMETER Provider
        AI provider for runbook generation: Anthropic, OpenAI, Ollama, or Custom.
    .PARAMETER ApiKey
        API key for the AI provider.
    .PARAMETER Model
        Specific model to use with the AI provider.
    .PARAMETER OutputPath
        Directory where the generated YAML file will be saved. Defaults to the engine runbooks directory.
    .EXAMPLE
        New-Runbook -Name 'custom-cpu' -FromTemplate 'high-cpu' -OutputPath 'C:\Runbooks'
        Create a new runbook based on the high-cpu template.
    .EXAMPLE
        New-Runbook -Name 'webserver-issues' -FromTicketHistory -ITSMProvider ServiceNow -ITSMEndpoint 'https://instance.service-now.com' -ITSMCredential $cred -CIName 'WEB01' -Provider OpenAI -ApiKey $key
        Generate a runbook from ServiceNow tickets about WEB01 using OpenAI.
    .EXAMPLE
        New-Runbook -Name 'db-recovery' -FromTicketHistory -ITSMProvider CSV -ITSMEndpoint 'C:\tickets.csv' -CIName 'SQL01' -Provider Ollama
        Generate a runbook from a CSV file of tickets using a local Ollama model.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Name,

        [Parameter(ParameterSetName = 'Template')]
        [string]$FromTemplate,

        [Parameter(ParameterSetName = 'TicketHistory')]
        [switch]$FromTicketHistory,

        [Parameter(ParameterSetName = 'TicketHistory')]
        [ValidateSet('ServiceNow', 'Jira', 'CSV')]
        [string]$ITSMProvider = 'ServiceNow',

        [Parameter(ParameterSetName = 'TicketHistory')]
        [string]$ITSMEndpoint,

        [Parameter(ParameterSetName = 'TicketHistory')]
        [PSCredential]$ITSMCredential,

        [Parameter(ParameterSetName = 'TicketHistory')]
        [string]$CIName,

        [Parameter(ParameterSetName = 'TicketHistory')]
        [ValidateSet('Anthropic', 'OpenAI', 'Ollama', 'Custom')]
        [string]$Provider = 'OpenAI',

        [Parameter(ParameterSetName = 'TicketHistory')]
        [string]$ApiKey,

        [Parameter(ParameterSetName = 'TicketHistory')]
        [string]$Model,

        [Parameter()]
        [string]$OutputPath
    )

    # Determine output directory
    if (-not $OutputPath) {
        $OutputPath = Join-Path $env:USERPROFILE '.runbookengine\runbooks'
    }

    if (-not (Test-Path $OutputPath)) {
        New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
    }

    $outputFile = Join-Path $OutputPath "$Name.yml"

    if ($FromTemplate) {
        # Copy from built-in template
        $templateDir = Join-Path $PSScriptRoot '..\Templates'
        $templateFile = Join-Path $templateDir "$FromTemplate.yml"

        if (-not (Test-Path $templateFile)) {
            $templateFile = Join-Path $templateDir "$FromTemplate.yaml"
        }

        if (-not (Test-Path $templateFile)) {
            # List available templates
            $available = Get-ChildItem -Path $templateDir -Filter '*.yml' -ErrorAction SilentlyContinue |
                ForEach-Object { $_.BaseName }
            throw "Template '$FromTemplate' not found. Available templates: $($available -join ', ')"
        }

        Copy-Item -Path $templateFile -Destination $outputFile -Force

        # Update the name in the copied file
        $content = Get-Content -Path $outputFile -Raw
        $content = $content -replace '^name:.*$', "name: $Name" -replace "(?m)^name:.*$", "name: $Name"
        Set-Content -Path $outputFile -Value $content -Encoding UTF8

        Write-Host "Runbook created from template '$FromTemplate': $outputFile" -ForegroundColor Green
        return $outputFile
    }

    if ($FromTicketHistory) {
        if (-not $CIName) {
            throw "CIName is required when generating from ticket history."
        }

        Write-Host "Fetching ticket history for CI: $CIName..." -ForegroundColor Cyan

        # Fetch tickets from ITSM
        $tickets = @()

        switch ($ITSMProvider) {
            'ServiceNow' {
                if (-not $ITSMEndpoint) { throw "ITSMEndpoint is required for ServiceNow." }
                if (-not $ITSMCredential) { throw "ITSMCredential is required for ServiceNow." }

                $headers = @{
                    'Accept' = 'application/json'
                    'Content-Type' = 'application/json'
                }

                $encodedCI = [System.Uri]::EscapeDataString($CIName)
                $uri = "$($ITSMEndpoint.TrimEnd('/'))/api/now/table/incident?sysparm_query=cmdb_ci.name=$encodedCI&sysparm_limit=200&sysparm_fields=number,short_description,description,category,subcategory,priority,state,resolved_at,resolution_code,close_notes"

                try {
                    $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers `
                        -Credential $ITSMCredential -ErrorAction Stop
                    $tickets = $response.result | ForEach-Object {
                        [PSCustomObject]@{
                            Number      = $_.number
                            Summary     = $_.short_description
                            Description = $_.description
                            Category    = $_.category
                            SubCategory = $_.subcategory
                            Priority    = $_.priority
                            State       = $_.state
                            Resolution  = $_.close_notes
                        }
                    }
                }
                catch {
                    throw "Failed to fetch tickets from ServiceNow: $_"
                }
            }

            'Jira' {
                if (-not $ITSMEndpoint) { throw "ITSMEndpoint is required for Jira." }
                if (-not $ITSMCredential) { throw "ITSMCredential is required for Jira." }

                $username = $ITSMCredential.UserName
                $password = $ITSMCredential.GetNetworkCredential().Password
                $base64Auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${username}:${password}"))

                $headers = @{
                    'Authorization' = "Basic $base64Auth"
                    'Content-Type'  = 'application/json'
                }

                $jql = [System.Uri]::EscapeDataString("labels = `"$CIName`" OR summary ~ `"$CIName`" ORDER BY created DESC")
                $uri = "$($ITSMEndpoint.TrimEnd('/'))/rest/api/2/search?jql=$jql&maxResults=200&fields=key,summary,description,priority,status,resolution,comment"

                try {
                    $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -ErrorAction Stop
                    $tickets = $response.issues | ForEach-Object {
                        [PSCustomObject]@{
                            Number      = $_.key
                            Summary     = $_.fields.summary
                            Description = $_.fields.description
                            Category    = 'Incident'
                            SubCategory = ''
                            Priority    = $_.fields.priority.name
                            State       = $_.fields.status.name
                            Resolution  = if ($_.fields.resolution) { $_.fields.resolution.name } else { '' }
                        }
                    }
                }
                catch {
                    throw "Failed to fetch tickets from Jira: $_"
                }
            }

            'CSV' {
                if (-not $ITSMEndpoint) { throw "ITSMEndpoint (CSV file path) is required." }
                if (-not (Test-Path $ITSMEndpoint)) { throw "CSV file not found: $ITSMEndpoint" }

                try {
                    $csvData = Import-Csv -Path $ITSMEndpoint -ErrorAction Stop

                    # Filter for the CI if there's a CI or ComputerName column
                    $ciColumn = $csvData[0].PSObject.Properties.Name |
                        Where-Object { $_ -in @('CI', 'CIName', 'ComputerName', 'Server', 'Asset') } |
                        Select-Object -First 1

                    if ($ciColumn) {
                        $csvData = $csvData | Where-Object { $_.$ciColumn -like "*$CIName*" }
                    }

                    $tickets = $csvData | ForEach-Object {
                        [PSCustomObject]@{
                            Number      = if ($_.Number) { $_.Number } elseif ($_.TicketId) { $_.TicketId } else { $_.Key }
                            Summary     = if ($_.Summary) { $_.Summary } elseif ($_.Title) { $_.Title } else { $_.short_description }
                            Description = if ($_.Description) { $_.Description } else { '' }
                            Category    = if ($_.Category) { $_.Category } else { 'Unknown' }
                            SubCategory = if ($_.SubCategory) { $_.SubCategory } else { '' }
                            Priority    = if ($_.Priority) { $_.Priority } else { 'Medium' }
                            State       = if ($_.State) { $_.State } elseif ($_.Status) { $_.Status } else { 'Closed' }
                            Resolution  = if ($_.Resolution) { $_.Resolution } elseif ($_.close_notes) { $_.close_notes } else { '' }
                        }
                    }
                }
                catch {
                    throw "Failed to parse CSV file: $_"
                }
            }
        }

        if ($tickets.Count -eq 0) {
            Write-Warning "No tickets found for CI '$CIName'. Creating a basic runbook template."
            $yamlContent = @"
name: $Name
version: "1.0"
description: Auto-generated runbook for $CIName
trigger:
  metric: manual
  threshold: 0
 
parameters:
  - name: ComputerName
    required: true
 
steps:
  - id: initial_check
    action: script
    description: Check current system status
    script: |
      Get-CimInstance Win32_OperatingSystem -ComputerName `$ComputerName |
        Select-Object CSName, LastBootUpTime, FreePhysicalMemory, TotalVisibleMemorySize
    outputs:
      - system_status
 
  - id: review
    action: notify
    description: Manual review required
    message: "Review system status for `$ComputerName and determine next steps."
    include_data: system_status
"@

            Set-Content -Path $outputFile -Value $yamlContent -Encoding UTF8
            Write-Host "Basic runbook created: $outputFile" -ForegroundColor Yellow
            return $outputFile
        }

        Write-Host "Found $($tickets.Count) tickets. Analyzing with AI..." -ForegroundColor Cyan

        # Prepare ticket summary for AI
        $ticketSummary = $tickets | ForEach-Object {
            "Ticket: $($_.Number)`nSummary: $($_.Summary)`nCategory: $($_.Category)`nPriority: $($_.Priority)`nResolution: $($_.Resolution)`n---"
        }
        $ticketText = $ticketSummary -join "`n"

        # Trim if too long
        if ($ticketText.Length -gt 12000) {
            $ticketText = $ticketText.Substring(0, 12000) + "`n... (truncated)"
        }

        $aiPrompt = @"
Analyze the following IT support tickets for the server/CI named "$CIName" and generate a YAML runbook that addresses the most common issues found in these tickets.
 
The runbook should follow this exact YAML structure:
- name: descriptive name
- version: "1.0"
- description: what this runbook handles
- trigger: metric, threshold, duration_minutes
- parameters: list with name, required, default
- steps: list where each step has id, action (script/decision/integration/notify/escalate), description
  - script steps: include a PowerShell script field and optionally outputs, verify
  - decision steps: include condition, if_true, if_false
  - notify steps: include message
  - escalate steps: include priority, message
 
For script steps, use real PowerShell commands that would work on Windows servers. Use `$ComputerName as the target variable.
 
Identify the top 3-5 most common issue patterns from the tickets and build a decision tree that:
1. Diagnoses the issue
2. Checks for common causes
3. Attempts automated remediation where safe
4. Escalates when automation cannot resolve
 
TICKET DATA:
$ticketText
 
Generate ONLY the YAML content, no explanations or markdown code fences.
"@


        $systemPrompt = 'You are an expert infrastructure automation engineer. Generate precise, production-ready YAML runbooks for Windows server remediation. Output only valid YAML with no markdown formatting.'

        try {
            $aiParams = @{
                Prompt       = $aiPrompt
                Provider     = $Provider
                SystemPrompt = $systemPrompt
            }
            if ($ApiKey) { $aiParams['ApiKey'] = $ApiKey }
            if ($Model) { $aiParams['Model'] = $Model }

            $generatedYaml = Invoke-AICompletion @aiParams

            # Clean up AI response - remove any markdown fences
            $generatedYaml = $generatedYaml -replace '```yaml\s*', '' -replace '```\s*', ''
            $generatedYaml = $generatedYaml.Trim()

            # Basic validation
            if ($generatedYaml -notmatch 'name:' -or $generatedYaml -notmatch 'steps:') {
                Write-Warning "AI response does not appear to be valid runbook YAML. Saving raw output."
            }

            Set-Content -Path $outputFile -Value $generatedYaml -Encoding UTF8
            Write-Host "AI-generated runbook created: $outputFile" -ForegroundColor Green
            Write-Host "Review the generated runbook before use. Verify all scripts and conditions." -ForegroundColor Yellow
        }
        catch {
            throw "Failed to generate runbook via AI: $_"
        }

        return $outputFile
    }

    # If neither template nor ticket history, create a blank runbook
    $blankYaml = @"
name: $Name
version: "1.0"
description: Custom runbook - add description here
trigger:
  metric: manual
  threshold: 0
 
parameters:
  - name: ComputerName
    required: true
 
steps:
  - id: step_1
    action: script
    description: First diagnostic step
    script: |
      Write-Output "Running diagnostics on `$ComputerName"
    outputs:
      - diagnostic_result
 
  - id: evaluate
    action: decision
    description: Evaluate diagnostic results
    condition: "`$diagnostic_result -ne `$null"
    if_true: remediate
    if_false: escalate
 
  - id: remediate
    action: script
    description: Apply remediation
    requires_approval: true
    blast_radius: single_service
    script: |
      Write-Output "Remediating issue on `$ComputerName"
 
  - id: escalate
    action: escalate
    description: Escalate to admin
    priority: medium
    message: "Issue on `$ComputerName requires manual intervention"
"@


    Set-Content -Path $outputFile -Value $blankYaml -Encoding UTF8
    Write-Host "Blank runbook created: $outputFile" -ForegroundColor Green
    Write-Host "Edit the runbook to add your custom logic." -ForegroundColor Yellow

    return $outputFile
}