github.com/ouraigua/jenkins-library@v0.0.0-20231028010029-fbeaf2f3aa9b/pkg/reporting/reporting.go (about) 1 package reporting 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "text/template" 8 "time" 9 10 "github.com/pkg/errors" 11 ) 12 13 // IssueDetail represents any content that can be transformed into the body of a GitHub issue 14 type IssueDetail interface { 15 Title() string 16 ToMarkdown() ([]byte, error) 17 ToTxt() string 18 } 19 20 // ScanReport defines the elements of a scan report used by various scan steps 21 type ScanReport struct { 22 StepName string `json:"stepName"` 23 ReportTitle string `json:"title"` 24 Subheaders []Subheader `json:"subheaders"` 25 Overview []OverviewRow `json:"overview"` 26 FurtherInfo string `json:"furtherInfo"` 27 ReportTime time.Time `json:"reportTime"` 28 DetailTable ScanDetailTable `json:"detailTable"` 29 SuccessfulScan bool `json:"successfulScan"` 30 } 31 32 // ScanDetailTable defines a table containing scan result details 33 type ScanDetailTable struct { 34 Headers []string `json:"headers"` 35 Rows []ScanRow `json:"rows"` 36 WithCounter bool `json:"withCounter"` 37 CounterHeader string `json:"counterHeader"` 38 NoRowsMessage string `json:"noRowsMessage"` 39 } 40 41 // ScanRow defines one row of a scan result table 42 type ScanRow struct { 43 Columns []ScanCell `json:"columns"` 44 } 45 46 // AddColumn adds a column to a dedicated ScanRow 47 func (s *ScanRow) AddColumn(content interface{}, style ColumnStyle) { 48 if s.Columns == nil { 49 s.Columns = []ScanCell{} 50 } 51 s.Columns = append(s.Columns, ScanCell{Content: fmt.Sprint(content), Style: style}) 52 } 53 54 // ScanCell defines one column of a scan result table 55 type ScanCell struct { 56 Content string `json:"content"` 57 Style ColumnStyle `json:"style"` 58 } 59 60 // ColumnStyle defines style for a specific column 61 type ColumnStyle int 62 63 // enum for style types 64 const ( 65 Green = iota + 1 66 Yellow 67 Red 68 Grey 69 Black 70 ) 71 72 func (c ColumnStyle) String() string { 73 return [...]string{"", "green-cell", "yellow-cell", "red-cell", "grey-cell", "black-cell"}[c] 74 } 75 76 // OverviewRow defines a row in the report's overview section 77 // it can consist of a description and some details where the details can have a style attached 78 type OverviewRow struct { 79 Description string `json:"description"` 80 Details string `json:"details,omitempty"` 81 Style ColumnStyle `json:"style,omitempty"` 82 } 83 84 // Subheader defines a dedicated sub header in a report 85 type Subheader struct { 86 Description string `json:"text"` 87 Details string `json:"details,omitempty"` 88 } 89 90 // AddSubHeader adds a sub header to the report containing of a text/title plus optional details 91 func (s *ScanReport) AddSubHeader(header, details string) { 92 s.Subheaders = append(s.Subheaders, Subheader{Description: header, Details: details}) 93 } 94 95 // StepReportDirectory specifies the default directory for markdown reports which can later be collected by step pipelineCreateSummary 96 const StepReportDirectory = ".pipeline/stepReports" 97 98 // ToJSON returns the report in JSON format 99 func (s *ScanReport) ToJSON() ([]byte, error) { 100 return json.Marshal(s) 101 } 102 103 // ToTxt up to now returns the report in JSON format 104 func (s ScanReport) ToTxt() string { 105 txt, _ := s.ToJSON() 106 return string(txt) 107 } 108 109 const reportHTMLTemplate = `<!DOCTYPE html> 110 <html> 111 <head> 112 <title>{{.Title}}</title> 113 <style type="text/css"> 114 body { 115 font-family: Arial, Verdana; 116 } 117 table { 118 border-collapse: collapse; 119 } 120 div.code { 121 font-family: "Courier New", "Lucida Console"; 122 } 123 th { 124 border-top: 1px solid #ddd; 125 } 126 th, td { 127 padding: 12px; 128 text-align: left; 129 border-bottom: 1px solid #ddd; 130 border-right: 1px solid #ddd; 131 } 132 tr:nth-child(even) { 133 background-color: #f2f2f2; 134 } 135 .bold { 136 font-weight: bold; 137 } 138 .green{ 139 color: olivedrab; 140 } 141 .red{ 142 color: orangered; 143 } 144 .nobullets { 145 list-style-type:none; 146 padding-left: 0; 147 padding-bottom: 0; 148 margin: 0; 149 } 150 .green-cell { 151 background-color: #e1f5a9; 152 padding: 5px 153 } 154 .yellow-cell { 155 background-color: #ffff99; 156 padding: 5px 157 } 158 .red-cell { 159 background-color: #ffe5e5; 160 padding: 5px 161 } 162 .grey-cell{ 163 background-color: rgba(212, 212, 212, 0.7); 164 padding: 5px; 165 } 166 .black-cell{ 167 background-color: rgba(0, 0, 0, 0.75); 168 padding: 5px; 169 } 170 </style> 171 </head> 172 <body> 173 <h1>{{.Title}}</h1> 174 <h2> 175 <span> 176 {{range $s := .Subheaders}} 177 {{- $s.Description}}: {{$s.Details}}<br /> 178 {{end -}} 179 </span> 180 </h2> 181 <div> 182 <h3> 183 {{range $o := .Overview}} 184 {{- drawOverviewRow $o}}<br /> 185 {{end -}} 186 </h3> 187 <span>{{.FurtherInfo}}</span> 188 </div> 189 <p>Snapshot taken: {{reportTime .ReportTime}}</p> 190 <table> 191 <tr> 192 {{if .DetailTable.WithCounter}}<th>{{.DetailTable.CounterHeader}}</th>{{end}} 193 {{- range $h := .DetailTable.Headers}} 194 <th>{{$h}}</th> 195 {{- end}} 196 </tr> 197 {{range $i, $r := .DetailTable.Rows}} 198 <tr> 199 {{if $.DetailTable.WithCounter}}<td>{{inc $i}}</td>{{end}} 200 {{- range $c := $r.Columns}} 201 {{drawCell $c}} 202 {{- end}} 203 </tr> 204 {{else}} 205 <tr><td colspan="{{columnCount .DetailTable}}">{{.DetailTable.NoRowsMessage}}</td></tr> 206 {{- end}} 207 </table> 208 </body> 209 </html> 210 ` 211 212 // ToHTML creates a HTML version of the report 213 func (s *ScanReport) ToHTML() ([]byte, error) { 214 funcMap := template.FuncMap{ 215 "inc": func(i int) int { 216 return i + 1 217 }, 218 "reportTime": func(currentTime time.Time) string { 219 return currentTime.Format("Jan 02, 2006 - 15:04:05 MST") 220 }, 221 "columnCount": tableColumnCount, 222 "drawCell": drawCell, 223 "drawOverviewRow": drawOverviewRow, 224 } 225 report := []byte{} 226 tmpl, err := template.New("report").Funcs(funcMap).Parse(reportHTMLTemplate) 227 if err != nil { 228 return report, errors.Wrap(err, "failed to create HTML report template") 229 } 230 buf := new(bytes.Buffer) 231 err = tmpl.Execute(buf, s) 232 if err != nil { 233 return report, errors.Wrap(err, "failed to execute HTML report template") 234 } 235 return buf.Bytes(), nil 236 } 237 238 const reportMdTemplate = `## {{if .SuccessfulScan}}:white_check_mark:{{else}}:x:{{end}} {{.Title}} 239 240 <table> 241 {{range $s := .Subheaders -}} 242 <tr><td><b>{{- $s.Description}}:</b></td><td>{{$s.Details}}</td></tr> 243 {{- end}} 244 245 {{range $o := .Overview -}} 246 {{drawOverviewRow $o}} 247 {{- end}} 248 </table> 249 250 {{.FurtherInfo}} 251 252 Snapshot taken: <i>{{reportTime .ReportTime}}</i> 253 254 {{if shouldDrawTable .DetailTable -}} 255 <details><summary><i>{{.Title}} details:</i></summary> 256 <p> 257 258 <table> 259 <tr> 260 {{if .DetailTable.WithCounter}}<th>{{.DetailTable.CounterHeader}}</th>{{end}} 261 {{- range $h := .DetailTable.Headers}} 262 <th>{{$h}}</th> 263 {{- end}} 264 </tr> 265 {{range $i, $r := .DetailTable.Rows}} 266 <tr> 267 {{if $.DetailTable.WithCounter}}<td>{{inc $i}}</td>{{end}} 268 {{- range $c := $r.Columns}} 269 {{drawCell $c}} 270 {{- end}} 271 </tr> 272 {{else}} 273 <tr><td colspan="{{columnCount .DetailTable}}">{{.DetailTable.NoRowsMessage}}</td></tr> 274 {{- end}} 275 </table> 276 </p> 277 </details> 278 {{ end }} 279 280 ` 281 282 // Title returns the title of the report 283 func (s ScanReport) Title() string { 284 return s.ReportTitle 285 } 286 287 // ToMarkdown creates a markdown version of the report content 288 func (s ScanReport) ToMarkdown() ([]byte, error) { 289 funcMap := template.FuncMap{ 290 "columnCount": tableColumnCount, 291 "drawCell": drawCell, 292 "shouldDrawTable": shouldDrawTable, 293 "inc": func(i int) int { 294 return i + 1 295 }, 296 "reportTime": func(currentTime time.Time) string { 297 return currentTime.Format("Jan 02, 2006 - 15:04:05 MST") 298 }, 299 "drawOverviewRow": drawOverviewRowMarkdown, 300 } 301 report := []byte{} 302 tmpl, err := template.New("report").Funcs(funcMap).Parse(reportMdTemplate) 303 if err != nil { 304 return report, errors.Wrap(err, "failed to create Markdown report template") 305 } 306 buf := new(bytes.Buffer) 307 err = tmpl.Execute(buf, s) 308 if err != nil { 309 return report, errors.Wrap(err, "failed to execute Markdown report template") 310 } 311 return buf.Bytes(), nil 312 } 313 314 func tableColumnCount(scanDetails ScanDetailTable) int { 315 colCount := len(scanDetails.Headers) 316 if scanDetails.WithCounter { 317 colCount++ 318 } 319 return colCount 320 } 321 322 func drawCell(cell ScanCell) string { 323 if cell.Style > 0 { 324 return fmt.Sprintf(`<td class="%v">%v</td>`, cell.Style, cell.Content) 325 } 326 return fmt.Sprintf(`<td>%v</td>`, cell.Content) 327 } 328 329 func shouldDrawTable(table ScanDetailTable) bool { 330 if len(table.Headers) > 0 { 331 return true 332 } 333 return false 334 } 335 336 func drawOverviewRow(row OverviewRow) string { 337 // so far accept only accept max. two columns for overview table: description and content 338 if len(row.Details) == 0 { 339 return row.Description 340 } 341 // ToDo: allow styling of details 342 return fmt.Sprintf("%v: %v", row.Description, row.Details) 343 } 344 345 func drawOverviewRowMarkdown(row OverviewRow) string { 346 // so far accept only accept max. two columns for overview table: description and content 347 if len(row.Details) == 0 { 348 return row.Description 349 } 350 // ToDo: allow styling of details 351 return fmt.Sprintf("<tr><td>%v:</td><td>%v</td></tr>", row.Description, row.Details) 352 }