Public/Get-RecurringIssues.ps1
|
function Get-RecurringIssues { <# .SYNOPSIS Analyzes tickets to identify recurring issues and patterns using AI. .DESCRIPTION Pulls tickets for a CI, category, or timeframe and uses AI to detect patterns such as repeated root causes, recurring symptoms, and escalation patterns. Returns actionable recommendations for permanent fixes. .EXAMPLE Get-RecurringIssues -CIName 'SQL-PROD-01' -Provider ServiceNow -Instance 'company.service-now.com' -Credential $cred .EXAMPLE Get-RecurringIssues -Provider File -FilePath '.\tickets.json' -MinOccurrences 2 -OutputPath '.\recurring.html' #> [CmdletBinding()] param( [Parameter()] [string]$CIName, [Parameter()] [string]$Category, [Parameter()] [ValidateSet('ServiceNow', 'Jira', 'File')] [string]$Provider = 'ServiceNow', [Parameter()] [string]$Instance, [Parameter()] [PSCredential]$Credential, [Parameter()] [string]$ApiKey, [Parameter()] [string]$BaseUrl, [Parameter()] [string]$Email, [Parameter()] [string]$FilePath, [Parameter()] [ValidateRange(1, 60)] [int]$MonthsBack = 6, [Parameter()] [int]$MinOccurrences = 3, [Parameter()] [ValidateSet('Anthropic', 'OpenAI', 'Ollama', 'Custom')] [string]$AIProvider = 'Anthropic', [Parameter()] [string]$AIApiKey, [Parameter()] [string]$AIModel, [Parameter()] [string]$AIEndpoint, [Parameter()] [string]$OutputPath, [Parameter()] [switch]$SkipAI ) Write-Verbose "Analyzing recurring issues (Provider: $Provider, MonthsBack: $MonthsBack, MinOccurrences: $MinOccurrences)" $allTickets = @() switch ($Provider) { 'ServiceNow' { if (-not $Instance) { throw 'ServiceNow provider requires -Instance parameter.' } $authParams = @{ Instance = $Instance } if ($Credential) { $authParams['Credential'] = $Credential } if ($ApiKey) { $authParams['ApiKey'] = $ApiKey } $fields = 'number,short_description,description,state,priority,category,subcategory,opened_at,closed_at,resolved_at,assigned_to,close_notes,work_notes,cmdb_ci' $dateFilter = "sys_created_on>javascript:gs.monthsAgo($MonthsBack)" # Build query based on filters $queryParts = @($dateFilter) if ($CIName) { $queryParts += "cmdb_ci.name=$CIName" } if ($Category) { $queryParts += "category=$Category" } $query = $queryParts -join '^' # Query incidents (primary source for recurring issues) Write-Verbose 'Querying ServiceNow incidents...' $incidents = Connect-ServiceNow @authParams -Method GET -Endpoint 'api/now/table/incident' ` -QueryParameters @{ sysparm_query = $query sysparm_fields = $fields sysparm_limit = '10000' } -Paginate foreach ($inc in @($incidents)) { $allTickets += [PSCustomObject]@{ Number = $inc.number Type = 'Incident' ShortDescription = $inc.short_description Description = $inc.description State = $inc.state Priority = $inc.priority Category = $inc.category Subcategory = $inc.subcategory OpenedAt = $inc.opened_at ClosedAt = $inc.closed_at ResolvedAt = $inc.resolved_at AssignedTo = if ($inc.assigned_to -is [string]) { $inc.assigned_to } else { $inc.assigned_to.display_value } CloseNotes = $inc.close_notes WorkNotes = $inc.work_notes CIName = if ($inc.cmdb_ci -is [string]) { $inc.cmdb_ci } else { $inc.cmdb_ci.display_value } Source = 'ServiceNow' } } # Query problems too (they often link recurring incidents) Write-Verbose 'Querying ServiceNow problems...' $problems = Connect-ServiceNow @authParams -Method GET -Endpoint 'api/now/table/problem' ` -QueryParameters @{ sysparm_query = $query sysparm_fields = $fields sysparm_limit = '5000' } -Paginate foreach ($prb in @($problems)) { $allTickets += [PSCustomObject]@{ Number = $prb.number Type = 'Problem' ShortDescription = $prb.short_description Description = $prb.description State = $prb.state Priority = $prb.priority Category = $prb.category Subcategory = $prb.subcategory OpenedAt = $prb.opened_at ClosedAt = $prb.closed_at ResolvedAt = $prb.resolved_at AssignedTo = if ($prb.assigned_to -is [string]) { $prb.assigned_to } else { $prb.assigned_to.display_value } CloseNotes = $prb.close_notes WorkNotes = $prb.work_notes CIName = if ($prb.cmdb_ci -is [string]) { $prb.cmdb_ci } else { $prb.cmdb_ci.display_value } Source = 'ServiceNow' } } Write-Verbose "Total tickets for analysis: $($allTickets.Count)" } 'Jira' { if (-not $BaseUrl) { throw 'Jira provider requires -BaseUrl parameter.' } if (-not $Email) { throw 'Jira provider requires -Email parameter.' } $jqlParts = @("created >= ""-${MonthsBack}m""") if ($CIName) { $jqlParts += "(""Affected CI"" = ""$CIName"" OR summary ~ ""$CIName"" OR description ~ ""$CIName"")" } if ($Category) { $jqlParts += "component = ""$Category""" } $jql = $jqlParts -join ' AND ' Write-Verbose "Querying Jira: $jql" $jiraParams = @{ BaseUrl = $BaseUrl Email = $Email Method = 'GET' Endpoint = 'rest/api/3/search' QueryParameters = @{ jql = $jql maxResults = '100' fields = 'summary,description,status,priority,issuetype,created,resolutiondate,assignee,reporter,comment,components' } Paginate = $true } if ($ApiKey) { $jiraParams['ApiToken'] = $ApiKey } $jiraIssues = Connect-JiraSM @jiraParams foreach ($issue in @($jiraIssues)) { $allTickets += ConvertFrom-JiraIssue -Issue $issue } Write-Verbose "Found $(@($jiraIssues).Count) Jira issues" } 'File' { if (-not $FilePath) { throw 'File provider requires -FilePath parameter.' } $importParams = @{ Path = $FilePath MonthsBack = $MonthsBack } if ($CIName) { $importParams['CIFilter'] = $CIName } $allTickets = @(Import-TicketExport @importParams) # Apply category filter if specified if ($Category) { $allTickets = @($allTickets | Where-Object { $_.Category -like "*$Category*" }) } Write-Verbose "Imported $($allTickets.Count) tickets from file" } } if ($allTickets.Count -eq 0) { Write-Warning 'No tickets found matching the specified criteria.' return @() } # Sort by date $allTickets = @($allTickets | Sort-Object { try { [datetime]$_.OpenedAt } catch { [datetime]::MinValue } }) # ── SkipAI: basic pattern detection without AI ── if ($SkipAI) { Write-Verbose 'SkipAI mode: performing basic pattern detection without AI...' $patterns = Find-BasicPatterns -Tickets $allTickets -MinOccurrences $MinOccurrences if ($OutputPath) { New-HtmlDashboard -ReportType 'RecurringIssues' -Data $patterns -OutputPath $OutputPath Write-Host "HTML report saved to: $OutputPath" -ForegroundColor Cyan } return $patterns } # ── AI-powered pattern detection ── Write-Verbose 'Generating AI pattern analysis...' $templatePath = Join-Path $PSScriptRoot '..\Templates\recurring-issues-prompt.txt' if (Test-Path $templatePath) { $promptTemplate = Get-Content -Path $templatePath -Raw -Encoding UTF8 } else { $promptTemplate = @" You are an IT problem management analyst. Analyze the following tickets to identify recurring issues and patterns. For each pattern found with {min_occurrences} or more occurrences, provide: 1. Pattern Name 2. Occurrences (ticket numbers) 3. Root Cause Analysis 4. Impact 5. Suggested Permanent Fix 6. Suggested KB Article outline TICKET DATA: {ticket_data} "@ } $ticketDataText = ($allTickets | ForEach-Object { "[$($_.Type)] $($_.Number) | $($_.OpenedAt) | CI: $($_.CIName) | Cat: $($_.Category) | $($_.ShortDescription) | State: $($_.State) | Close Notes: $($_.CloseNotes)" }) -join "`n" $prompt = $promptTemplate -replace '\{min_occurrences\}', $MinOccurrences -replace '\{ticket_data\}', $ticketDataText $aiParams = @{ Prompt = $prompt Provider = $AIProvider } if ($AIApiKey) { $aiParams['ApiKey'] = $AIApiKey } if ($AIModel) { $aiParams['Model'] = $AIModel } if ($AIEndpoint) { $aiParams['Endpoint'] = $AIEndpoint } try { $aiResponse = Invoke-AICompletion @aiParams } catch { Write-Warning "AI analysis failed: $($_.Exception.Message). Falling back to basic pattern detection." $patterns = Find-BasicPatterns -Tickets $allTickets -MinOccurrences $MinOccurrences if ($OutputPath) { New-HtmlDashboard -ReportType 'RecurringIssues' -Data $patterns -OutputPath $OutputPath Write-Host "HTML report saved to: $OutputPath" -ForegroundColor Cyan } return $patterns } # Parse AI response into structured objects $patterns = ConvertFrom-AIPatternResponse -AIResponse $aiResponse -Tickets $allTickets if ($OutputPath) { New-HtmlDashboard -ReportType 'RecurringIssues' -Data $patterns -OutputPath $OutputPath Write-Host "HTML report saved to: $OutputPath" -ForegroundColor Cyan } return $patterns } function Find-BasicPatterns { <# .SYNOPSIS Basic pattern detection without AI - groups tickets by similar short descriptions and categories. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$Tickets, [Parameter()] [int]$MinOccurrences = 3 ) $patterns = @() # Group by category + subcategory $categoryGroups = $Tickets | Group-Object { "$($_.Category)|$($_.Subcategory)" } | Where-Object { $_.Count -ge $MinOccurrences } foreach ($group in $categoryGroups) { $sortedTickets = $group.Group | Sort-Object { try { [datetime]$_.OpenedAt } catch { [datetime]::MinValue } } $ticketNumbers = @($sortedTickets | ForEach-Object { $_.Number }) $firstDate = ($sortedTickets | Select-Object -First 1).OpenedAt $lastDate = ($sortedTickets | Select-Object -Last 1).OpenedAt $patterns += [PSCustomObject]@{ Pattern = "Category: $($group.Name -replace '\|', ' > ')" Occurrences = $group.Count TicketNumbers = $ticketNumbers FirstSeen = $firstDate LastSeen = $lastDate SuggestedResolution = "Review tickets in this category for common root cause. Consider creating a knowledge article or implementing a permanent fix." EstimatedTimeSaved = "$($group.Count) ticket interactions potentially avoidable" Source = 'BasicAnalysis' } } # Group by similar short descriptions (simple word overlap approach) $descGroups = @{} foreach ($ticket in $Tickets) { $words = ($ticket.ShortDescription -split '\s+' | Where-Object { $_.Length -gt 3 } | ForEach-Object { $_.ToLower() } | Select-Object -First 5) -join ' ' if ($words) { if (-not $descGroups.ContainsKey($words)) { $descGroups[$words] = @() } $descGroups[$words] += $ticket } } foreach ($key in $descGroups.Keys) { $group = $descGroups[$key] if ($group.Count -ge $MinOccurrences) { $sortedTickets = $group | Sort-Object { try { [datetime]$_.OpenedAt } catch { [datetime]::MinValue } } $ticketNumbers = @($sortedTickets | ForEach-Object { $_.Number }) $patterns += [PSCustomObject]@{ Pattern = "Similar: $($group[0].ShortDescription)" Occurrences = $group.Count TicketNumbers = $ticketNumbers FirstSeen = ($sortedTickets | Select-Object -First 1).OpenedAt LastSeen = ($sortedTickets | Select-Object -Last 1).OpenedAt SuggestedResolution = 'Multiple tickets with similar descriptions detected. Investigate shared root cause.' EstimatedTimeSaved = "Approximately $([math]::Round($group.Count * 0.5, 1)) hours if resolved permanently" Source = 'BasicAnalysis' } } } return @($patterns | Sort-Object Occurrences -Descending) } function ConvertFrom-AIPatternResponse { <# .SYNOPSIS Parses AI response text into structured RecurringIssue objects. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$AIResponse, [Parameter(Mandatory)] [object[]]$Tickets ) $patterns = @() # Split response by pattern sections (look for numbered headers or ## headers) $sections = $AIResponse -split '(?m)^(?:#{1,3}\s*\d+\.?\s*|(?:\*\*)?Pattern\s*(?:Name)?\s*(?:\d+)?:?\s*(?:\*\*)?)' foreach ($section in $sections) { if ([string]::IsNullOrWhiteSpace($section)) { continue } if ($section.Length -lt 20) { continue } # Extract pattern name (first line or bold text) $lines = $section.Trim() -split "`n" $patternName = ($lines[0] -replace '\*\*', '' -replace '^#+\s*', '' -replace '^\d+\.\s*', '').Trim() if (-not $patternName -or $patternName.Length -lt 3) { continue } # Extract ticket numbers mentioned $ticketNumbers = @() $ticketMatches = [regex]::Matches($section, '(?:INC|CHG|PRB|REQ|RITM|TASK|SCTASK|[A-Z]+-)\d{5,10}') foreach ($match in $ticketMatches) { $ticketNumbers += $match.Value } $ticketNumbers = @($ticketNumbers | Select-Object -Unique) # Extract occurrence count $occurrences = $ticketNumbers.Count $occurrenceMatch = [regex]::Match($section, '(?:Occurrences?|Count|Times?)[\s:]*(\d+)', 'IgnoreCase') if ($occurrenceMatch.Success) { $occurrences = [int]$occurrenceMatch.Groups[1].Value } if ($occurrences -lt 1) { $occurrences = 1 } # Extract suggested resolution $suggestedFix = '' $fixMatch = [regex]::Match($section, '(?:Suggested\s*(?:Permanent\s*)?Fix|Resolution|Recommendation)[\s:]*(.+?)(?=\n\s*(?:\*\*|#{1,3}|\d+\.)|\z)', 'IgnoreCase,Singleline') if ($fixMatch.Success) { $suggestedFix = $fixMatch.Groups[1].Value.Trim() } else { # Fallback: use the last paragraph $suggestedFix = ($lines | Select-Object -Last 3) -join ' ' } # Determine date range from matched tickets $firstSeen = '' $lastSeen = '' if ($ticketNumbers.Count -gt 0) { $matchedTickets = $Tickets | Where-Object { $_.Number -in $ticketNumbers } | Sort-Object { try { [datetime]$_.OpenedAt } catch { [datetime]::MinValue } } if ($matchedTickets) { $firstSeen = ($matchedTickets | Select-Object -First 1).OpenedAt $lastSeen = ($matchedTickets | Select-Object -Last 1).OpenedAt } } $patterns += [PSCustomObject]@{ Pattern = $patternName Occurrences = $occurrences TicketNumbers = $ticketNumbers FirstSeen = $firstSeen LastSeen = $lastSeen SuggestedResolution = $suggestedFix EstimatedTimeSaved = "Approximately $([math]::Round($occurrences * 0.5, 1)) hours per recurrence" Source = 'AI' } } # If AI parsing yielded no structured patterns, return the raw response as a single pattern if ($patterns.Count -eq 0) { $patterns += [PSCustomObject]@{ Pattern = 'AI Analysis (unstructured)' Occurrences = $Tickets.Count TicketNumbers = @($Tickets | ForEach-Object { $_.Number }) FirstSeen = ($Tickets | Select-Object -First 1).OpenedAt LastSeen = ($Tickets | Select-Object -Last 1).OpenedAt SuggestedResolution = $AIResponse EstimatedTimeSaved = 'See analysis details' Source = 'AI' } } return @($patterns | Sort-Object Occurrences -Descending) } |