mirror of
https://github.com/actions/runner-images.git
synced 2025-12-10 19:16:48 +00:00
Implement Software Report Base Module (#6707)
* Implement first version * fix tables rendering * Fix scripts * update test files * implement calculate image diff script * Polish code and make e2e validation * remove test files * render add and removed firstly
This commit is contained in:
48
helpers/software-report-base/Calculate-ImagesDiff.ps1
Normal file
48
helpers/software-report-base/Calculate-ImagesDiff.ps1
Normal file
@@ -0,0 +1,48 @@
|
||||
using module ./SoftwareReport.psm1
|
||||
using module ./SoftwareReport.Comparer.psm1
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Calculates the difference between two software reports and saves it to a file.
|
||||
.PARAMETER PreviousJsonReportPath
|
||||
Path to the previous software report.
|
||||
.PARAMETER CurrentJsonReportPath
|
||||
Path to the current software report.
|
||||
.PARAMETER OutputFile
|
||||
Path to the file where the difference will be saved.
|
||||
#>
|
||||
|
||||
Param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string] $PreviousJsonReportPath,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string] $CurrentJsonReportPath,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string] $OutputFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$global:ErrorView = "NormalView"
|
||||
|
||||
function Read-SoftwareReport {
|
||||
Param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string] $JsonReportPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path $JsonReportPath)) {
|
||||
throw "File '$JsonReportPath' does not exist"
|
||||
}
|
||||
|
||||
$jsonReport = Get-Content -Path $JsonReportPath -Raw
|
||||
$report = [SoftwareReport]::FromJson($jsonReport)
|
||||
return $report
|
||||
}
|
||||
|
||||
$previousReport = Read-SoftwareReport -JsonReportPath $PreviousJsonReportPath
|
||||
$currentReport = Read-SoftwareReport -JsonReportPath $CurrentJsonReportPath
|
||||
|
||||
$comparer = [SoftwareReportComparer]::new($previousReport, $currentReport)
|
||||
$comparer.CompareReports()
|
||||
$diff = $comparer.GetMarkdownReport()
|
||||
$diff | Out-File -Path $OutputFile -Encoding utf8NoBOM
|
||||
47
helpers/software-report-base/SoftwareReport.BaseNodes.psm1
Normal file
47
helpers/software-report-base/SoftwareReport.BaseNodes.psm1
Normal file
@@ -0,0 +1,47 @@
|
||||
############################
|
||||
### Abstract base nodes ####
|
||||
############################
|
||||
|
||||
# Abstract base class for all nodes
|
||||
class BaseNode {
|
||||
[Boolean] ShouldBeIncludedToDiff() {
|
||||
return $False
|
||||
}
|
||||
|
||||
[Boolean] IsSimilarTo([BaseNode] $OtherNode) {
|
||||
throw "Abtract method 'IsSimilarTo' is not implemented for '$($this.GetType().Name)'"
|
||||
}
|
||||
|
||||
[Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
|
||||
throw "Abtract method 'IsIdenticalTo' is not implemented for '$($this.GetType().Name)'"
|
||||
}
|
||||
}
|
||||
|
||||
# Abstract base class for all nodes that describe a tool and should be rendered inside diff table
|
||||
class BaseToolNode: BaseNode {
|
||||
[String] $ToolName
|
||||
|
||||
BaseToolNode([String] $ToolName) {
|
||||
$this.ToolName = $ToolName
|
||||
}
|
||||
|
||||
[Boolean] ShouldBeIncludedToDiff() {
|
||||
return $True
|
||||
}
|
||||
|
||||
[String] GetValue() {
|
||||
throw "Abtract method 'GetValue' is not implemented for '$($this.GetType().Name)'"
|
||||
}
|
||||
|
||||
[Boolean] IsSimilarTo([BaseNode] $OtherNode) {
|
||||
if ($this.GetType() -ne $OtherNode.GetType()) {
|
||||
return $False
|
||||
}
|
||||
|
||||
return $this.ToolName -eq $OtherNode.ToolName
|
||||
}
|
||||
|
||||
[Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
|
||||
return $this.IsSimilarTo($OtherNode) -and ($this.GetValue() -eq $OtherNode.GetValue())
|
||||
}
|
||||
}
|
||||
303
helpers/software-report-base/SoftwareReport.Comparer.psm1
Normal file
303
helpers/software-report-base/SoftwareReport.Comparer.psm1
Normal file
@@ -0,0 +1,303 @@
|
||||
using module ./SoftwareReport.psm1
|
||||
using module ./SoftwareReport.BaseNodes.psm1
|
||||
using module ./SoftwareReport.Nodes.psm1
|
||||
|
||||
|
||||
# SoftwareReportComparer is used to calculate differences between two SoftwareReport objects
|
||||
class SoftwareReportComparer {
|
||||
hidden [SoftwareReport] $PreviousReport
|
||||
hidden [SoftwareReport] $CurrentReport
|
||||
|
||||
hidden [Collections.Generic.List[ReportDifferenceItem]] $AddedItems
|
||||
hidden [Collections.Generic.List[ReportDifferenceItem]] $ChangedItems
|
||||
hidden [Collections.Generic.List[ReportDifferenceItem]] $DeletedItems
|
||||
|
||||
SoftwareReportComparer([SoftwareReport] $PreviousReport, [SoftwareReport] $CurrentReport) {
|
||||
$this.PreviousReport = $PreviousReport
|
||||
$this.CurrentReport = $CurrentReport
|
||||
}
|
||||
|
||||
[void] CompareReports() {
|
||||
$this.AddedItems = @()
|
||||
$this.ChangedItems = @()
|
||||
$this.DeletedItems = @()
|
||||
|
||||
$this.CompareInternal($this.PreviousReport.Root, $this.CurrentReport.Root, @())
|
||||
}
|
||||
|
||||
hidden [void] CompareInternal([HeaderNode] $previousReportPointer, [HeaderNode] $currentReportPointer, [Array] $Headers) {
|
||||
$currentReportPointer.Children ?? @() | Where-Object { $_.ShouldBeIncludedToDiff() -and $this.FilterExcludedNodes($_) } | ForEach-Object {
|
||||
$currentReportNode = $_
|
||||
$sameNodeInPreviousReport = $previousReportPointer ? $previousReportPointer.FindSimilarChildNode($currentReportNode) : $null
|
||||
|
||||
if ($currentReportNode -is [HeaderNode]) {
|
||||
$this.CompareInternal($sameNodeInPreviousReport, $currentReportNode, $Headers + $currentReportNode.Title)
|
||||
} else {
|
||||
if ($sameNodeInPreviousReport -and ($currentReportNode.IsIdenticalTo($sameNodeInPreviousReport))) {
|
||||
# Nodes are identical, nothing changed, just ignore it
|
||||
} elseif ($sameNodeInPreviousReport) {
|
||||
# Nodes are equal but not identical, so something was changed
|
||||
$this.ChangedItems.Add([ReportDifferenceItem]::new($sameNodeInPreviousReport, $currentReportNode, $Headers))
|
||||
} else {
|
||||
# Node was not found in previous report, new node was added
|
||||
$this.AddedItems.Add([ReportDifferenceItem]::new($null, $currentReportNode, $Headers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Detecting nodes that were removed
|
||||
$previousReportPointer.Children ?? @() | Where-Object { $_.ShouldBeIncludedToDiff() -and $this.FilterExcludedNodes($_) } | ForEach-Object {
|
||||
$previousReportNode = $_
|
||||
$sameNodeInCurrentReport = $currentReportPointer ? $currentReportPointer.FindSimilarChildNode($previousReportNode) : $null
|
||||
|
||||
if (-not $sameNodeInCurrentReport) {
|
||||
if ($previousReportNode -is [HeaderNode]) {
|
||||
$this.CompareInternal($previousReportNode, $null, $Headers + $previousReportNode.Title)
|
||||
} else {
|
||||
$this.DeletedItems.Add([ReportDifferenceItem]::new($previousReportNode, $null, $Headers))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[String] GetMarkdownReport() {
|
||||
$reporter = [SoftwareReportComparerReport]::new()
|
||||
$report = $reporter.GenerateMarkdownReport($this.CurrentReport, $this.PreviousReport, $this.AddedItems, $this.ChangedItems, $this.DeletedItems)
|
||||
return $report
|
||||
}
|
||||
|
||||
hidden [Boolean] FilterExcludedNodes([BaseNode] $Node) {
|
||||
# We shouldn't show "Image Version" diff because it is already shown in report header
|
||||
if (($Node -is [ToolNode]) -and ($Node.ToolName -eq "Image Version:")) {
|
||||
return $False
|
||||
}
|
||||
|
||||
return $True
|
||||
}
|
||||
}
|
||||
|
||||
# SoftwareReportComparerReport is used to render results of SoftwareReportComparer in markdown format
|
||||
class SoftwareReportComparerReport {
|
||||
[String] GenerateMarkdownReport([SoftwareReport] $CurrentReport, [SoftwareReport] $PreviousReport, [ReportDifferenceItem[]] $AddedItems, [ReportDifferenceItem[]] $ChangedItems, [ReportDifferenceItem[]] $DeletedItems) {
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
|
||||
$rootNode = $CurrentReport.Root
|
||||
$imageVersion = $this.GetImageVersion($CurrentReport)
|
||||
$previousImageVersion = $this.GetImageVersion($PreviousReport)
|
||||
|
||||
#############################
|
||||
### Render report header ####
|
||||
#############################
|
||||
|
||||
$sb.AppendLine("# :desktop_computer: $($rootNode.Title)")
|
||||
|
||||
# ToolNodes on root level contains main image description so just copy-paste them to final report
|
||||
$rootNode.Children | Where-Object { $_ -is [BaseToolNode] } | ForEach-Object {
|
||||
$sb.AppendLine($_.ToMarkdown(0))
|
||||
}
|
||||
$sb.AppendLine()
|
||||
|
||||
$sb.AppendLine("## :mega: What's changed?")
|
||||
|
||||
###########################
|
||||
### Render added items ####
|
||||
###########################
|
||||
|
||||
[ReportDifferenceItem[]] $addedItemsExcludeTables = $AddedItems | Where-Object { $_.IsBaseToolNode() }
|
||||
if ($addedItemsExcludeTables.Count -gt 0) {
|
||||
$tableItems = $addedItemsExcludeTables | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
"Category" = $this.RenderCategory($_.Headers, $True);
|
||||
"Tool name" = $this.RenderToolName($_.CurrentReportNode.ToolName);
|
||||
"Current ($imageVersion)" = $_.CurrentReportNode.GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
$sb.AppendLine("### Added :heavy_plus_sign:")
|
||||
$sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
|
||||
$sb.AppendLine()
|
||||
}
|
||||
|
||||
# Render added tables separately
|
||||
$AddedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
|
||||
$sb.AppendLine($this.RenderTableNodesDiff($_))
|
||||
}
|
||||
|
||||
#############################
|
||||
### Render deleted items ####
|
||||
#############################
|
||||
|
||||
[ReportDifferenceItem[]] $deletedItemsExcludeTables = $DeletedItems | Where-Object { $_.IsBaseToolNode() }
|
||||
if ($deletedItemsExcludeTables.Count -gt 0) {
|
||||
$tableItems = $deletedItemsExcludeTables | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
"Category" = $this.RenderCategory($_.Headers, $True);
|
||||
"Tool name" = $this.RenderToolName($_.PreviousReportNode.ToolName);
|
||||
"Previous ($previousImageVersion)" = $_.PreviousReportNode.GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
$sb.AppendLine("### Deleted :heavy_minus_sign:")
|
||||
$sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
|
||||
$sb.AppendLine()
|
||||
}
|
||||
|
||||
#############################
|
||||
### Render updated items ####
|
||||
#############################
|
||||
|
||||
[ReportDifferenceItem[]] $changedItemsExcludeTables = $ChangedItems | Where-Object { $_.IsBaseToolNode() }
|
||||
if ($changedItemsExcludeTables.Count -gt 0) {
|
||||
$tableItems = $changedItemsExcludeTables | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
"Category" = $this.RenderCategory($_.Headers, $True);
|
||||
"Tool name" = $this.RenderToolName($_.CurrentReportNode.ToolName);
|
||||
"Previous ($previousImageVersion)" = $_.PreviousReportNode.GetValue();
|
||||
"Current ($imageVersion)" = $_.CurrentReportNode.GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
$sb.AppendLine("### Updated")
|
||||
$sb.AppendLine($this.RenderHtmlTable($tableItems, "Category"))
|
||||
$sb.AppendLine()
|
||||
}
|
||||
|
||||
# Render updated tables separately
|
||||
$ChangedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
|
||||
$sb.AppendLine($this.RenderTableNodesDiff($_))
|
||||
}
|
||||
|
||||
# Render deleted tables separately
|
||||
$DeletedItems | Where-Object { $_.IsTableNode() } | ForEach-Object {
|
||||
$sb.AppendLine($this.RenderTableNodesDiff($_))
|
||||
}
|
||||
|
||||
return $sb.ToString()
|
||||
}
|
||||
|
||||
[String] RenderHtmlTable([PSCustomObject[]] $Table, $RowSpanColumnName) {
|
||||
$headers = $Table[0].PSObject.Properties.Name
|
||||
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
$sb.AppendLine("<table>")
|
||||
$sb.AppendLine(" <thead>")
|
||||
$headers | ForEach-Object {
|
||||
$sb.AppendLine(" <th>$_</th>")
|
||||
}
|
||||
$sb.AppendLine(" </thead>")
|
||||
$sb.AppendLine(" <tbody>")
|
||||
|
||||
|
||||
$tableRowSpans = $this.CalculateHtmlTableRowSpan($Table, $RowSpanColumnName)
|
||||
for ($rowIndex = 0; $rowIndex -lt $Table.Count; $rowIndex++) {
|
||||
$row = $Table[$rowIndex]
|
||||
|
||||
$sb.AppendLine(" <tr>")
|
||||
$headers | ForEach-Object {
|
||||
if ($_ -eq $RowSpanColumnName) {
|
||||
if ($tableRowSpans[$rowIndex] -gt 0) {
|
||||
$sb.AppendLine(" <td rowspan=$($tableRowSpans[$rowIndex])>$($row.$_)</td>")
|
||||
} else {
|
||||
# Skip rendering this cell at all
|
||||
}
|
||||
} else {
|
||||
$sb.AppendLine(" <td>$($row.$_)</td>")
|
||||
}
|
||||
}
|
||||
$sb.AppendLine(" </tr>")
|
||||
}
|
||||
$sb.AppendLine(" </tbody>")
|
||||
$sb.AppendLine("</table>")
|
||||
|
||||
return $sb.ToString()
|
||||
}
|
||||
|
||||
[int[]] CalculateHtmlTableRowSpan([PSCustomObject[]] $Table, $keyColumn) {
|
||||
$result = @(0) * $Table.Count
|
||||
|
||||
for ($rowIndex = $Table.Count - 1; $rowIndex -ge 0; $rowIndex--) {
|
||||
if (($rowIndex -lt ($Table.Count - 1)) -and ($Table[$rowIndex].$keyColumn -eq $Table[$rowIndex + 1].$keyColumn)) {
|
||||
# If the current row is the same as the next row
|
||||
# Then rowspan of current row should be equal to rowspan of the next row + 1
|
||||
# And rowspan of the next row should be 0 because it is already included in the rowspan of the current row
|
||||
$result[$rowIndex] = $result[$rowIndex + 1] + 1
|
||||
$result[$rowIndex + 1] = 0
|
||||
} else {
|
||||
$result[$rowIndex] = 1
|
||||
}
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
[String] RenderTableNodesDiff([ReportDifferenceItem] $DiffItem) {
|
||||
# Use the simplest approach for now: first, print all removed lines. Then print added lines
|
||||
# It will work well for most cases like changing existing rows, adding new rows and removing rows
|
||||
# But can produce not so pretty results for cases when some rows are changed and some rows are added at the same time
|
||||
# Let's see how it works in practice and improve it later if needed
|
||||
|
||||
[String] $tableHeaders = ($DiffItem.CurrentReportNode ?? $DiffItem.PreviousReportNode).Headers
|
||||
[System.Collections.ArrayList] $tableRows = @()
|
||||
$DiffItem.PreviousReportNode.Rows ?? @() | Where-Object { $_ -notin $DiffItem.CurrentReportNode.Rows } | ForEach-Object {
|
||||
$tableRows.Add($this.StrikeTableRow($_))
|
||||
}
|
||||
$DiffItem.CurrentReportNode.Rows ?? @() | Where-Object { $_ -notin $DiffItem.PreviousReportNode.Rows } | ForEach-Object {
|
||||
$tableRows.Add($_)
|
||||
}
|
||||
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
$sb.AppendLine("#### $($this.RenderCategory($DiffItem.Headers, $False))")
|
||||
$sb.AppendLine([TableNode]::new($tableHeaders, $tableRows).ToMarkdown(0))
|
||||
return $sb.ToString()
|
||||
}
|
||||
|
||||
[String] RenderCategory([Array] $Headers, [Boolean] $AddLineSeparator) {
|
||||
# Always skip the first header because it is "Installed Software"
|
||||
[Array] $takeHeaders = $Headers | Select-Object -Skip 1
|
||||
if ($takeHeaders.Count -eq 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$lineSeparator = $AddLineSeparator ? "<br>": ""
|
||||
return [String]::Join(" >$lineSeparator ", $takeHeaders)
|
||||
}
|
||||
|
||||
[String] RenderToolName([String] $ToolName) {
|
||||
return $ToolName.TrimEnd(":")
|
||||
}
|
||||
|
||||
[String] StrikeTableRow([String] $Row) {
|
||||
# Convert "a|b|c" to "~~a~~|~~b~~|~~c~~
|
||||
$cells = $Row.Split("|")
|
||||
$strikedCells = $cells | ForEach-Object { "~~$($_)~~"}
|
||||
return [String]::Join("|", $strikedCells)
|
||||
}
|
||||
|
||||
[String] GetImageVersion([SoftwareReport] $Report) {
|
||||
$imageVersionNode = $Report.Root.Children ?? @() | Where-Object { ($_ -is [ToolNode]) -and ($_.ToolName -eq "Image Version:") } | Select-Object -First 1
|
||||
return $imageVersionNode.Version ?? "Unknown version"
|
||||
}
|
||||
}
|
||||
|
||||
# Temporary structure to store the single difference between two reports
|
||||
class ReportDifferenceItem {
|
||||
[BaseNode] $PreviousReportNode
|
||||
[BaseNode] $CurrentReportNode
|
||||
[Array] $Headers
|
||||
|
||||
ReportDifferenceItem([BaseNode] $PreviousReportNode, [BaseNode] $CurrentReportNode, [Array] $Headers) {
|
||||
$this.PreviousReportNode = $PreviousReportNode
|
||||
$this.CurrentReportNode = $CurrentReportNode
|
||||
$this.Headers = $Headers
|
||||
}
|
||||
|
||||
[Boolean] IsBaseToolNode() {
|
||||
$node = $this.CurrentReportNode ?? $this.PreviousReportNode
|
||||
return $node -is [BaseToolNode]
|
||||
}
|
||||
|
||||
[Boolean] IsTableNode() {
|
||||
$node = $this.CurrentReportNode ?? $this.PreviousReportNode
|
||||
return $node -is [TableNode]
|
||||
}
|
||||
}
|
||||
334
helpers/software-report-base/SoftwareReport.Nodes.psm1
Normal file
334
helpers/software-report-base/SoftwareReport.Nodes.psm1
Normal file
@@ -0,0 +1,334 @@
|
||||
using module ./SoftwareReport.BaseNodes.psm1
|
||||
|
||||
#########################################
|
||||
### Nodes to describe image software ####
|
||||
#########################################
|
||||
|
||||
# NodesFactory is used to simplify parsing different types of notes
|
||||
# Every node has own logic of parsing and this method just invokes "FromJsonObject" of correct node type
|
||||
class NodesFactory {
|
||||
static [BaseNode] ParseNodeFromObject($jsonObj) {
|
||||
if ($jsonObj.NodeType -eq [HeaderNode].Name) {
|
||||
return [HeaderNode]::FromJsonObject($jsonObj)
|
||||
} elseif ($jsonObj.NodeType -eq [ToolNode].Name) {
|
||||
return [ToolNode]::FromJsonObject($jsonObj)
|
||||
} elseif ($jsonObj.NodeType -eq [ToolVersionsNode].Name) {
|
||||
return [ToolVersionsNode]::FromJsonObject($jsonObj)
|
||||
} elseif ($jsonObj.NodeType -eq [TableNode].Name) {
|
||||
return [TableNode]::FromJsonObject($jsonObj)
|
||||
} elseif ($jsonObj.NodeType -eq [NoteNode].Name) {
|
||||
return [NoteNode]::FromJsonObject($jsonObj)
|
||||
}
|
||||
|
||||
throw "Unknown node type in ParseNodeFromObject '$($jsonObj.NodeType)'"
|
||||
}
|
||||
}
|
||||
|
||||
# Node type to describe headers: "## Installed software"
|
||||
class HeaderNode: BaseNode {
|
||||
[String] $Title
|
||||
[System.Collections.ArrayList] $Children
|
||||
|
||||
HeaderNode([String] $Title) {
|
||||
$this.Title = $Title
|
||||
$this.Children = @()
|
||||
}
|
||||
|
||||
[Boolean] ShouldBeIncludedToDiff() {
|
||||
return $True
|
||||
}
|
||||
|
||||
[void] AddNode([BaseNode] $node) {
|
||||
$similarNode = $this.FindSimilarChildNode($node)
|
||||
if ($similarNode) {
|
||||
throw "This HeaderNode already contains the similar child node. It is not allowed to add the same node twice.`nFound node: $($similarNode.ToJsonObject() | ConvertTo-Json)`nNew node: $($node.ToJsonObject() | ConvertTo-Json)"
|
||||
}
|
||||
|
||||
$this.Children.Add($node)
|
||||
}
|
||||
|
||||
[void] AddNodes([Array] $nodes) {
|
||||
$nodes | ForEach-Object {
|
||||
$this.AddNode($_)
|
||||
}
|
||||
}
|
||||
|
||||
[HeaderNode] AddHeaderNode([String] $Title) {
|
||||
$node = [HeaderNode]::new($Title)
|
||||
$this.AddNode($node)
|
||||
return $node
|
||||
}
|
||||
|
||||
[void] AddToolNode([String] $ToolName, [String] $Version) {
|
||||
$this.AddNode([ToolNode]::new($ToolName, $Version))
|
||||
}
|
||||
|
||||
[void] AddToolVersionsNode([String] $ToolName, [Array] $Version) {
|
||||
$this.AddNode([ToolVersionsNode]::new($ToolName, $Version))
|
||||
}
|
||||
|
||||
[void] AddTableNode([Array] $Table) {
|
||||
$this.AddNode([TableNode]::FromObjectsArray($Table))
|
||||
}
|
||||
|
||||
[void] AddNoteNode([String] $Content) {
|
||||
$this.AddNode([NoteNode]::new($Content))
|
||||
}
|
||||
|
||||
[String] ToMarkdown($level) {
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
$sb.AppendLine()
|
||||
$sb.AppendLine("$("#" * $level) $($this.Title)")
|
||||
$this.Children | ForEach-Object {
|
||||
$sb.AppendLine($_.ToMarkdown($level + 1))
|
||||
}
|
||||
|
||||
return $sb.ToString().TrimEnd()
|
||||
}
|
||||
|
||||
[PSCustomObject] ToJsonObject() {
|
||||
return [PSCustomObject]@{
|
||||
NodeType = $this.GetType().Name
|
||||
Title = $this.Title
|
||||
Children = $this.Children | ForEach-Object { $_.ToJsonObject() }
|
||||
}
|
||||
}
|
||||
|
||||
static [HeaderNode] FromJsonObject($jsonObj) {
|
||||
$node = [HeaderNode]::new($jsonObj.Title)
|
||||
$jsonObj.Children | Where-Object { $_ } | ForEach-Object { $node.AddNode([NodesFactory]::ParseNodeFromObject($_)) }
|
||||
return $node
|
||||
}
|
||||
|
||||
[Boolean] IsSimilarTo([BaseNode] $OtherNode) {
|
||||
if ($OtherNode.GetType() -ne [HeaderNode]) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $this.Title -eq $OtherNode.Title
|
||||
}
|
||||
|
||||
[Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
|
||||
return $this.IsSimilarTo($OtherNode)
|
||||
}
|
||||
|
||||
[BaseNode] FindSimilarChildNode([BaseNode] $Find) {
|
||||
foreach ($childNode in $this.Children) {
|
||||
if ($childNode.IsSimilarTo($Find)) {
|
||||
return $childNode
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Node type to describe the tool with single version: "Bash 5.1.16"
|
||||
class ToolNode: BaseToolNode {
|
||||
[String] $Version
|
||||
|
||||
ToolNode([String] $ToolName, [String] $Version): base($ToolName) {
|
||||
$this.Version = $Version
|
||||
}
|
||||
|
||||
[String] ToMarkdown($level) {
|
||||
return "- $($this.ToolName) $($this.Version)"
|
||||
}
|
||||
|
||||
[String] GetValue() {
|
||||
return $this.Version
|
||||
}
|
||||
|
||||
[PSCustomObject] ToJsonObject() {
|
||||
return [PSCustomObject]@{
|
||||
NodeType = $this.GetType().Name
|
||||
ToolName = $this.ToolName
|
||||
Version = $this.Version
|
||||
}
|
||||
}
|
||||
|
||||
static [BaseNode] FromJsonObject($jsonObj) {
|
||||
return [ToolNode]::new($jsonObj.ToolName, $jsonObj.Version)
|
||||
}
|
||||
}
|
||||
|
||||
# Node type to describe the tool with multiple versions "Toolcache Node.js 14.17.6 16.2.0 18.2.3"
|
||||
class ToolVersionsNode: BaseToolNode {
|
||||
[Array] $Versions
|
||||
|
||||
ToolVersionsNode([String] $ToolName, [Array] $Versions): base($ToolName) {
|
||||
$this.Versions = $Versions
|
||||
}
|
||||
|
||||
[String] ToMarkdown($level) {
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
$sb.AppendLine()
|
||||
$sb.AppendLine("$("#" * $level) $($this.ToolName)")
|
||||
$this.Versions | ForEach-Object {
|
||||
$sb.AppendLine("- $_")
|
||||
}
|
||||
|
||||
return $sb.ToString().TrimEnd()
|
||||
}
|
||||
|
||||
[String] GetValue() {
|
||||
return $this.Versions -join ', '
|
||||
}
|
||||
|
||||
[PSCustomObject] ToJsonObject() {
|
||||
return [PSCustomObject]@{
|
||||
NodeType = $this.GetType().Name
|
||||
ToolName = $this.ToolName
|
||||
Versions = $this.Versions
|
||||
}
|
||||
}
|
||||
|
||||
static [ToolVersionsNode] FromJsonObject($jsonObj) {
|
||||
return [ToolVersionsNode]::new($jsonObj.ToolName, $jsonObj.Versions)
|
||||
}
|
||||
}
|
||||
|
||||
# Node type to describe tables
|
||||
class TableNode: BaseNode {
|
||||
# It is easier to store the table as rendered lines because it will simplify finding differences in rows later
|
||||
[String] $Headers
|
||||
[System.Collections.ArrayList] $Rows
|
||||
|
||||
TableNode($Headers, $Rows) {
|
||||
$this.Headers = $Headers
|
||||
$this.Rows = $Rows
|
||||
}
|
||||
|
||||
[Boolean] ShouldBeIncludedToDiff() {
|
||||
return $True
|
||||
}
|
||||
|
||||
static [TableNode] FromObjectsArray([Array] $Table) {
|
||||
# take column names from the first row in table because we expect all rows to have the same columns
|
||||
[String] $tableHeaders = [TableNode]::ArrayToTableRow($Table[0].PSObject.Properties.Name)
|
||||
[System.Collections.ArrayList] $tableRows = @()
|
||||
|
||||
$Table | ForEach-Object {
|
||||
$tableRows.Add([TableNode]::ArrayToTableRow($_.PSObject.Properties.Value))
|
||||
}
|
||||
|
||||
return [TableNode]::new($tableHeaders, $tableRows)
|
||||
}
|
||||
|
||||
[String] ToMarkdown($level) {
|
||||
$maxColumnWidths = $this.Headers.Split("|") | ForEach-Object { $_.Length }
|
||||
$columnsCount = $maxColumnWidths.Count
|
||||
|
||||
$this.Rows | ForEach-Object {
|
||||
$columnWidths = $_.Split("|") | ForEach-Object { $_.Length }
|
||||
for ($colIndex = 0; $colIndex -lt $columnsCount; $colIndex++) {
|
||||
$maxColumnWidths[$colIndex] = [Math]::Max($maxColumnWidths[$colIndex], $columnWidths[$colIndex])
|
||||
}
|
||||
}
|
||||
|
||||
$delimeterLine = [String]::Join("|", @("-") * $columnsCount)
|
||||
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
@($this.Headers) + @($delimeterLine) + $this.Rows | ForEach-Object {
|
||||
$sb.Append("|")
|
||||
$row = $_.Split("|")
|
||||
|
||||
for ($colIndex = 0; $colIndex -lt $columnsCount; $colIndex++) {
|
||||
$padSymbol = $row[$colIndex] -eq "-" ? "-" : " "
|
||||
$cellContent = $row[$colIndex].PadRight($maxColumnWidths[$colIndex], $padSymbol)
|
||||
$sb.Append(" $($cellContent) |")
|
||||
}
|
||||
|
||||
$sb.AppendLine()
|
||||
}
|
||||
|
||||
return $sb.ToString().TrimEnd()
|
||||
}
|
||||
|
||||
[PSCustomObject] ToJsonObject() {
|
||||
return [PSCustomObject]@{
|
||||
NodeType = $this.GetType().Name
|
||||
Headers = $this.Headers
|
||||
Rows = $this.Rows
|
||||
}
|
||||
}
|
||||
|
||||
static [TableNode] FromJsonObject($jsonObj) {
|
||||
return [TableNode]::new($jsonObj.Headers, $jsonObj.Rows)
|
||||
}
|
||||
|
||||
[Boolean] IsSimilarTo([BaseNode] $OtherNode) {
|
||||
if ($OtherNode.GetType() -ne [TableNode]) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# We don't support having multiple TableNode instances on the same header level so such check is fine
|
||||
return $true
|
||||
}
|
||||
|
||||
[Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
|
||||
if (-not $this.IsSimilarTo($OtherNode)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($this.Headers -ne $OtherNode.Headers) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($this.Rows.Count -ne $OtherNode.Rows.Count) {
|
||||
return $false
|
||||
}
|
||||
|
||||
for ($rowIndex = 0; $rowIndex -lt $this.Rows.Count; $rowIndex++) {
|
||||
if ($this.Rows[$rowIndex] -ne $OtherNode.Rows[$rowIndex]) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
hidden static [String] ArrayToTableRow([Array] $Values) {
|
||||
# TO-DO: Add validation for the case when $Values contains "|"
|
||||
return [String]::Join("|", $Values)
|
||||
}
|
||||
}
|
||||
|
||||
class NoteNode: BaseNode {
|
||||
[String] $Content
|
||||
|
||||
NoteNode([String] $Content) {
|
||||
$this.Content = $Content
|
||||
}
|
||||
|
||||
[String] ToMarkdown($level) {
|
||||
return @(
|
||||
'```',
|
||||
$this.Content,
|
||||
'```'
|
||||
) -join "`n"
|
||||
}
|
||||
|
||||
[PSCustomObject] ToJsonObject() {
|
||||
return [PSCustomObject]@{
|
||||
NodeType = $this.GetType().Name
|
||||
Content = $this.Content
|
||||
}
|
||||
}
|
||||
|
||||
static [NoteNode] FromJsonObject($jsonObj) {
|
||||
return [NoteNode]::new($jsonObj.Content)
|
||||
}
|
||||
|
||||
[Boolean] IsSimilarTo([BaseNode] $OtherNode) {
|
||||
if ($OtherNode.GetType() -ne [NoteNode]) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $this.Content -eq $OtherNode.Content
|
||||
}
|
||||
|
||||
[Boolean] IsIdenticalTo([BaseNode] $OtherNode) {
|
||||
return $this.IsSimilarTo($OtherNode)
|
||||
}
|
||||
}
|
||||
28
helpers/software-report-base/SoftwareReport.psm1
Normal file
28
helpers/software-report-base/SoftwareReport.psm1
Normal file
@@ -0,0 +1,28 @@
|
||||
using module ./SoftwareReport.BaseNodes.psm1
|
||||
using module ./SoftwareReport.Nodes.psm1
|
||||
|
||||
class SoftwareReport {
|
||||
[HeaderNode] $Root
|
||||
|
||||
SoftwareReport([String] $Title) {
|
||||
$this.Root = [HeaderNode]::new($Title)
|
||||
}
|
||||
|
||||
SoftwareReport([HeaderNode] $Root) {
|
||||
$this.Root = $Root
|
||||
}
|
||||
|
||||
[String] ToJson() {
|
||||
return $this.Root.ToJsonObject() | ConvertTo-Json -Depth 10
|
||||
}
|
||||
|
||||
static [SoftwareReport] FromJson($jsonString) {
|
||||
$jsonObj = $jsonString | ConvertFrom-Json
|
||||
$rootNode = [NodesFactory]::ParseNodeFromObject($jsonObj)
|
||||
return [SoftwareReport]::new($rootNode)
|
||||
}
|
||||
|
||||
[String] ToMarkdown() {
|
||||
return $this.Root.ToMarkdown(1).Trim()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user