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  }