Skip to content

Commit

Permalink
changelog as html (#427)
Browse files Browse the repository at this point in the history
  • Loading branch information
Reuven Harrison authored Nov 15, 2023
1 parent 82da6e2 commit 993c772
Show file tree
Hide file tree
Showing 13 changed files with 290 additions and 10 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ oasdiff checks

### Output Formats
The default changelog format is human-readable text.
You can specify the `--format` flag to output the changelog as json or yaml.
You can specify the `--format` flag to output the changelog as json, yaml or html.

### Customizing the Changelog
If you encounter a change that isn't logged by oasdiff you may add a [custom check](CUSTOMIZING-CHECKS.md).
4 changes: 4 additions & 0 deletions checker/api_change.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type ApiChange struct {
SourceColumnEnd int `json:"-" yaml:"-"`
}

func (c ApiChange) IsBreaking() bool {
return c.GetLevel().IsBreaking()
}

func (c ApiChange) MatchIgnore(ignorePath, ignoreLine string) bool {
if ignorePath == "" {
return false
Expand Down
1 change: 1 addition & 0 deletions checker/change.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package checker

type Change interface {
IsBreaking() bool
GetId() string
GetText() string
GetComment() string
Expand Down
12 changes: 8 additions & 4 deletions checker/changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package checker

type Changes []Change

func (changes Changes) Group() GroupedChanges {
return groupChanges(changes)
}

func (changes Changes) HasLevelOrHigher(level Level) bool {
for _, e := range changes {
if e.GetLevel() >= level {
for _, change := range changes {
if change.GetLevel() >= level {
return true
}
}
Expand All @@ -13,8 +17,8 @@ func (changes Changes) HasLevelOrHigher(level Level) bool {

func (changes Changes) GetLevelCount() map[Level]int {
counts := map[Level]int{}
for _, err := range changes {
level := err.GetLevel()
for _, change := range changes {
level := change.GetLevel()
counts[level] = counts[level] + 1
}
return counts
Expand Down
36 changes: 36 additions & 0 deletions checker/changes_by_endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package checker

type Endpoint struct {
Path string
Operation string
}

type ChangesByEndpoint map[Endpoint]*Changes

type GroupedChanges struct {
APIChanges ChangesByEndpoint
}

func newGroupedChanges() GroupedChanges {
return GroupedChanges{
APIChanges: ChangesByEndpoint{},
}
}

func groupChanges(changes Changes) GroupedChanges {

result := newGroupedChanges()

for _, change := range changes {
switch change.(type) {
case ApiChange:
ep := Endpoint{Path: change.GetPath(), Operation: change.GetOperation()}
if c, ok := result.APIChanges[ep]; ok {
*c = append(*c, change)
} else {
result.APIChanges[ep] = &Changes{change}
}
}
}
return result
}
61 changes: 61 additions & 0 deletions checker/changes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package checker_test

import (
"sort"
"testing"

"github.com/stretchr/testify/require"
"github.com/tufin/oasdiff/checker"
)

var changes = checker.Changes{
checker.ApiChange{
Id: "api-deleted",
Text: "API deleted",
Comment: "",
Level: checker.ERR,
Operation: "GET",
Path: "/test",
},
checker.ApiChange{
Id: "api-added",
Text: "API added",
Comment: "",
Level: checker.INFO,
Operation: "GET",
Path: "/test",
},
checker.ComponentChange{
Id: "component-added",
Text: "component added",
Comment: "",
Level: checker.INFO,
},
checker.SecurityChange{
Id: "security-added",
Text: "security added",
Comment: "",
Level: checker.INFO,
},
}

func TestChanges_Sort(t *testing.T) {
sort.Sort(changes)
}

func TestChanges_IsBreaking(t *testing.T) {
for _, c := range changes {
require.True(t, c.IsBreaking() == (c.GetLevel() != checker.INFO))
}
}

func TestChanges_Count(t *testing.T) {
lc := changes.GetLevelCount()
require.Equal(t, 3, lc[checker.INFO])
require.Equal(t, 0, lc[checker.WARN])
require.Equal(t, 1, lc[checker.ERR])
}

func TestChanges_Group(t *testing.T) {
require.Contains(t, changes.Group().APIChanges, checker.Endpoint{Path: "/test", Operation: "GET"})
}
4 changes: 4 additions & 0 deletions checker/component_change.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ type ComponentChange struct {
SourceColumnEnd int `json:"-" yaml:"-"`
}

func (c ComponentChange) IsBreaking() bool {
return c.GetLevel().IsBreaking()
}

func (c ComponentChange) MatchIgnore(ignorePath, ignoreLine string) bool {
return strings.Contains(ignoreLine, strings.ToLower(GetUncolorizedText(c))) &&
strings.Contains(ignoreLine, "components")
Expand Down
4 changes: 4 additions & 0 deletions checker/level.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ func (level Level) String() string {
}
}

func (level Level) IsBreaking() bool {
return level == ERR || level == WARN
}

func (level Level) PrettyString() string {
if IsPipedOutput() {
return level.String()
Expand Down
4 changes: 4 additions & 0 deletions checker/security_change.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ type SecurityChange struct {
SourceColumnEnd int `json:"-" yaml:"-"`
}

func (c SecurityChange) IsBreaking() bool {
return c.GetLevel().IsBreaking()
}

func (c SecurityChange) MatchIgnore(ignorePath, ignoreLine string) bool {
return strings.Contains(ignoreLine, strings.ToLower(GetUncolorizedText(c))) &&
strings.Contains(ignoreLine, "security")
Expand Down
21 changes: 20 additions & 1 deletion formatters/format_html.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package formatters

import (
"bytes"
"fmt"
"html/template"

_ "embed"

"github.com/tufin/oasdiff/checker"
"github.com/tufin/oasdiff/diff"
"github.com/tufin/oasdiff/report"
)
Expand All @@ -20,6 +25,20 @@ func (f HTMLFormatter) RenderDiff(diff *diff.Diff, opts RenderOpts) ([]byte, err
return []byte(reportAsString), nil
}

//go:embed templates/changelog.html
var changelog string

func (f HTMLFormatter) RenderChangelog(changes checker.Changes, opts RenderOpts) ([]byte, error) {
tmpl := template.Must(template.New("changelog").Parse(changelog))

var out bytes.Buffer
if err := tmpl.Execute(&out, changes.Group()); err != nil {
return nil, err
}

return out.Bytes(), nil
}

func (f HTMLFormatter) SupportedOutputs() []Output {
return []Output{OutputDiff}
return []Output{OutputDiff, OutputChangelog}
}
20 changes: 17 additions & 3 deletions formatters/format_html_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,24 @@ func TestHtmlFormatter_RenderDiff(t *testing.T) {
out, err := formatter.RenderDiff(nil, formatters.RenderOpts{})
require.NoError(t, err)
require.Equal(t, string(out), "<p>No changes</p>\n")
}

func TestHtmlFormatter_RenderChangelog(t *testing.T) {
formatter := formatters.HTMLFormatter{}

testChanges := checker.Changes{
checker.ApiChange{
Path: "/test",
Operation: "GET",
Id: "change_id",
Text: "This is a breaking change.",
Level: checker.ERR,
},
}

out, err := formatter.RenderChangelog(testChanges, formatters.RenderOpts{})
require.NoError(t, err)
require.NotEmpty(t, string(out))
}

func TestHtmlFormatter_NotImplemented(t *testing.T) {
Expand All @@ -25,9 +42,6 @@ func TestHtmlFormatter_NotImplemented(t *testing.T) {
_, err = formatter.RenderBreakingChanges(checker.Changes{}, formatters.RenderOpts{})
assert.Error(t, err)

_, err = formatter.RenderChangelog(checker.Changes{}, formatters.RenderOpts{})
assert.Error(t, err)

_, err = formatter.RenderChecks(formatters.Checks{}, formatters.RenderOpts{})
assert.Error(t, err)

Expand Down
3 changes: 2 additions & 1 deletion formatters/interface_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ func TestSummaryOutputFormats(t *testing.T) {

func TestChangelogOutputFormats(t *testing.T) {
supportedFormats := SupportedFormatsByContentType(OutputChangelog)
assert.Len(t, supportedFormats, 3)
assert.Len(t, supportedFormats, 4)
assert.Contains(t, supportedFormats, string(FormatYAML))
assert.Contains(t, supportedFormats, string(FormatJSON))
assert.Contains(t, supportedFormats, string(FormatText))
assert.Contains(t, supportedFormats, string(FormatHTML))
}

func TestBreakingChangesOutputFormats(t *testing.T) {
Expand Down
128 changes: 128 additions & 0 deletions formatters/templates/changelog.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<html>

<head>
<style>
@import url('https://fonts.cdnfonts.com/css/euclid-circular-a');

.title {
margin: 1em 0 0.5em 0;
font-family: 'Ultra', sans-serif;
font-size: 36px;
text-transform: uppercase;
}

.path {
color: #016BF8;
font-size: 18px;
font-weight: 600;
font-family: 'Euclid Circular A','Helvetica Neue',Helvetica,Arial,sans-serif;
}

.endpoint {
color: #21313c;
font-family: Euclid Circular A,Helvetica Neue,Helvetica,Arial,sans-serif;
line-height: 24px;
margin: 22px 0;
}

.endpoint-header {
display: inline-flex;
align-items: center;
gap: 5px;
}

.change-type {
box-sizing: border-box;
font-weight: 700;
font-size: 12px;
line-height: 16px;
border-radius: 5px;
height: 18px;
padding-left: 6px;
padding-right: 6px;
text-transform: uppercase;
border: 1px solid;
letter-spacing: 1px;
background-color: #E3FCF7;
border-color: #C0FAE6;
color: #00684A;
margin-top: 2px;
}

.change {
}

.breaking {
display: inline-flex;
align-items: center;
gap: 5px;
margin-right: 5px;
}

.breaking-icon {
color: #DB3030;
}

.endpoint-changes {
}

.tooltip {
position:relative; /* making the .tooltip span a container for the tooltip text */
}

.tooltip:before {
content: attr(data-text); /* here's the magic */
position:absolute;

/* vertically center */
top:50%;
transform:translateY(-50%);

/* move to right */
left:100%;
margin-left:15px; /* and add a small left margin */

/* basic styles */
width:200px;
padding:10px;
border-radius:10px;
background:#000;
color: #fff;
text-align:center;

display:none; /* hide by default */
}

.tooltip:hover:before {
display:block;
}
</style>
</head>

<body>
<div class="title">Changelog</div>
{{ range $endpoint, $changes := .APIChanges }}
<div class="endpoint">
<div class="endpoint-header">
<span class="path">
<div class="">{{ $endpoint.Operation }}<!-- --> <!-- -->{{ $endpoint.Path }}</div>
</span>
<div class="change-type">Updated</div>
</div>
<ul class="endpoint-changes">
{{ range $changes }}
<li class="change">
{{ if .IsBreaking }}
<div class="breaking tooltip" data-text="Breaking Change">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16" class="breaking-icon" role="img" aria-label="Important With Circle Icon"><path fill="currentColor" fill-rule="evenodd" d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14ZM7 4.5a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0v-4Zm2 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" clip-rule="evenodd"></path></svg>
</div>
{{ end }}
{{ .GetText }}
</li>
{{ end }}
</ul>
</div>
{{ end }}
</body>

</html>

0 comments on commit 993c772

Please sign in to comment.